/ GUNRPG.Tests / AccuracyProficiencyTests.cs
AccuracyProficiencyTests.cs
1 using GUNRPG.Core; 2 using GUNRPG.Core.Combat; 3 using GUNRPG.Core.Intents; 4 using GUNRPG.Core.Operators; 5 using GUNRPG.Core.Weapons; 6 using Xunit; 7 8 namespace GUNRPG.Tests; 9 10 /// <summary> 11 /// Tests for the Operator Accuracy Proficiency system. 12 /// Validates that proficiency affects recoil control and aim stability 13 /// without modifying weapon base stats. 14 /// </summary> 15 public class AccuracyProficiencyTests 16 { 17 [Fact] 18 public void Operator_DefaultAccuracyProficiency_IsMidRange() 19 { 20 var op = new Operator("Test"); 21 22 // Default proficiency should be 0.5 (mid-range) 23 Assert.Equal(0.5f, op.AccuracyProficiency); 24 } 25 26 [Fact] 27 public void AccuracyProficiency_ClampedToValidRange() 28 { 29 var op = new Operator("Test"); 30 31 op.AccuracyProficiency = 1.5f; 32 Assert.Equal(1.0f, op.AccuracyProficiency); 33 34 op.AccuracyProficiency = -0.5f; 35 Assert.Equal(0.0f, op.AccuracyProficiency); 36 } 37 38 [Fact] 39 public void AccuracyModel_CalculateAimErrorStdDev_ScalesWithProficiency() 40 { 41 // At proficiency 0: full aim error (BaseAimErrorScale = 0.15) 42 float lowProfError = AccuracyModel.CalculateAimErrorStdDev(0f); 43 44 // At proficiency 1: reduced aim error by MaxAimErrorReductionFactor (50%) 45 // 0.15 * (1 - 0.5) = 0.075 46 float highProfError = AccuracyModel.CalculateAimErrorStdDev(1f); 47 48 Assert.True(lowProfError > highProfError, 49 $"Low proficiency error ({lowProfError}) should be greater than high proficiency error ({highProfError})"); 50 Assert.Equal(0.15f, lowProfError, 3); 51 Assert.Equal(0.075f, highProfError, 3); 52 } 53 54 [Fact] 55 public void AccuracyModel_CalculateAimErrorStdDev_WithAccuracyAndProficiency() 56 { 57 // Low accuracy (0.5), low proficiency (0): (1 - 0.5) * 0.15 * 1.0 = 0.075 58 float lowLow = AccuracyModel.CalculateAimErrorStdDev(0.5f, 0f); 59 Assert.Equal(0.075f, lowLow, 3); 60 61 // Low accuracy (0.5), high proficiency (1): (1 - 0.5) * 0.15 * 0.5 = 0.0375 62 float lowHigh = AccuracyModel.CalculateAimErrorStdDev(0.5f, 1f); 63 Assert.Equal(0.0375f, lowHigh, 3); 64 65 // High accuracy (1.0), any proficiency: (1 - 1.0) * 0.15 * anything = 0 66 float highAny = AccuracyModel.CalculateAimErrorStdDev(1.0f, 0.5f); 67 Assert.Equal(0f, highAny, 3); 68 } 69 70 [Fact] 71 public void AccuracyModel_CalculateEffectiveRecoil_ReducesWithProficiency() 72 { 73 float weaponRecoil = 0.5f; 74 75 // At proficiency 0: no reduction (1.0 * weaponRecoil) 76 float noReduction = AccuracyModel.CalculateEffectiveRecoil(weaponRecoil, 0f); 77 Assert.Equal(weaponRecoil, noReduction, 3); 78 79 // At proficiency 1: 60% reduction (0.4 * weaponRecoil) 80 float maxReduction = AccuracyModel.CalculateEffectiveRecoil(weaponRecoil, 1f); 81 Assert.Equal(weaponRecoil * 0.4f, maxReduction, 3); 82 83 // At proficiency 0.5: 30% reduction (0.7 * weaponRecoil) 84 float midReduction = AccuracyModel.CalculateEffectiveRecoil(weaponRecoil, 0.5f); 85 Assert.Equal(weaponRecoil * 0.7f, midReduction, 3); 86 } 87 88 [Fact] 89 public void AccuracyModel_CalculateRecoveryRateMultiplier_IncreasesWithProficiency() 90 { 91 // At proficiency 0: 0.5x recovery rate 92 float lowMultiplier = AccuracyModel.CalculateRecoveryRateMultiplier(0f); 93 Assert.Equal(0.5f, lowMultiplier, 3); 94 95 // At proficiency 1: 2.0x recovery rate 96 float highMultiplier = AccuracyModel.CalculateRecoveryRateMultiplier(1f); 97 Assert.Equal(2.0f, highMultiplier, 3); 98 99 // At proficiency 0.5: 1.25x recovery rate 100 float midMultiplier = AccuracyModel.CalculateRecoveryRateMultiplier(0.5f); 101 Assert.Equal(1.25f, midMultiplier, 3); 102 } 103 104 [Fact] 105 public void AccuracyModel_ApplyRecovery_RecoversFasterWithHighProficiency() 106 { 107 float currentRecoil = 1.0f; 108 float baseRecovery = 0.2f; 109 110 // At proficiency 0: 0.5x recovery rate -> 0.1 recovered 111 float lowProfRecoil = AccuracyModel.ApplyRecovery(currentRecoil, baseRecovery, 0f); 112 Assert.Equal(0.9f, lowProfRecoil, 3); 113 114 // At proficiency 1: 2.0x recovery rate -> 0.4 recovered 115 float highProfRecoil = AccuracyModel.ApplyRecovery(currentRecoil, baseRecovery, 1f); 116 Assert.Equal(0.6f, highProfRecoil, 3); 117 } 118 119 [Fact] 120 public void HitResolution_ResolveShotWithProficiency_ReducesEffectiveRecoil() 121 { 122 // Test that high proficiency reduces effective vertical recoil 123 float weaponRecoil = 0.5f; 124 float currentRecoil = 0.2f; 125 126 var lowProfResults = new List<float>(); 127 var highProfResults = new List<float>(); 128 129 for (int i = 0; i < 50; i++) 130 { 131 // Low proficiency shot 132 var lowResult = HitResolution.ResolveShotWithProficiency( 133 BodyPart.UpperTorso, 134 1.0f, // Perfect accuracy (no aim error) 135 0.0f, // Low proficiency 136 weaponRecoil, 137 currentRecoil, 138 0f, // No variance 139 new Random(i)); 140 lowProfResults.Add(lowResult.FinalAngleDegrees); 141 142 // High proficiency shot 143 var highResult = HitResolution.ResolveShotWithProficiency( 144 BodyPart.UpperTorso, 145 1.0f, // Perfect accuracy (no aim error) 146 1.0f, // High proficiency 147 weaponRecoil, 148 currentRecoil, 149 0f, // No variance 150 new Random(i)); 151 highProfResults.Add(highResult.FinalAngleDegrees); 152 } 153 154 float avgLowProf = lowProfResults.Average(); 155 float avgHighProf = highProfResults.Average(); 156 157 // High proficiency should result in lower final angles (less recoil effect) 158 Assert.True(avgHighProf < avgLowProf, 159 $"High proficiency average ({avgHighProf:F4}) should be lower than low proficiency ({avgLowProf:F4})"); 160 } 161 162 [Fact] 163 public void HitResolution_ResolveShotWithProficiency_TightensAimDistribution() 164 { 165 // Test that high proficiency tightens aim error distribution 166 var lowProfResults = new List<float>(); 167 var highProfResults = new List<float>(); 168 169 for (int i = 0; i < 100; i++) 170 { 171 // Low proficiency shot (with medium accuracy to see aim error effects) 172 var lowResult = HitResolution.ResolveShotWithProficiency( 173 BodyPart.LowerTorso, 174 0.5f, // Medium accuracy 175 0.0f, // Low proficiency 176 0f, // No weapon recoil 177 0f, // No accumulated recoil 178 0f, // No variance 179 new Random(i)); 180 lowProfResults.Add(lowResult.FinalAngleDegrees); 181 182 // High proficiency shot 183 var highResult = HitResolution.ResolveShotWithProficiency( 184 BodyPart.LowerTorso, 185 0.5f, // Medium accuracy 186 1.0f, // High proficiency 187 0f, // No weapon recoil 188 0f, // No accumulated recoil 189 0f, // No variance 190 new Random(i)); 191 highProfResults.Add(highResult.FinalAngleDegrees); 192 } 193 194 // Calculate standard deviation of results 195 float lowProfStdDev = CalculateStdDev(lowProfResults); 196 float highProfStdDev = CalculateStdDev(highProfResults); 197 198 // High proficiency should have tighter distribution (lower std dev) 199 Assert.True(highProfStdDev < lowProfStdDev, 200 $"High proficiency std dev ({highProfStdDev:F4}) should be lower than low proficiency ({lowProfStdDev:F4})"); 201 } 202 203 [Fact] 204 public void HitResolution_ResolveShotWithProficiency_DoesNotAffectBodyPartBands() 205 { 206 // Test that body part bands remain unchanged regardless of proficiency 207 var random = new Random(42); 208 209 var testCases = new[] 210 { 211 (BodyPart.LowerTorso, 0.00f, 0.25f), 212 (BodyPart.UpperTorso, 0.25f, 0.50f), 213 (BodyPart.Neck, 0.50f, 0.75f), 214 (BodyPart.Head, 0.75f, 1.00f) 215 }; 216 217 foreach (var (targetPart, minAngle, maxAngle) in testCases) 218 { 219 // Perfect accuracy and proficiency, no recoil - should hit target band 220 var result = HitResolution.ResolveShotWithProficiency( 221 targetPart, 222 1.0f, // Perfect accuracy 223 1.0f, // Perfect proficiency 224 0f, // No recoil 225 0f, // No accumulated recoil 226 0f, // No variance 227 random); 228 229 Assert.True(result.FinalAngleDegrees >= minAngle - 0.05f && 230 result.FinalAngleDegrees <= maxAngle + 0.05f, 231 $"Target {targetPart} (band {minAngle}-{maxAngle}°) resulted in angle {result.FinalAngleDegrees}°"); 232 } 233 } 234 235 [Fact] 236 public void HitResolution_WeaponRecoilValuesRemainUnchanged() 237 { 238 // Verify that weapon base stats are not modified by proficiency 239 var weapon = WeaponFactory.CreateSturmwolf45(); 240 float originalRecoil = weapon.VerticalRecoil; 241 242 // Create operators with different proficiencies 243 var lowProfOp = new Operator("LowProf") { AccuracyProficiency = 0f }; 244 var highProfOp = new Operator("HighProf") { AccuracyProficiency = 1f }; 245 246 // Fire shots (simulated) 247 var random = new Random(42); 248 HitResolution.ResolveShotWithProficiency( 249 BodyPart.UpperTorso, lowProfOp.Accuracy, lowProfOp.AccuracyProficiency, 250 weapon.VerticalRecoil, 0f, 0f, random); 251 252 HitResolution.ResolveShotWithProficiency( 253 BodyPart.UpperTorso, highProfOp.Accuracy, highProfOp.AccuracyProficiency, 254 weapon.VerticalRecoil, 0f, 0f, random); 255 256 // Weapon recoil should remain unchanged 257 Assert.Equal(originalRecoil, weapon.VerticalRecoil); 258 } 259 260 [Fact] 261 public void Operator_RecoilRecovery_AffectedByProficiency() 262 { 263 // Test that recoil recovery is affected by AccuracyProficiency 264 var lowProfOp = new Operator("LowProf") 265 { 266 AccuracyProficiency = 0f, 267 CurrentRecoilY = 1.0f, 268 RecoilRecoveryStartMs = 0, 269 RecoilRecoveryRate = 1.0f 270 }; 271 272 var highProfOp = new Operator("HighProf") 273 { 274 AccuracyProficiency = 1f, 275 CurrentRecoilY = 1.0f, 276 RecoilRecoveryStartMs = 0, 277 RecoilRecoveryRate = 1.0f 278 }; 279 280 // Update regeneration for 1 second 281 lowProfOp.UpdateRegeneration(1000, 1000); 282 highProfOp.UpdateRegeneration(1000, 1000); 283 284 // Low proficiency: 0.5x recovery (1.0 * 1.0 * 0.5 = 0.5 recovered -> 0.5 remaining) 285 Assert.True(lowProfOp.CurrentRecoilY > highProfOp.CurrentRecoilY, 286 $"Low prof recoil ({lowProfOp.CurrentRecoilY:F3}) should be > high prof ({highProfOp.CurrentRecoilY:F3})"); 287 288 // High proficiency: 2.0x recovery (1.0 * 1.0 * 2.0 = 2.0 recovered -> 0 remaining) 289 Assert.Equal(0f, highProfOp.CurrentRecoilY, 3); 290 } 291 292 [Fact] 293 public void AccuracyProficiency_DoesNotAffectDamage() 294 { 295 var weapon = WeaponFactory.CreateSturmwolf45(); 296 297 // Damage should be the same regardless of proficiency 298 float damageAtDistance = weapon.GetDamageAtDistance(15f, BodyPart.UpperTorso); 299 300 var lowProfOp = new Operator("LowProf") { AccuracyProficiency = 0f, EquippedWeapon = weapon }; 301 var highProfOp = new Operator("HighProf") { AccuracyProficiency = 1f, EquippedWeapon = weapon }; 302 303 // Damage retrieval should be unaffected by operator proficiency 304 Assert.Equal(damageAtDistance, lowProfOp.EquippedWeapon.GetDamageAtDistance(15f, BodyPart.UpperTorso)); 305 Assert.Equal(damageAtDistance, highProfOp.EquippedWeapon.GetDamageAtDistance(15f, BodyPart.UpperTorso)); 306 } 307 308 [Fact] 309 public void AccuracyProficiency_DoesNotAffectFireRate() 310 { 311 var weapon = WeaponFactory.CreateSturmwolf45(); 312 float originalFireRate = weapon.RoundsPerMinute; 313 float originalTimeBetweenShots = weapon.GetTimeBetweenShotsMs(); 314 315 var lowProfOp = new Operator("LowProf") { AccuracyProficiency = 0f, EquippedWeapon = weapon }; 316 var highProfOp = new Operator("HighProf") { AccuracyProficiency = 1f, EquippedWeapon = weapon }; 317 318 // Fire rate should be unaffected by operator proficiency 319 Assert.Equal(originalFireRate, lowProfOp.EquippedWeapon.RoundsPerMinute); 320 Assert.Equal(originalFireRate, highProfOp.EquippedWeapon.RoundsPerMinute); 321 Assert.Equal(originalTimeBetweenShots, lowProfOp.EquippedWeapon.GetTimeBetweenShotsMs()); 322 Assert.Equal(originalTimeBetweenShots, highProfOp.EquippedWeapon.GetTimeBetweenShotsMs()); 323 } 324 325 [Fact] 326 public void AccuracyModel_SampleAimError_DeterministicWithSeed() 327 { 328 // Verify deterministic behavior with same seed 329 var model1 = new AccuracyModel(new Random(42)); 330 var model2 = new AccuracyModel(new Random(42)); 331 332 float error1 = model1.SampleAimError(0.5f); 333 float error2 = model2.SampleAimError(0.5f); 334 335 Assert.Equal(error1, error2); 336 } 337 338 [Fact] 339 public void ResolveShotWithProficiency_Deterministic_SameSeedSameResult() 340 { 341 float operatorAccuracy = 0.7f; 342 float proficiency = 0.5f; 343 float weaponVerticalRecoil = 0.15f; 344 float currentRecoilY = 0.05f; 345 float recoilVariance = 0.05f; 346 347 var result1 = HitResolution.ResolveShotWithProficiency( 348 BodyPart.UpperTorso, operatorAccuracy, proficiency, weaponVerticalRecoil, 349 currentRecoilY, recoilVariance, new Random(999)); 350 351 var result2 = HitResolution.ResolveShotWithProficiency( 352 BodyPart.UpperTorso, operatorAccuracy, proficiency, weaponVerticalRecoil, 353 currentRecoilY, recoilVariance, new Random(999)); 354 355 Assert.Equal(result1.HitLocation, result2.HitLocation); 356 Assert.Equal(result1.FinalAngleDegrees, result2.FinalAngleDegrees); 357 } 358 359 [Fact] 360 public void RecoilRecovery_OccursEvenWhenRoundEndsEarly() 361 { 362 // Arrange: Create combat where a hit ends the round quickly 363 var player = new Operator("Player") 364 { 365 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 366 CurrentAmmo = 30, 367 DistanceToOpponent = 15f, 368 AccuracyProficiency = 0.5f, 369 Accuracy = 1.0f // Ensure hit 370 }; 371 var enemy = new Operator("Enemy") 372 { 373 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 374 CurrentAmmo = 30, 375 DistanceToOpponent = 15f, 376 AccuracyProficiency = 0.5f, 377 Accuracy = 0.0f // Ensure miss so only player hits 378 }; 379 380 // Initial recoil should be zero 381 Assert.Equal(0f, player.CurrentRecoilY); 382 383 var combat = new CombatSystemV2(player, enemy, seed: 42); 384 385 // Act: Fire a shot (round will end on hit) 386 var playerIntents = new SimultaneousIntents(player.Id) 387 { 388 Primary = PrimaryAction.Fire, 389 Movement = MovementAction.Stand 390 }; 391 var enemyIntents = new SimultaneousIntents(enemy.Id) 392 { 393 Primary = PrimaryAction.Fire, 394 Movement = MovementAction.Stand 395 }; 396 397 combat.SubmitIntents(player, playerIntents); 398 combat.SubmitIntents(enemy, enemyIntents); 399 combat.BeginExecution(); 400 combat.ExecuteUntilReactionWindow(); 401 402 // Assert: Recoil should have been applied and then partially recovered 403 // The weapon has ~0.15 recoil, and immediate recovery happens 404 // With 0.5 proficiency and RecoilRecoveryRate of 5, recovery should reduce recoil 405 Assert.True(player.CurrentRecoilY >= 0, 406 $"Recoil should not be negative. Current: {player.CurrentRecoilY}"); 407 408 // Recoil should be less than the raw weapon recoil due to immediate recovery 409 var weapon = player.EquippedWeapon!; 410 Assert.True(player.CurrentRecoilY < weapon.VerticalRecoil * 2, 411 $"Recoil ({player.CurrentRecoilY}) should be controlled after shot. Raw weapon recoil: {weapon.VerticalRecoil}"); 412 } 413 414 [Fact] 415 public void RecoilRecovery_HighProficiency_RecoversFasterPerShot() 416 { 417 // Create operators with different proficiencies 418 var lowProfOp = new Operator("LowProf") 419 { 420 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 421 CurrentAmmo = 30, 422 AccuracyProficiency = 0.1f, 423 RecoilRecoveryRate = 5f 424 }; 425 var highProfOp = new Operator("HighProf") 426 { 427 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 428 CurrentAmmo = 30, 429 AccuracyProficiency = 1.0f, 430 RecoilRecoveryRate = 5f 431 }; 432 433 // Simulate firing without full combat system to isolate the effect 434 var weapon = lowProfOp.EquippedWeapon!; 435 436 // Add same recoil to both 437 lowProfOp.CurrentRecoilY = weapon.VerticalRecoil; 438 highProfOp.CurrentRecoilY = weapon.VerticalRecoil; 439 440 // Simulate immediate recovery (100ms worth) 441 const float recoveryTimeSeconds = 0.1f; 442 float lowProfMultiplier = AccuracyModel.CalculateRecoveryRateMultiplier(lowProfOp.AccuracyProficiency); 443 float highProfMultiplier = AccuracyModel.CalculateRecoveryRateMultiplier(highProfOp.AccuracyProficiency); 444 445 float lowProfRecovery = lowProfOp.RecoilRecoveryRate * recoveryTimeSeconds * lowProfMultiplier; 446 float highProfRecovery = highProfOp.RecoilRecoveryRate * recoveryTimeSeconds * highProfMultiplier; 447 448 lowProfOp.CurrentRecoilY = Math.Max(0, lowProfOp.CurrentRecoilY - lowProfRecovery); 449 highProfOp.CurrentRecoilY = Math.Max(0, highProfOp.CurrentRecoilY - highProfRecovery); 450 451 // High proficiency should have recovered more (lower remaining recoil) 452 Assert.True(highProfOp.CurrentRecoilY < lowProfOp.CurrentRecoilY, 453 $"High prof recoil ({highProfOp.CurrentRecoilY}) should be < low prof ({lowProfOp.CurrentRecoilY})"); 454 } 455 456 [Fact] 457 public void Operator_MinRecommendedAccuracyProficiency_Constant() 458 { 459 // Verify the constant exists and has a sensible value 460 Assert.True(Operator.MinRecommendedAccuracyProficiency > 0f, 461 "MinRecommendedAccuracyProficiency should be > 0"); 462 Assert.True(Operator.MinRecommendedAccuracyProficiency <= 0.5f, 463 "MinRecommendedAccuracyProficiency should not be too high"); 464 } 465 466 [Fact] 467 public void Operator_HasLowAccuracyProficiency_DetectsLowValues() 468 { 469 var op = new Operator("Test"); 470 471 // Default proficiency (0.5) should not be low 472 Assert.False(op.HasLowAccuracyProficiency, 473 "Default proficiency should not be flagged as low"); 474 475 // Zero proficiency should be flagged as low 476 op.AccuracyProficiency = 0f; 477 Assert.True(op.HasLowAccuracyProficiency, 478 "Zero proficiency should be flagged as low"); 479 480 // Just below threshold should be flagged as low 481 op.AccuracyProficiency = Operator.MinRecommendedAccuracyProficiency - 0.01f; 482 Assert.True(op.HasLowAccuracyProficiency, 483 "Proficiency below threshold should be flagged as low"); 484 485 // At threshold should NOT be flagged as low 486 op.AccuracyProficiency = Operator.MinRecommendedAccuracyProficiency; 487 Assert.False(op.HasLowAccuracyProficiency, 488 "Proficiency at threshold should not be flagged as low"); 489 } 490 491 private static float CalculateStdDev(List<float> values) 492 { 493 if (values.Count == 0) return 0; 494 float mean = values.Average(); 495 float sumSquaredDiff = values.Sum(v => (v - mean) * (v - mean)); 496 return (float)Math.Sqrt(sumSquaredDiff / values.Count); 497 } 498 }