/ GUNRPG.Tests / CoverSemanticsTests.cs
CoverSemanticsTests.cs
1 using GUNRPG.Core; 2 using GUNRPG.Core.Combat; 3 using GUNRPG.Core.Events; 4 using GUNRPG.Core.Intents; 5 using GUNRPG.Core.Operators; 6 using GUNRPG.Core.Weapons; 7 using Xunit; 8 9 namespace GUNRPG.Tests; 10 11 /// <summary> 12 /// Tests for clarified cover semantics: 13 /// - Partial Cover = Peeking (only upper body exposed) 14 /// - Full Cover = Complete Concealment (no damage possible) 15 /// </summary> 16 public class CoverSemanticsTests 17 { 18 private static Operator CreateTestOperator(string name, float accuracy = 1.0f, float accuracyProficiency = 0.8f) 19 { 20 var op = new Operator(name) 21 { 22 Health = 100, 23 MaxHealth = 100, 24 Accuracy = accuracy, 25 AccuracyProficiency = accuracyProficiency, 26 CurrentAmmo = 30, 27 DistanceToOpponent = 10f, 28 CurrentMovement = MovementState.Stationary, 29 CurrentCover = CoverState.None, 30 EquippedWeapon = WeaponFactory.CreateSturmwolf45() 31 }; 32 return op; 33 } 34 35 [Fact] 36 public void PartialCover_BlocksLowerTorsoHits() 37 { 38 // Fire many shots to test hit distribution 39 int lowerTorsoHits = 0; 40 int upperBodyHits = 0; 41 int totalShots = 100; 42 43 for (int i = 0; i < totalShots; i++) 44 { 45 var shooter2 = CreateTestOperator("Shooter"); 46 var target2 = CreateTestOperator("Target"); 47 target2.CurrentCover = CoverState.Partial; 48 49 var combat2 = new CombatSystemV2(shooter2, target2, seed: 42 + i); 50 51 var intents = new SimultaneousIntents(shooter2.Id) { Primary = PrimaryAction.Fire }; 52 combat2.SubmitIntents(shooter2, intents); 53 combat2.BeginExecution(); 54 combat2.ExecuteUntilReactionWindow(); 55 56 // Check if target was hit and where 57 float healthLost = 100 - target2.Health; 58 if (healthLost > 0) 59 { 60 // Hit occurred - check that it wasn't lower torso 61 // We can't directly check the hit location from health alone, 62 // but we can verify lower torso hits are filtered by checking events 63 var damageEvents = combat2.ExecutedEvents.OfType<DamageAppliedEvent>().ToList(); 64 foreach (var evt in damageEvents) 65 { 66 if (evt.BodyPart == BodyPart.LowerTorso) 67 { 68 lowerTorsoHits++; 69 } 70 else if (evt.BodyPart == BodyPart.UpperTorso || evt.BodyPart == BodyPart.Neck || evt.BodyPart == BodyPart.Head) 71 { 72 upperBodyHits++; 73 } 74 } 75 } 76 } 77 78 // Assert: No lower torso hits should occur in partial cover 79 Assert.Equal(0, lowerTorsoHits); 80 // Assert: Some upper body hits should occur (to verify test is working) 81 Assert.True(upperBodyHits > 0, "Expected some upper body hits to occur"); 82 } 83 84 [Fact] 85 public void FullCover_BlocksAllDamage() 86 { 87 // Arrange 88 var shooter = CreateTestOperator("Shooter", accuracy: 1.0f); 89 var target = CreateTestOperator("Target"); 90 target.CurrentCover = CoverState.Full; // Completely concealed 91 92 var combat = new CombatSystemV2(shooter, target, seed: 42); 93 94 // Act: Shoot multiple times 95 int shotsToFire = 10; 96 for (int i = 0; i < shotsToFire; i++) 97 { 98 var intents = new SimultaneousIntents(shooter.Id) { Primary = PrimaryAction.Fire }; 99 combat.SubmitIntents(shooter, intents); 100 combat.BeginExecution(); 101 combat.ExecuteUntilReactionWindow(); 102 103 // Reset for next shot 104 shooter.CurrentAmmo = 30; 105 } 106 107 // Assert: Target should have taken no damage 108 Assert.Equal(100f, target.Health); 109 110 // Assert: No damage events should have been generated 111 var damageEvents = combat.ExecutedEvents.OfType<DamageAppliedEvent>().ToList(); 112 Assert.Empty(damageEvents); 113 } 114 115 [Fact] 116 public void FullCover_StillAppliesSuppression() 117 { 118 // Arrange 119 var shooter = CreateTestOperator("Shooter", accuracy: 0.5f); // Lower accuracy for more misses 120 var target = CreateTestOperator("Target"); 121 target.CurrentCover = CoverState.Full; // Completely concealed 122 123 var combat = new CombatSystemV2(shooter, target, seed: 123); 124 125 // Act: Fire shots that should miss but still suppress 126 var intents = new SimultaneousIntents(shooter.Id) { Primary = PrimaryAction.Fire }; 127 combat.SubmitIntents(shooter, intents); 128 combat.BeginExecution(); 129 combat.ExecuteUntilReactionWindow(); 130 131 // Assert: Target should have some suppression level (shots are passing by) 132 // Note: Suppression may or may not apply depending on shot accuracy and proximity 133 // The key is that the system allows suppression events for full cover 134 var suppressionEvents = combat.ExecutedEvents 135 .Where(e => e is SuppressionStartedEvent || e is SuppressionUpdatedEvent) 136 .ToList(); 137 138 // We're verifying that suppression events CAN be generated for full cover targets 139 // (deterministically, given the fixed seed and parameters) 140 Assert.NotEmpty(suppressionEvents); 141 142 // And full cover should still block all damage 143 Assert.InRange(target.Health, 99.9f, 100.1f); 144 } 145 146 [Fact] 147 public void FullCover_BlocksShooting() 148 { 149 // Arrange 150 var shooter = CreateTestOperator("Shooter"); 151 shooter.CurrentCover = CoverState.Full; // Shooter is in full cover 152 var target = CreateTestOperator("Target"); 153 154 var combat = new CombatSystemV2(shooter, target, seed: 42); 155 156 // Act: Try to shoot from full cover 157 var intents = new SimultaneousIntents(shooter.Id) { Primary = PrimaryAction.Fire }; 158 var result = combat.SubmitIntents(shooter, intents); 159 160 // Submit should succeed (intent is valid), but execution should block it 161 Assert.True(result.success); 162 163 combat.BeginExecution(); 164 combat.ExecuteUntilReactionWindow(); 165 166 // Assert: No shots should have been fired 167 var shotEvents = combat.ExecutedEvents.OfType<ShotFiredEvent>().ToList(); 168 Assert.Empty(shotEvents); 169 170 // Assert: Ammo should not have been consumed 171 Assert.Equal(30, shooter.CurrentAmmo); 172 } 173 174 [Fact] 175 public void FullCover_BlocksAdvancing() 176 { 177 // Arrange 178 var shooter = CreateTestOperator("Shooter"); 179 shooter.CurrentCover = CoverState.Full; // In full cover 180 var target = CreateTestOperator("Target"); 181 182 var combat = new CombatSystemV2(shooter, target, seed: 42); 183 184 float initialDistance = shooter.DistanceToOpponent; 185 186 // Act: Try to advance from full cover 187 var intents = new SimultaneousIntents(shooter.Id) { Movement = MovementAction.WalkToward }; 188 var result = combat.SubmitIntents(shooter, intents); 189 190 Assert.True(result.success); 191 192 combat.BeginExecution(); 193 combat.ExecuteUntilReactionWindow(); 194 195 // Assert: Distance should not have changed (movement blocked) 196 Assert.Equal(initialDistance, shooter.DistanceToOpponent); 197 } 198 199 [Fact] 200 public void FullCover_AllowsRetreating() 201 { 202 // Arrange 203 var shooter = CreateTestOperator("Shooter"); 204 shooter.CurrentCover = CoverState.Full; // In full cover 205 var target = CreateTestOperator("Target"); 206 207 var combat = new CombatSystemV2(shooter, target, seed: 42); 208 209 // Act: Try to retreat from full cover (should be allowed) 210 var intents = new SimultaneousIntents(shooter.Id) { Movement = MovementAction.WalkAway }; 211 var result = combat.SubmitIntents(shooter, intents); 212 213 // Assert: retreat intent is accepted from full cover 214 Assert.True(result.success); 215 } 216 217 [Fact] 218 public void PartialCover_AllowsShooting() 219 { 220 // Arrange 221 var shooter = CreateTestOperator("Shooter"); 222 shooter.CurrentCover = CoverState.Partial; // Peeking 223 var target = CreateTestOperator("Target"); 224 225 var combat = new CombatSystemV2(shooter, target, seed: 42); 226 227 // Act: Try to shoot from partial cover (should be allowed) 228 var intents = new SimultaneousIntents(shooter.Id) { Primary = PrimaryAction.Fire }; 229 var result = combat.SubmitIntents(shooter, intents); 230 231 Assert.True(result.success); 232 233 combat.BeginExecution(); 234 combat.ExecuteUntilReactionWindow(); 235 236 // Assert: Shot should have been fired 237 var shotEvents = combat.ExecutedEvents.OfType<ShotFiredEvent>().ToList(); 238 Assert.NotEmpty(shotEvents); 239 240 // Assert: Ammo should have been consumed 241 Assert.Equal(29, shooter.CurrentAmmo); 242 } 243 244 [Fact] 245 public void PartialCover_AllowsAdvancing() 246 { 247 // Arrange 248 var shooter = CreateTestOperator("Shooter"); 249 shooter.CurrentCover = CoverState.Partial; // Peeking 250 var target = CreateTestOperator("Target"); 251 252 var combat = new CombatSystemV2(shooter, target, seed: 42); 253 254 // Act: Try to advance from partial cover (should be allowed) 255 var intents = new SimultaneousIntents(shooter.Id) { Movement = MovementAction.WalkToward }; 256 var result = combat.SubmitIntents(shooter, intents); 257 258 // Assert: Intent should be accepted 259 Assert.True(result.success); 260 } 261 262 [Fact] 263 public void CanShoot_ReturnsCorrectValues() 264 { 265 var op = CreateTestOperator("Test"); 266 267 // None: Can shoot 268 op.CurrentCover = CoverState.None; 269 Assert.True(op.CanShoot()); 270 271 // Partial: Can shoot (peeking) 272 op.CurrentCover = CoverState.Partial; 273 Assert.True(op.CanShoot()); 274 275 // Full: Cannot shoot (concealed) 276 op.CurrentCover = CoverState.Full; 277 Assert.False(op.CanShoot()); 278 } 279 280 [Fact] 281 public void CanAdvance_ReturnsCorrectValues() 282 { 283 var op = CreateTestOperator("Test"); 284 285 // None: Can advance 286 op.CurrentCover = CoverState.None; 287 Assert.True(op.CanAdvance()); 288 289 // Partial: Can advance 290 op.CurrentCover = CoverState.Partial; 291 Assert.True(op.CanAdvance()); 292 293 // Full: Cannot advance (must exit cover first) 294 op.CurrentCover = CoverState.Full; 295 Assert.False(op.CanAdvance()); 296 } 297 298 [Fact] 299 public void NoCover_CanBeHit() 300 { 301 // This test verifies that without cover, hits can occur 302 // The implicit verification is that the PartialCover test shows lower torso IS filtered 303 // Therefore, the filtering logic is working (it wouldn't make sense to filter if hits weren't possible) 304 305 int hitsLanded = 0; 306 307 for (int i = 0; i < 10; i++) 308 { 309 var shooter2 = CreateTestOperator("Shooter", accuracy: 0.9f); 310 var target2 = CreateTestOperator("Target"); 311 target2.CurrentCover = CoverState.None; 312 313 var combat = new CombatSystemV2(shooter2, target2, seed: 100 + i); 314 315 var intents = new SimultaneousIntents(shooter2.Id) { Primary = PrimaryAction.Fire }; 316 combat.SubmitIntents(shooter2, intents); 317 combat.BeginExecution(); 318 combat.ExecuteUntilReactionWindow(); 319 320 if (target2.Health < target2.MaxHealth) 321 hitsLanded++; 322 } 323 324 // Assert: With high accuracy and no cover, some hits should land 325 Assert.True(hitsLanded > 0, "Expected some hits to land with no cover and high accuracy"); 326 } 327 }