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