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