/ GUNRPG.Tests / CombatOutcomeTests.cs
CombatOutcomeTests.cs
  1  using GUNRPG.Application.Dtos;
  2  using GUNRPG.Application.Mapping;
  3  using GUNRPG.Application.Requests;
  4  using GUNRPG.Application.Results;
  5  using GUNRPG.Application.Sessions;
  6  using GUNRPG.Core.Combat;
  7  using GUNRPG.Core.Equipment;
  8  using GUNRPG.Core.Intents;
  9  using GUNRPG.Core.Operators;
 10  using GUNRPG.Tests.Stubs;
 11  
 12  namespace GUNRPG.Tests;
 13  
 14  public class CombatOutcomeTests
 15  {
 16      [Fact]
 17      public async Task GetOutcome_ThrowsException_WhenSessionNotCompleted()
 18      {
 19          // Arrange
 20          var store = new InMemoryCombatSessionStore();
 21          var service = new CombatSessionService(store);
 22          var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 42 })).Value!;
 23          
 24          var loadedSession = await LoadRequiredSessionAsync(store, session.Id);
 25  
 26          // Act & Assert
 27          var exception = Assert.Throws<InvalidOperationException>(() => loadedSession!.GetOutcome());
 28          Assert.Contains("not completed", exception.Message, StringComparison.OrdinalIgnoreCase);
 29      }
 30  
 31      [Fact]
 32      public async Task GetOutcome_ProducesOutcome_WhenSessionCompleted()
 33      {
 34          // Arrange
 35          var store = new InMemoryCombatSessionStore();
 36          var service = new CombatSessionService(store);
 37          var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 42 })).Value!;
 38  
 39          // Advance session until completion
 40          for (int i = 0; i < 100; i++)
 41          {
 42              var state = await service.GetStateAsync(session.Id);
 43              if (state.Value!.Phase == SessionPhase.Completed)
 44                  break;
 45  
 46              await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest
 47              {
 48                  Intents = new IntentDto { Primary = PrimaryAction.Fire }
 49              });
 50  
 51              await service.AdvanceAsync(session.Id);
 52          }
 53  
 54          var loadedSession = await LoadRequiredSessionAsync(store, session.Id);
 55          Assert.Equal(SessionPhase.Completed, loadedSession!.Phase);
 56  
 57          // Act
 58          var outcome = loadedSession.GetOutcome();
 59  
 60          // Assert
 61          Assert.NotNull(outcome);
 62          Assert.Equal(session.Id, outcome.SessionId);
 63          Assert.NotNull(outcome.GearLost);
 64      }
 65  
 66      [Fact]
 67      public async Task GetOutcome_ReportsOperatorDeathCorrectly()
 68      {
 69          // Arrange
 70          var store = new InMemoryCombatSessionStore();
 71          var service = new CombatSessionService(store);
 72          
 73          // Create session and force combat until completion
 74          var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 123 })).Value!;
 75  
 76          // Advance session until completion
 77          for (int i = 0; i < 100; i++)
 78          {
 79              var state = await service.GetStateAsync(session.Id);
 80              if (state.Value!.Phase == SessionPhase.Completed)
 81                  break;
 82  
 83              await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest
 84              {
 85                  Intents = new IntentDto { Primary = PrimaryAction.Fire }
 86              });
 87  
 88              await service.AdvanceAsync(session.Id);
 89          }
 90  
 91          var loadedSession = await LoadRequiredSessionAsync(store, session.Id);
 92          Assert.Equal(SessionPhase.Completed, loadedSession!.Phase);
 93  
 94          // Act
 95          var outcome = loadedSession.GetOutcome();
 96  
 97          // Assert
 98          Assert.NotNull(outcome);
 99          Assert.Equal(!loadedSession.Player.IsAlive, outcome.OperatorDied);
