/ 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  }