/ GUNRPG.Tests / DeterministicCombatEngineTests.cs
DeterministicCombatEngineTests.cs
1 using System.Text.Json; 2 using GUNRPG.Application.Backend; 3 using GUNRPG.Application.Combat; 4 using GUNRPG.Application.Dtos; 5 using Xunit; 6 7 namespace GUNRPG.Tests; 8 9 /// <summary> 10 /// Tests for <see cref="DeterministicCombatEngine"/> and <see cref="OfflineMissionHashing"/>. 11 /// Validates determinism guarantees required for server-side replay verification. 12 /// </summary> 13 public sealed class DeterministicCombatEngineTests 14 { 15 private readonly IDeterministicCombatEngine _engine = new DeterministicCombatEngine(); 16 17 // ─── Determinism tests ─── 18 19 [Fact] 20 public void Execute_SameSnapshotAndSeed_ProducesIdenticalResult() 21 { 22 var snapshot = CreateTestOperator(); 23 const int seed = 12345; 24 25 var result1 = _engine.Execute(snapshot, seed); 26 var result2 = _engine.Execute(snapshot, seed); 27 28 Assert.Equal( 29 OfflineMissionHashing.ComputeOperatorStateHash(result1.ResultOperator), 30 OfflineMissionHashing.ComputeOperatorStateHash(result2.ResultOperator)); 31 Assert.Equal(result1.ResultOperator.TotalXp, result2.ResultOperator.TotalXp); 32 Assert.Equal(result1.ResultOperator.CurrentHealth, result2.ResultOperator.CurrentHealth); 33 Assert.Equal(result1.IsVictory, result2.IsVictory); 34 Assert.Equal(result1.OperatorDied, result2.OperatorDied); 35 Assert.Equal(result1.BattleLog.Count, result2.BattleLog.Count); 36 } 37 38 [Fact] 39 public void Execute_DifferentSeed_ProducesDifferentResult() 40 { 41 var snapshot = CreateTestOperator(); 42 43 // Run engine across a range of seeds and verify we get at least two distinct hashes. 44 // A deterministic engine driven by Random(seed) will produce different sequences for different seeds. 45 var hashes = Enumerable.Range(1, 50) 46 .Select(seed => OfflineMissionHashing.ComputeOperatorStateHash(_engine.Execute(snapshot, seed).ResultOperator)) 47 .Distinct() 48 .Count(); 49 50 Assert.True(hashes > 1, "Engine must produce distinct results for at least some different seeds"); 51 } 52 53 [Fact] 54 public void Execute_ProducesNonNullResult() 55 { 56 var snapshot = CreateTestOperator(); 57 58 var result = _engine.Execute(snapshot, 42); 59 60 Assert.NotNull(result); 61 Assert.NotNull(result.ResultOperator); 62 Assert.NotNull(result.BattleLog); 63 } 64 65 [Fact] 66 public void Execute_ResultOperator_HasValidXp() 67 { 68 var snapshot = CreateTestOperator(); 69 var initialXp = snapshot.TotalXp; 70 71 var result = _engine.Execute(snapshot, 42); 72 73 // XP can only be 0, 50, or 100 relative to initial 74 var xpDelta = result.ResultOperator.TotalXp - initialXp; 75 Assert.True(xpDelta == 0 || xpDelta == 50 || xpDelta == 100, 76 $"XP delta {xpDelta} is not a valid reward (0, 50, or 100)"); 77 } 78 79 [Fact] 80 public void Execute_ResultOperator_HealthIsPositive() 81 { 82 var snapshot = CreateTestOperator(); 83 84 var result = _engine.Execute(snapshot, 42); 85 86 Assert.True(result.ResultOperator.CurrentHealth >= 1f, 87 $"Health {result.ResultOperator.CurrentHealth} must be at least 1"); 88 } 89 90 [Fact] 91 public void Execute_VictoryAndDeath_AreMutuallyExclusive() 92 { 93 // Test with multiple seeds to cover both outcomes 94 for (int seed = 1; seed <= 20; seed++) 95 { 96 var snapshot = CreateTestOperator(); 97 var result = _engine.Execute(snapshot, seed); 98 Assert.False(result.IsVictory && result.OperatorDied, 99 $"Seed {seed}: IsVictory and OperatorDied cannot both be true"); 100 } 101 } 102 103 [Fact] 104 public void Execute_BattleLog_ContainsAtLeastOneEntry() 105 { 106 var snapshot = CreateTestOperator(); 107 108 var result = _engine.Execute(snapshot, 42); 109 110 Assert.NotEmpty(result.BattleLog); 111 } 112 113 [Fact] 114 public void Execute_BattleLog_EntriesHaveValidEventTypes() 115 { 116 var snapshot = CreateTestOperator(); 117 118 var result = _engine.Execute(snapshot, 42); 119 120 foreach (var entry in result.BattleLog) 121 { 122 Assert.True(entry.EventType == "Damage" || entry.EventType == "Miss", 123 $"Unexpected event type: {entry.EventType}"); 124 } 125 } 126 127 // ─── Snapshot hash stability tests ─── 128 129 [Fact] 130 public void ComputeSnapshotHash_SameJson_ProducesIdenticalHash() 131 { 132 const string json = "{\"id\":\"test\",\"name\":\"Op\"}"; 133 134 var hash1 = OfflineMissionHashing.ComputeSnapshotHash(json); 135 var hash2 = OfflineMissionHashing.ComputeSnapshotHash(json); 136 137 Assert.Equal(hash1, hash2); 138 } 139 140 [Fact] 141 public void ComputeSnapshotHash_DifferentJson_ProducesDifferentHash() 142 { 143 var hash1 = OfflineMissionHashing.ComputeSnapshotHash("{\"id\":\"a\"}"); 144 var hash2 = OfflineMissionHashing.ComputeSnapshotHash("{\"id\":\"b\"}"); 145 146 Assert.NotEqual(hash1, hash2); 147 } 148 149 [Fact] 150 public void ComputeSnapshotHash_IsHexString() 151 { 152 var hash = OfflineMissionHashing.ComputeSnapshotHash("{}"); 153 154 Assert.All(hash, c => Assert.True( 155 (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'), 156 $"Character '{c}' is not a valid uppercase hex digit")); 157 Assert.Equal(64, hash.Length); // SHA256 = 32 bytes = 64 hex chars 158 } 159 160 [Fact] 161 public void ComputeOperatorStateHash_StableAcrossSerializations() 162 { 163 var dto = CreateTestOperatorDto(); 164 165 // Compute hash twice from same DTO 166 var hash1 = OfflineMissionHashing.ComputeOperatorStateHash(dto); 167 var hash2 = OfflineMissionHashing.ComputeOperatorStateHash(dto); 168 169 Assert.Equal(hash1, hash2); 170 } 171 172 // ─── Replay parity tests ─── 173 174 [Fact] 175 public void Execute_ReplayResultMatchesClientResult() 176 { 177 // Simulate client creating an envelope 178 var snapshot = CreateTestOperator(); 179 const int seed = 77777; 180 181 var clientResult = _engine.Execute(snapshot, seed); 182 var clientHash = OfflineMissionHashing.ComputeOperatorStateHash(clientResult.ResultOperator); 183 184 // Simulate server re-running the same engine 185 var serverResult = _engine.Execute(snapshot, seed); 186 var serverHash = OfflineMissionHashing.ComputeOperatorStateHash(serverResult.ResultOperator); 187 188 Assert.Equal(clientHash, serverHash); 189 } 190 191 [Fact] 192 public void Execute_ReplayFromSerializedSnapshot_ProducesSameResult() 193 { 194 var snapshot = CreateTestOperator(); 195 const int seed = 42; 196 197 // Client runs engine and serializes initial snapshot 198 var clientResult = _engine.Execute(snapshot, seed); 199 var clientHash = OfflineMissionHashing.ComputeOperatorStateHash(clientResult.ResultOperator); 200 201 // Server deserializes snapshot JSON and re-runs engine 202 var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); 203 var serialized = JsonSerializer.Serialize(snapshot, jsonOptions); 204 var deserialized = JsonSerializer.Deserialize<OperatorDto>(serialized, jsonOptions)!; 205 var serverResult = _engine.Execute(deserialized, seed); 206 var serverHash = OfflineMissionHashing.ComputeOperatorStateHash(serverResult.ResultOperator); 207 208 Assert.Equal(clientHash, serverHash); 209 } 210 211 // ─── Helpers ─── 212 213 private static OperatorDto CreateTestOperator() => new() 214 { 215 Id = "op-test", 216 Name = "TestOperator", 217 TotalXp = 500, 218 CurrentHealth = 100f, 219 MaxHealth = 100f, 220 EquippedWeaponName = "Rifle", 221 UnlockedPerks = new List<string> { "Steady Aim" }, 222 ExfilStreak = 2, 223 IsDead = false, 224 CurrentMode = "Infil", 225 LockedLoadout = "Rifle" 226 }; 227 228 private static OperatorDto CreateTestOperatorDto() => new() 229 { 230 Id = "op-hash-test", 231 Name = "HashTestOp", 232 TotalXp = 1000, 233 CurrentHealth = 80f, 234 MaxHealth = 100f, 235 EquippedWeaponName = "SMG", 236 UnlockedPerks = new List<string> { "Fast Reload", "Suppressor" }, 237 ExfilStreak = 3, 238 IsDead = false, 239 CurrentMode = "Infil", 240 LockedLoadout = "SMG" 241 }; 242 }