100      }
101  
102      [Fact]
103      public async Task GetOutcome_CalculatesXpCorrectly_ForAllOutcomes()
104      {
105          // Arrange
106          var store = new InMemoryCombatSessionStore();
107          var service = new CombatSessionService(store);
108          var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 50 })).Value!;
109  
110          // Advance session until completion
111          for (int i = 0; i < 100; i++)
112          {
113              var state = await service.GetStateAsync(session.Id);
114              if (state.Value!.Phase == SessionPhase.Completed)
115                  break;
116  
117              await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest
118              {
119                  Intents = new IntentDto { Primary = PrimaryAction.Fire }
120              });
121  
122              await service.AdvanceAsync(session.Id);
123          }
124  
125          var loadedSession = await LoadRequiredSessionAsync(store, session.Id);
126          Assert.Equal(SessionPhase.Completed, loadedSession!.Phase);
127  
128          // Act
129          var outcome = loadedSession.GetOutcome();
130  
131          // Assert
132          Assert.NotNull(outcome);
133          
134          // XP calculation rules:
135          // - Victory: 100 XP
136          // - Survival (no victory): 50 XP
137          // - Death: 0 XP
138          if (outcome.IsVictory)
139          {
140              Assert.Equal(100, outcome.XpGained);
141          }
142          else if (!outcome.OperatorDied)
143          {
144              Assert.Equal(50, outcome.XpGained);
145          }
146          else
147          {
148              Assert.Equal(0, outcome.XpGained);
149          }
150      }
151  
152      [Fact]
153      public async Task GetOutcome_IsDeterministic_WhenCalledMultipleTimes()
154      {
155          // Arrange
156          var store = new InMemoryCombatSessionStore();
157          var service = new CombatSessionService(store);
158          var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 999 })).Value!;
159  
160          // Advance session until completion
161          for (int i = 0; i < 100; i++)
162          {
163              var state = await service.GetStateAsync(session.Id);
164              if (state.Value!.Phase == SessionPhase.Completed)
165                  break;
166  
167              await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest
168              {
169                  Intents = new IntentDto { Primary = PrimaryAction.Fire }
170              });
171  
172              await service.AdvanceAsync(session.Id);
173          }
174  
175          var loadedSession = await LoadRequiredSessionAsync(store, session.Id);
176          Assert.Equal(SessionPhase.Completed, loadedSession!.Phase);
177  
178          // Act
179          var outcome1 = loadedSession.GetOutcome();
180          var outcome2 = loadedSession.GetOutcome();
181  
182          // Assert
183          Assert.Equal(outcome1.SessionId, outcome2.SessionId);
184          Assert.Equal(outcome1.OperatorId.Value, outcome2.OperatorId.Value);
185          Assert.Equal(outcome1.OperatorDied, outcome2.OperatorDied);
186          Assert.Equal(outcome1.XpGained, outcome2.XpGained);
187          Assert.Equal(outcome1.IsVictory, outcome2.IsVictory);
188          Assert.Equal(outcome1.DamageTaken, outcome2.DamageTaken);
189          Assert.Equal(outcome1.TurnsSurvived, outcome2.TurnsSurvived);
190          Assert.Equal(outcome1.CompletedAt, outcome2.CompletedAt);
191      }
192  
193      [Fact]
194      public void CombatOutcome_Constructor_ValidatesXpGained()
195      {
196          // Arrange
197          var operatorId = OperatorId.NewId();
198  
199          // Act & Assert
200          Assert.Throws<ArgumentException>(() => new Application.Combat.CombatOutcome(
201              sessionId: Guid.NewGuid(),
202              operatorId: operatorId,
203              operatorDied: false,
204              xpGained: -100, // Invalid: negative XP
205              gearLost: Array.Empty<GearId>(),
206              isVictory: false,
207              turnsSurvived: 0,
208              damageTaken: 0f));
209      }
210  
211      [Fact]
212      public void CombatOutcome_Constructor_ValidatesTurnsSurvived()
213      {
214          // Arrange
215          var operatorId = OperatorId.NewId();
216  
217          // Act & Assert
218          Assert.Throws<ArgumentException>(() => new Application.Combat.CombatOutcome(
219              sessionId: Guid.NewGuid(),
220              operatorId: operatorId,
221              operatorDied: false,
222              xpGained: 100,
223              gearLost: Array.Empty<GearId>(),
224              isVictory: false,
225              turnsSurvived: -5, // Invalid: negative turns
226              damageTaken: 0f));
227      }
228  
229      [Fact]
230      public void CombatOutcome_Constructor_ValidatesDamageTaken()
231      {
232          // Arrange
233          var operatorId = OperatorId.NewId();
234  
235          // Act & Assert
236          Assert.Throws<ArgumentException>(() => new Application.Combat.CombatOutcome(
237              sessionId: Guid.NewGuid(),
238              operatorId: operatorId,
239              operatorDied: false,
240              xpGained: 100,
241              gearLost: Array.Empty<GearId>(),
242              isVictory: false,
243              turnsSurvived: 0,
244              damageTaken: -10f)); // Invalid: negative damage
245      }
246  
247      [Fact]
248      public void CombatOutcome_Constructor_RequiresGearLost()
249      {
250          // Arrange
251          var operatorId = OperatorId.NewId();
252  
253          // Act & Assert
254          Assert.Throws<ArgumentNullException>(() => new Application.Combat.CombatOutcome(
255              sessionId: Guid.NewGuid(),
256              operatorId: operatorId,
257              operatorDied: false,
258              xpGained: 100,
259              gearLost: null!, // Invalid: null
260              isVictory: false,
261              turnsSurvived: 0,
262              damageTaken: 0f));
263      }
264  
265      [Fact]
266      public void CombatOutcome_Constructor_RejectsVictoryWhenOperatorDied()
267      {
268          // Arrange
269          var operatorId = OperatorId.NewId();
270  
271          // Act & Assert
272          var exception = Assert.Throws<ArgumentException>(() => new Application.Combat.CombatOutcome(
273              sessionId: Guid.NewGuid(),
274              operatorId: operatorId,
275              operatorDied: true, // Operator died
276              xpGained: 0,
277              gearLost: Array.Empty<GearId>(),
278              isVictory: true, // Invalid: can't have victory if operator died
279              turnsSurvived: 5,
280              damageTaken: 100f));
281          
282          Assert.Contains("victory", exception.Message, StringComparison.OrdinalIgnoreCase);
283          Assert.Contains("operator", exception.Message, StringComparison.OrdinalIgnoreCase);
284      }
285  
286      [Fact]
287      public async Task CombatSession_PreventsIntentSubmission_WhenCompleted()
288      {
289          // Arrange
290          var store = new InMemoryCombatSessionStore();
291          var service = new CombatSessionService(store);
292          var session = (await service.CreateSessionAsync(new SessionCreateRequest { Seed = 200 })).Value!;
293  
294          // Advance session until completion
295          for (int i = 0; i < 100; i++)
296          {
297              var state = await service.GetStateAsync(session.Id);
298              if (state.Value!.Phase == SessionPhase.Completed)
299                  break;
300  
301              await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest
302              {
303                  Intents = new IntentDto { Primary = PrimaryAction.Fire }
304              });
305  
306              await service.AdvanceAsync(session.Id);
307          }
308  
309          var loadedSession = await LoadRequiredSessionAsync(store, session.Id);
310          Assert.Equal(SessionPhase.Completed, loadedSession!.Phase);
311  
312          // Act
313          var result = await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest
314          {
315              Intents = new IntentDto { Primary = PrimaryAction.Fire }
316          });
317  
318          // Assert
319          Assert.False(result.IsSuccess);
320          Assert.Equal(ResultStatus.InvalidState, result.Status);
321      }
322  
323      [Fact]
324      public async Task GetOutcome_UsesSessionOperatorId_NotPlayerId()
325      {
326          // Arrange - create session with an explicit operator ID
327          var store = new InMemoryCombatSessionStore();
328          var service = new CombatSessionService(store);
329          var explicitOperatorId = Guid.NewGuid();
330  
331          var session = (await service.CreateSessionAsync(new SessionCreateRequest
332          {
333              Seed = 42,
334              OperatorId = explicitOperatorId
335          })).Value!;
336  
337          // Advance session until completion
338          for (int i = 0; i < 100; i++)
339          {
340              var state = await service.GetStateAsync(session.Id);
341              if (state.Value!.Phase == SessionPhase.Completed)
342                  break;
343  
344              await service.SubmitPlayerIntentsAsync(session.Id, new SubmitIntentsRequest
345              {
346                  Intents = new IntentDto { Primary = PrimaryAction.Fire }
347              });
348  
349              await service.AdvanceAsync(session.Id);
350          }
351  
352          var loadedSession = await LoadRequiredSessionAsync(store, session.Id);
353          Assert.Equal(SessionPhase.Completed, loadedSession!.Phase);
354  
355          // Act
356          var outcome = loadedSession.GetOutcome();
357  
358          // Assert - outcome should use session's OperatorId, not Player.Id
359          Assert.Equal(explicitOperatorId, outcome.OperatorId.Value);
360          Assert.NotEqual(loadedSession.Player.Id, explicitOperatorId); // Verify they differ
361          Assert.NotEqual(loadedSession.Player.Id, outcome.OperatorId.Value); // Verify bug is fixed
362      }
363  
364      private static async Task<CombatSession> LoadRequiredSessionAsync(ICombatSessionStore store, Guid sessionId)
365      {
366          var snapshot = await store.LoadAsync(sessionId);
367          Assert.NotNull(snapshot);
368          return SessionMapping.FromSnapshot(snapshot!);
369      }
370  }