/ GUNRPG.Tests / HitResolutionTests.cs
HitResolutionTests.cs
  1  using GUNRPG.Core.Combat;
  2  using GUNRPG.Core.Weapons;
  3  using Xunit;
  4  
  5  namespace GUNRPG.Tests;
  6  
  7  public class HitResolutionTests
  8  {
  9      [Fact]
 10      public void ResolveShot_PerfectAimNoRecoil_HitsTargetBodyPart()
 11      {
 12          // Arrange
 13          var random = new Random(42);
 14          float operatorAccuracy = 1.0f; // Perfect accuracy (aim error std dev = 0)
 15          float weaponVerticalRecoil = 0f; // No recoil
 16          float currentRecoilY = 0f;
 17          float recoilVariance = 0f;
 18  
 19          // Act - Aim at lower torso (center: 0.125°)
 20          // With perfect accuracy and no recoil, shot should be deterministic
 21          var result = HitResolution.ResolveShot(
 22              BodyPart.LowerTorso,
 23              operatorAccuracy,
 24              weaponVerticalRecoil,
 25              currentRecoilY,
 26              recoilVariance,
 27              random);
 28  
 29          // Assert - With perfect accuracy and no recoil, should hit exactly the target
 30          // Perfect accuracy means aim error standard deviation is 0, making the shot deterministic
 31          Assert.Equal(BodyPart.LowerTorso, result.HitLocation);
 32      }
 33  
 34      [Fact]
 35      public void ResolveShot_WithVerticalRecoil_HitsHigherBodyPart()
 36      {
 37          // Arrange
 38          var random = new Random(42);
 39          float operatorAccuracy = 1.0f; // Perfect accuracy
 40          float weaponVerticalRecoil = 0.3f; // Moderate upward recoil
 41          float currentRecoilY = 0f;
 42          float recoilVariance = 0f;
 43  
 44          // Act - Aim at lower torso (center: 0.125°)
 45          // With 0.3° recoil, should hit upper torso or neck
 46          var result = HitResolution.ResolveShot(
 47              BodyPart.LowerTorso,
 48              operatorAccuracy,
 49              weaponVerticalRecoil,
 50              currentRecoilY,
 51              recoilVariance,
 52              random);
 53  
 54          // Assert
 55          Assert.True(result.HitLocation == BodyPart.UpperTorso || 
 56                      result.HitLocation == BodyPart.Neck,
 57                      $"Expected UpperTorso or Neck with recoil, got {result.HitLocation}");
 58          Assert.True(result.FinalAngleDegrees > 0.25f, "Final angle should be higher than lower torso");
 59      }
 60  
 61      [Fact]
 62      public void ResolveShot_ExcessiveRecoil_MissesOvershoot()
 63      {
 64          // Arrange
 65          var random = new Random(42);
 66          float operatorAccuracy = 1.0f; // Perfect accuracy
 67          float weaponVerticalRecoil = 0.5f; // High recoil
 68          float currentRecoilY = 0.6f; // Accumulated recoil
 69          float recoilVariance = 0f;
 70  
 71          // Act - Total recoil > 1.0°, should miss
 72          var result = HitResolution.ResolveShot(
 73              BodyPart.LowerTorso,
 74              operatorAccuracy,
 75              weaponVerticalRecoil,
 76              currentRecoilY,
 77              recoilVariance,
 78              random);
 79  
 80          // Assert
 81          Assert.Equal(BodyPart.Miss, result.HitLocation);
 82          Assert.True(result.FinalAngleDegrees > 1.0f, "Should overshoot the head");
 83      }
 84  
 85      [Fact]
 86      public void ResolveShot_NegativeAngle_MissesUndershoot()
 87      {
 88          // Arrange
 89          float operatorAccuracy = 0.0f; // Poor accuracy = high variance
 90          float weaponVerticalRecoil = -0.5f; // Downward (unusual but testing bounds)
 91          float currentRecoilY = 0f;
 92          float recoilVariance = 0f;
 93  
 94          // Act - Multiple attempts to get a miss
 95          bool foundUndershoot = false;
 96          for (int i = 0; i < 100; i++)
 97          {
 98              var result = HitResolution.ResolveShot(
 99                  BodyPart.LowerTorso,
100                  operatorAccuracy,
101                  weaponVerticalRecoil,
102                  currentRecoilY,
103                  recoilVariance,
104                  new Random(i));
105  
106              if (result.HitLocation == BodyPart.Miss && result.FinalAngleDegrees < 0f)
107              {
108                  foundUndershoot = true;
109                  break;
110              }
111          }
112  
113          // Assert
114          Assert.True(foundUndershoot, "Should be able to undershoot with poor accuracy and negative recoil");
115      }
116  
117      [Fact]
118      public void ResolveShot_LowerAccuracy_HigherAimError()
119      {
120          // Arrange
121          float weaponVerticalRecoil = 0f;
122          float currentRecoilY = 0f;
123          float recoilVariance = 0f;
124  
125          // Act - Test multiple shots with different accuracy levels
126          var resultsHighAccuracy = new List<float>();
127          var resultsLowAccuracy = new List<float>();
128  
129          for (int i = 0; i < 50; i++)
130          {
131              var highResult = HitResolution.ResolveShot( BodyPart.LowerTorso, 0.9f, weaponVerticalRecoil, 
132                  currentRecoilY, recoilVariance, new Random(i));
133              resultsHighAccuracy.Add(Math.Abs(highResult.FinalAngleDegrees - 0.125f));
134  
135              var lowResult = HitResolution.ResolveShot( BodyPart.LowerTorso, 0.1f, weaponVerticalRecoil, 
136                  currentRecoilY, recoilVariance, new Random(i));
137              resultsLowAccuracy.Add(Math.Abs(lowResult.FinalAngleDegrees - 0.125f));
138          }
139  
140          // Assert - Low accuracy should have more deviation on average
141          float highAccuracyAvgError = resultsHighAccuracy.Average();
142          float lowAccuracyAvgError = resultsLowAccuracy.Average();
143  
144          Assert.True(lowAccuracyAvgError > highAccuracyAvgError,
145              $"Low accuracy error ({lowAccuracyAvgError:F4}) should be > high accuracy error ({highAccuracyAvgError:F4})");
146      }
147  
148      [Fact]
149      public void ResolveShot_WithRecoilVariance_ProducesDifferentResults()
150      {
151          // Arrange
152          float operatorAccuracy = 0.8f;
153          float weaponVerticalRecoil = 0.2f;
154          float currentRecoilY = 0f;
155          float recoilVariance = 0.1f; // 10% variance
156  
157          // Act - Multiple shots with different seeds produce variation from both RNG and recoil variance
158          var results = new List<float>();
159          for (int i = 0; i < 20; i++)
160          {
161              var result = HitResolution.ResolveShot(
162                  BodyPart.LowerTorso,
163                  operatorAccuracy,
164                  weaponVerticalRecoil,
165                  currentRecoilY,
166                  recoilVariance,
167                  new Random(i));
168              results.Add(result.FinalAngleDegrees);
169          }
170  
171          // Assert - Should have some variation in results
172          float stdDev = CalculateStdDev(results);
173          Assert.True(stdDev > 0.05f, $"Should have significant variation with variance, got stdDev: {stdDev}");
174      }
175  
176      [Fact]
177      public void ResolveShot_TargetHead_HigherBaseAngle()
178      {
179          // Arrange
180          var random = new Random(42);
181          float operatorAccuracy = 1.0f; // Perfect accuracy
182          float weaponVerticalRecoil = 0f;
183          float currentRecoilY = 0f;
184          float recoilVariance = 0f;
185  
186          // Act - Aim at head
187          var result = HitResolution.ResolveShot(
188              BodyPart.Head,
189              operatorAccuracy,
190              weaponVerticalRecoil,
191              currentRecoilY,
192              recoilVariance,
193              random);
194  
195          // Assert - With perfect aim and no recoil, should be near head region
196          Assert.True(result.FinalAngleDegrees >= 0.75f, 
197              $"Aiming at head should result in angle >= 0.75°, got {result.FinalAngleDegrees}");
198      }
199  
200      [Fact]
201      public void ResolveShot_AngularBands_CorrectMapping()
202      {
203          // Arrange
204          var random = new Random(42);
205          float operatorAccuracy = 1.0f;
206          float weaponVerticalRecoil = 0f;
207          float currentRecoilY = 0f;
208          float recoilVariance = 0f;
209  
210          // Act & Assert - Test each body part band
211          var testCases = new[]
212          {
213              (BodyPart.LowerTorso, 0.00f, 0.25f),
214              (BodyPart.UpperTorso, 0.25f, 0.50f),
215              (BodyPart.Neck, 0.50f, 0.75f),
216              (BodyPart.Head, 0.75f, 1.00f)
217          };
218  
219          foreach (var (targetPart, minAngle, maxAngle) in testCases)
220          {
221              var result = HitResolution.ResolveShot(
222                  targetPart, operatorAccuracy, weaponVerticalRecoil,
223                  currentRecoilY, recoilVariance, random);
224  
225              // With perfect accuracy and no recoil, should hit within or very close to target band
226              Assert.True(result.FinalAngleDegrees >= minAngle - 0.05f && 
227                         result.FinalAngleDegrees <= maxAngle + 0.05f,
228                  $"Target {targetPart} (band {minAngle}-{maxAngle}°) resulted in angle {result.FinalAngleDegrees}°");
229          }
230      }
231  
232      [Fact]
233      public void ResolveShot_Deterministic_SameSeedSameResult()
234      {
235          // Arrange
236          float operatorAccuracy = 0.7f;
237          float weaponVerticalRecoil = 0.15f;
238          float currentRecoilY = 0.05f;
239          float recoilVariance = 0.05f;
240  
241          // Act - Run twice with same seed
242          var result1 = HitResolution.ResolveShot( BodyPart.UpperTorso, operatorAccuracy, weaponVerticalRecoil,
243              currentRecoilY, recoilVariance, new Random(999));
244  
245          var result2 = HitResolution.ResolveShot( BodyPart.UpperTorso, operatorAccuracy, weaponVerticalRecoil,
246              currentRecoilY, recoilVariance, new Random(999));
247  
248          // Assert - Should be identical
249          Assert.Equal(result1.HitLocation, result2.HitLocation);
250          Assert.Equal(result1.FinalAngleDegrees, result2.FinalAngleDegrees);
251      }
252  
253      private static float CalculateStdDev(List<float> values)
254      {
255          if (values.Count == 0) return 0;
256          float mean = values.Average();
257          float sumSquaredDiff = values.Sum(v => (v - mean) * (v - mean));
258          return (float)Math.Sqrt(sumSquaredDiff / values.Count);
259      }
260  }