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