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