/ GUNRPG.Tests / HitResolutionIntegrationTests.cs
HitResolutionIntegrationTests.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 using Xunit.Abstractions; 8 9 namespace GUNRPG.Tests; 10 11 /// <summary> 12 /// Integration tests for the complete hit resolution system. 13 /// Validates that the system works end-to-end with operators and combat. 14 /// </summary> 15 public class HitResolutionIntegrationTests 16 { 17 private readonly ITestOutputHelper _output; 18 19 public HitResolutionIntegrationTests(ITestOutputHelper output) 20 { 21 _output = output; 22 } 23 24 [Fact] 25 public void CombatSystem_UsesNewHitResolution() 26 { 27 // Arrange: Create operators with known accuracy 28 var player = new Operator("Player") 29 { 30 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 31 CurrentAmmo = 30, 32 DistanceToOpponent = 15f, 33 Accuracy = 0.9f, // High accuracy 34 AccuracyProficiency = 0.5f // Explicit proficiency 35 }; 36 37 var enemy = new Operator("Enemy") 38 { 39 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 40 CurrentAmmo = 30, 41 DistanceToOpponent = 15f, 42 Accuracy = 0.9f, // High accuracy 43 AccuracyProficiency = 0.5f // Explicit proficiency 44 }; 45 46 var combat = new CombatSystemV2(player, enemy, seed: 42); 47 48 // Act: Execute a single round 49 var playerIntents = new SimultaneousIntents(player.Id) 50 { 51 Primary = PrimaryAction.Fire, 52 Movement = MovementAction.Stand 53 }; 54 55 var enemyIntents = new SimultaneousIntents(enemy.Id) 56 { 57 Primary = PrimaryAction.Fire, 58 Movement = MovementAction.Stand 59 }; 60 61 combat.SubmitIntents(player, playerIntents); 62 combat.SubmitIntents(enemy, enemyIntents); 63 combat.BeginExecution(); 64 combat.ExecuteUntilReactionWindow(); 65 66 // Assert: With high accuracy, at least one hit should occur 67 bool someoneWasHit = player.Health < player.MaxHealth || enemy.Health < enemy.MaxHealth; 68 Assert.True(someoneWasHit, "With 90% accuracy, at least one operator should be hit"); 69 70 _output.WriteLine($"Player Health: {player.Health}/{player.MaxHealth}"); 71 _output.WriteLine($"Enemy Health: {enemy.Health}/{enemy.MaxHealth}"); 72 } 73 74 [Fact] 75 public void AccuracyAffectsHitRate() 76 { 77 // Test multiple rounds with different accuracy levels 78 var testCases = new[] 79 { 80 (accuracy: 0.95f, expectedMinHitRate: 0.5f, label: "High accuracy"), 81 (accuracy: 0.5f, expectedMinHitRate: 0.2f, label: "Medium accuracy"), 82 (accuracy: 0.2f, expectedMinHitRate: 0.0f, label: "Low accuracy") 83 }; 84 85 foreach (var (accuracy, expectedMinHitRate, label) in testCases) 86 { 87 int totalRounds = 50; 88 int hitsLanded = 0; 89 90 for (int i = 0; i < totalRounds; i++) 91 { 92 var player = new Operator("Player") 93 { 94 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 95 CurrentAmmo = 30, 96 DistanceToOpponent = 15f, 97 Accuracy = accuracy, 98 AccuracyProficiency = 1.0f // Max proficiency to isolate accuracy stat effect 99 }; 100 101 var enemy = new Operator("Enemy") 102 { 103 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 104 CurrentAmmo = 30, 105 DistanceToOpponent = 15f, 106 Accuracy = 0f, // Minimum accuracy (0% = maximum aim error) 107 AccuracyProficiency = 0f // Min proficiency so enemy misses more 108 }; 109 110 var combat = new CombatSystemV2(player, enemy, seed: 100 + i); 111 112 var playerIntents = new SimultaneousIntents(player.Id) 113 { 114 Primary = PrimaryAction.Fire, 115 Movement = MovementAction.Stand 116 }; 117 118 var enemyIntents = new SimultaneousIntents(enemy.Id) 119 { 120 Primary = PrimaryAction.Fire, 121 Movement = MovementAction.Stand 122 }; 123 124 combat.SubmitIntents(player, playerIntents); 125 combat.SubmitIntents(enemy, enemyIntents); 126 combat.BeginExecution(); 127 combat.ExecuteUntilReactionWindow(); 128 129 if (enemy.Health < enemy.MaxHealth) 130 hitsLanded++; 131 } 132 133 float hitRate = (float)hitsLanded / totalRounds; 134 _output.WriteLine($"{label} (Accuracy {accuracy:P0}): {hitsLanded}/{totalRounds} hits = {hitRate:P1}"); 135 136 Assert.True(hitRate >= expectedMinHitRate, 137 $"{label}: Hit rate {hitRate:P1} should be >= {expectedMinHitRate:P1}"); 138 } 139 } 140 141 [Fact] 142 public void RecoilAccumulates_AcrossMultipleShots() 143 { 144 // Arrange: Operator fires multiple shots 145 // Note: This test manually simulates recoil accumulation to test the isolated 146 // HitResolution behavior. The actual combat system handles recoil in CombatEvents.cs. 147 var player = new Operator("Player") 148 { 149 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 150 CurrentAmmo = 5, 151 DistanceToOpponent = 15f, 152 Accuracy = 1.0f, // Perfect accuracy to isolate recoil effect (aim error = 0) 153 AccuracyProficiency = 0.5f, // Explicit proficiency 154 CurrentRecoilY = 0f 155 }; 156 157 float weaponRecoil = player.EquippedWeapon.VerticalRecoil; 158 _output.WriteLine($"Weapon vertical recoil: {weaponRecoil}"); 159 160 var random = new Random(42); 161 162 // Shot 1 163 var result1 = HitResolution.ResolveShot( 164 BodyPart.UpperTorso, player.Accuracy, weaponRecoil, 165 player.CurrentRecoilY, 0f, random); 166 player.CurrentRecoilY += weaponRecoil; 167 168 // Shot 2 - should have more recoil 169 var result2 = HitResolution.ResolveShot( 170 BodyPart.UpperTorso, player.Accuracy, weaponRecoil, 171 player.CurrentRecoilY, 0f, random); 172 player.CurrentRecoilY += weaponRecoil; 173 174 // Shot 3 - even more recoil 175 var result3 = HitResolution.ResolveShot( 176 BodyPart.UpperTorso, player.Accuracy, weaponRecoil, 177 player.CurrentRecoilY, 0f, random); 178 179 _output.WriteLine($"Shot 1 angle: {result1.FinalAngleDegrees:F4}° -> {result1.HitLocation}"); 180 _output.WriteLine($"Shot 2 angle: {result2.FinalAngleDegrees:F4}° -> {result2.HitLocation}"); 181 _output.WriteLine($"Shot 3 angle: {result3.FinalAngleDegrees:F4}° -> {result3.HitLocation}"); 182 183 // Assert: Accumulated recoil should affect shot placement 184 // With perfect accuracy (1.0), aim error is deterministic (0), so angles should strictly increase 185 Assert.True(result2.FinalAngleDegrees > result1.FinalAngleDegrees, 186 "Second shot should have higher angle due to recoil accumulation"); 187 Assert.True(result3.FinalAngleDegrees > result2.FinalAngleDegrees, 188 "Third shot should have higher angle due to accumulated recoil"); 189 } 190 191 [Fact] 192 public void BodyPartHits_DealCorrectDamage() 193 { 194 // Arrange: Create operator with known weapon 195 var player = new Operator("Player") 196 { 197 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 198 CurrentAmmo = 30, 199 DistanceToOpponent = 15f, 200 Accuracy = 1.0f, // Perfect accuracy 201 AccuracyProficiency = 0.5f // Explicit proficiency 202 }; 203 204 var weapon = player.EquippedWeapon; 205 206 // Get expected damage for each body part at this distance 207 float headDamage = weapon.GetDamageAtDistance(15f, BodyPart.Head); 208 float neckDamage = weapon.GetDamageAtDistance(15f, BodyPart.Neck); 209 float upperTorsoDamage = weapon.GetDamageAtDistance(15f, BodyPart.UpperTorso); 210 float lowerTorsoDamage = weapon.GetDamageAtDistance(15f, BodyPart.LowerTorso); 211 212 _output.WriteLine($"Expected damage at 15m:"); 213 _output.WriteLine($" Head: {headDamage}"); 214 _output.WriteLine($" Neck: {neckDamage}"); 215 _output.WriteLine($" UpperTorso: {upperTorsoDamage}"); 216 _output.WriteLine($" LowerTorso: {lowerTorsoDamage}"); 217 218 // Assert: Headshots should deal more damage than body shots 219 Assert.True(headDamage > upperTorsoDamage, "Head should deal more damage than torso"); 220 Assert.True(headDamage > neckDamage || Math.Abs(headDamage - neckDamage) < 0.1f, 221 "Head should deal at least as much damage as neck"); 222 } 223 224 [Fact] 225 public void TargetInFullCover_CannotBeHit_WhenNotFiring() 226 { 227 // Test that a target in full cover who is NOT firing cannot be hit 228 int totalRounds = 50; 229 int hitsLanded = 0; 230 231 for (int i = 0; i < totalRounds; i++) 232 { 233 var player = new Operator("Player") 234 { 235 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 236 CurrentAmmo = 30, 237 DistanceToOpponent = 15f, 238 Accuracy = 1.0f, // Perfect accuracy - should hit every time without cover 239 AccuracyProficiency = 1.0f 240 }; 241 242 var enemy = new Operator("Enemy") 243 { 244 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 245 CurrentAmmo = 0, // No ammo - won't be firing 246 DistanceToOpponent = 15f, 247 Accuracy = 0f, 248 AccuracyProficiency = 0f 249 }; 250 251 // Enemy enters full cover 252 enemy.EnterCover(CoverState.Full, 0); 253 Assert.Equal(CoverState.Full, enemy.CurrentCover); 254 255 var combat = new CombatSystemV2(player, enemy, seed: 200 + i); 256 257 var playerIntents = new SimultaneousIntents(player.Id) 258 { 259 Primary = PrimaryAction.Fire, 260 Movement = MovementAction.Stand 261 }; 262 263 var enemyIntents = new SimultaneousIntents(enemy.Id) 264 { 265 Primary = PrimaryAction.None, // Not firing 266 Movement = MovementAction.Stand 267 }; 268 269 combat.SubmitIntents(player, playerIntents); 270 combat.SubmitIntents(enemy, enemyIntents); 271 combat.BeginExecution(); 272 combat.ExecuteUntilReactionWindow(); 273 274 // Verify enemy is not actively firing (since they have no ammo) 275 Assert.False(enemy.IsActivelyFiring, "Enemy should not be actively firing without ammo"); 276 277 if (enemy.Health < enemy.MaxHealth) 278 hitsLanded++; 279 } 280 281 _output.WriteLine($"Target in full cover (not firing): {hitsLanded}/{totalRounds} hits landed"); 282 283 // With full cover and not firing, NO hits should land 284 Assert.Equal(0, hitsLanded); 285 } 286 287 [Fact] 288 public void TargetInPartialCover_CanBeHit_WhenExposed() 289 { 290 // Test that a target in partial cover (peeking) can be hit in exposed body parts 291 int totalRounds = 50; 292 int hitsLanded = 0; 293 294 for (int i = 0; i < totalRounds; i++) 295 { 296 var player = new Operator("Player") 297 { 298 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 299 CurrentAmmo = 30, 300 DistanceToOpponent = 15f, 301 Accuracy = 1.0f, // Perfect accuracy 302 AccuracyProficiency = 1.0f 303 }; 304 305 var enemy = new Operator("Enemy") 306 { 307 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 308 CurrentAmmo = 30, // Has ammo - can fire from partial cover 309 DistanceToOpponent = 15f, 310 Accuracy = 0f, 311 AccuracyProficiency = 0f 312 }; 313 314 // Enemy enters partial cover (peeking) 315 enemy.EnterCover(CoverState.Partial, 0); 316 Assert.Equal(CoverState.Partial, enemy.CurrentCover); 317 318 var combat = new CombatSystemV2(player, enemy, seed: 300 + i); 319 320 var playerIntents = new SimultaneousIntents(player.Id) 321 { 322 Primary = PrimaryAction.Fire, 323 Movement = MovementAction.Stand 324 }; 325 326 var enemyIntents = new SimultaneousIntents(enemy.Id) 327 { 328 Primary = PrimaryAction.Fire, // Firing from partial cover 329 Movement = MovementAction.Stand 330 }; 331 332 combat.SubmitIntents(player, playerIntents); 333 combat.SubmitIntents(enemy, enemyIntents); 334 combat.BeginExecution(); 335 combat.ExecuteUntilReactionWindow(); 336 337 // Verify enemy is actively firing 338 Assert.True(enemy.IsActivelyFiring, "Enemy should be actively firing with ammo"); 339 340 if (enemy.Health < enemy.MaxHealth) 341 hitsLanded++; 342 } 343 344 _output.WriteLine($"Target in partial cover (peeking): {hitsLanded}/{totalRounds} hits landed"); 345 346 // With partial cover (peeking), hits should land on exposed upper body parts 347 // With perfect accuracy, we expect a reasonable hit rate when peeking 348 Assert.True(hitsLanded > 20, $"Target in partial cover should be hit frequently with perfect accuracy (expected >20, got {hitsLanded})"); 349 } 350 351 [Fact] 352 public void TargetInFullCover_CannotBeHit() 353 { 354 // Test that a target in full cover (completely concealed) cannot be hit 355 int totalRounds = 50; 356 int hitsLanded = 0; 357 358 for (int i = 0; i < totalRounds; i++) 359 { 360 var player = new Operator("Player") 361 { 362 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 363 CurrentAmmo = 30, 364 DistanceToOpponent = 15f, 365 Accuracy = 1.0f, // Perfect accuracy 366 AccuracyProficiency = 1.0f 367 }; 368 369 var enemy = new Operator("Enemy") 370 { 371 EquippedWeapon = WeaponFactory.CreateSturmwolf45(), 372 CurrentAmmo = 30, 373 DistanceToOpponent = 15f, 374 Accuracy = 0f, 375 AccuracyProficiency = 0f 376 }; 377 378 // Enemy enters full cover (completely concealed) 379 enemy.EnterCover(CoverState.Full, 0); 380 Assert.Equal(CoverState.Full, enemy.CurrentCover); 381 382 var combat = new CombatSystemV2(player, enemy, seed: 300 + i); 383 384 var playerIntents = new SimultaneousIntents(player.Id) 385 { 386 Primary = PrimaryAction.Fire, 387 Movement = MovementAction.Stand 388 }; 389 390 // Enemy in full cover cannot shoot 391 var enemyIntents = new SimultaneousIntents(enemy.Id) 392 { 393 Movement = MovementAction.Stand 394 }; 395 396 combat.SubmitIntents(player, playerIntents); 397 combat.SubmitIntents(enemy, enemyIntents); 398 combat.BeginExecution(); 399 combat.ExecuteUntilReactionWindow(); 400 401 if (enemy.Health < enemy.MaxHealth) 402 hitsLanded++; 403 } 404 405 _output.WriteLine($"Target in full cover (concealed): {hitsLanded}/{totalRounds} hits landed"); 406 407 // With full cover (complete concealment), NO hits should land 408 Assert.Equal(0, hitsLanded); 409 } 410 }