CombatSessionService.cs
1 using GUNRPG.Application.Combat; 2 using GUNRPG.Application.Distributed; 3 using GUNRPG.Application.Dtos; 4 using GUNRPG.Application.Mapping; 5 using GUNRPG.Application.Operators; 6 using GUNRPG.Application.Requests; 7 using GUNRPG.Application.Results; 8 using GUNRPG.Core.Combat; 9 using GUNRPG.Core.Intents; 10 using GUNRPG.Core.Operators; 11 using GUNRPG.Core.VirtualPet; 12 13 namespace GUNRPG.Application.Sessions; 14 15 /// <summary> 16 /// Application service that orchestrates combat sessions and exposes UI-agnostic operations. 17 /// When an <see cref="IGameAuthority"/> is provided, combat actions are replicated 18 /// through the distributed lockstep authority for P2P state verification. 19 /// </summary> 20 public sealed class CombatSessionService 21 { 22 private const float VictoryDifficultyModifier = 0.9f; 23 private const float DefeatDifficultyModifier = 1.2f; 24 private const int XpMultiplier = 20; 25 26 private readonly ICombatSessionStore _store; 27 private readonly IOperatorEventStore? _operatorEventStore; 28 private readonly IGameAuthority? _gameAuthority; 29 private readonly CombatSessionUpdateHub? _updateHub; 30 31 public CombatSessionService(ICombatSessionStore store, IOperatorEventStore? operatorEventStore = null, IGameAuthority? gameAuthority = null, CombatSessionUpdateHub? updateHub = null) 32 { 33 _store = store; 34 _operatorEventStore = operatorEventStore; 35 _gameAuthority = gameAuthority; 36 _updateHub = updateHub; 37 } 38 39 public async Task<ServiceResult<CombatSessionDto>> CreateSessionAsync(SessionCreateRequest request) 40 { 41 if (request.OperatorId.HasValue && request.OperatorId.Value == Guid.Empty) 42 { 43 return ServiceResult<CombatSessionDto>.ValidationError("Operator ID cannot be empty"); 44 } 45 46 if (request.Id.HasValue) 47 { 48 if (request.Id.Value == Guid.Empty) 49 { 50 return ServiceResult<CombatSessionDto>.ValidationError("Session ID cannot be empty"); 51 } 52 53 var existing = await _store.LoadAsync(request.Id.Value); 54 if (existing != null) 55 { 56 return ServiceResult<CombatSessionDto>.InvalidState("A session with the provided ID already exists"); 57 } 58 } 59 60 var session = CombatSession.CreateDefault( 61 playerName: request.PlayerName, 62 seed: request.Seed, 63 startingDistance: request.StartingDistance, 64 enemyName: request.EnemyName, 65 id: request.Id, 66 operatorId: request.OperatorId); 67 68 await _store.SaveAsync(SessionMapping.ToSnapshot(session)); 69 return ServiceResult<CombatSessionDto>.Success(SessionMapping.ToDto(session)); 70 } 71 72 public async Task<ServiceResult<CombatSessionDto>> GetStateAsync(Guid sessionId) 73 { 74 var session = await LoadAsync(sessionId); 75 return session == null 76 ? ServiceResult<CombatSessionDto>.NotFound("Session not found") 77 : ServiceResult<CombatSessionDto>.Success(SessionMapping.ToDto(session)); 78 } 79 80 /// <summary> 81 /// Submits player intents without advancing the turn. This only records the intent. 82 /// Call Advance() separately to resolve the turn. 83 /// </summary> 84 public async Task<ServiceResult<CombatSessionDto>> SubmitPlayerIntentsAsync(Guid sessionId, SubmitIntentsRequest request) 85 { 86 var session = await LoadAsync(sessionId); 87 if (session == null) 88 { 89 return ServiceResult<CombatSessionDto>.NotFound("Session not found"); 90 } 91 92 // Validate operator is in Infil mode and session matches the active session before allowing combat actions 93 var modeValidation = await ValidateOperatorInInfilModeAsync(session, request.OperatorId); 94 if (modeValidation != null) 95 { 96 return ServiceResult<CombatSessionDto>.FromResult(modeValidation); 97 } 98 99 if (session.Phase != SessionPhase.Planning) 100 { 101 return ServiceResult<CombatSessionDto>.InvalidState("Intents can only be submitted during the Planning phase"); 102 } 103 104 if (session.Combat.Phase == CombatPhase.Ended) 105 { 106 return ServiceResult<CombatSessionDto>.InvalidState("Combat has already ended"); 107 } 108 109 var playerIntents = SessionMapping.ToDomainIntent(session.Player.Id, request.Intents); 110 var submission = session.Combat.SubmitIntents(session.Player, playerIntents); 111 if (!submission.success) 112 { 113 return ServiceResult<CombatSessionDto>.ValidationError(submission.errorMessage ?? "Intent submission failed"); 114 } 115 116 var enemyIntents = session.Ai.DecideIntents(session.Enemy, session.Player, session.Combat); 117 var enemySubmission = session.Combat.SubmitIntents(session.Enemy, enemyIntents); 118 if (!enemySubmission.success) 119 { 120 session.Combat.SubmitIntents(session.Enemy, SimultaneousIntents.CreateStop(session.Enemy.Id)); 121 } 122 123 session.RecordAction(); 124 await SaveAsync(session); 125 126 // Replicate the action through the distributed authority for P2P state verification. 127 // Sessions without an operator (legacy/test data created without OperatorId) are skipped 128 // because the distributed ledger tracks actions per operator. 129 if (_gameAuthority != null && !session.OperatorId.IsEmpty) 130 { 131 await _gameAuthority.SubmitActionAsync(new PlayerActionDto 132 { 133 OperatorId = session.OperatorId.Value, 134 Primary = request.Intents.Primary, 135 Movement = request.Intents.Movement, 136 Stance = request.Intents.Stance, 137 Cover = request.Intents.Cover, 138 CancelMovement = request.Intents.CancelMovement 139 }); 140 } 141 142 return ServiceResult<CombatSessionDto>.Success(SessionMapping.ToDto(session)); 143 } 144 145 /// <summary> 146 /// Advances the combat turn until the next planning phase or end of combat. 147 /// </summary> 148 public async Task<ServiceResult<CombatSessionDto>> AdvanceAsync(Guid sessionId, Guid? callerOperatorId = null) 149 { 150 var session = await LoadAsync(sessionId); 151 if (session == null) 152 { 153 return ServiceResult<CombatSessionDto>.NotFound("Session not found"); 154 } 155 156 // Validate operator is in Infil mode and session matches the active session before allowing combat actions 157 var modeValidation = await ValidateOperatorInInfilModeAsync(session, callerOperatorId); 158 if (modeValidation != null) 159 { 160 return ServiceResult<CombatSessionDto>.FromResult(modeValidation); 161 } 162 163 if (session.Phase != SessionPhase.Planning && session.Phase != SessionPhase.Resolving) 164 { 165 return ServiceResult<CombatSessionDto>.InvalidState("Advance is only allowed during Planning or Resolving phases"); 166 } 167 168 if (session.Combat.Phase == CombatPhase.Ended) 169 { 170 ApplyPostCombat(session); 171 session.TransitionTo(SessionPhase.Completed); 172 await SaveAsync(session); 173 return ServiceResult<CombatSessionDto>.Success(SessionMapping.ToDto(session)); 174 } 175 176 var pendingIntents = session.Combat.GetPendingIntents(); 177 var hasPlayerIntents = pendingIntents.player != null; 178 var hasEnemyIntents = pendingIntents.enemy != null; 179 if (!hasPlayerIntents || !hasEnemyIntents) 180 { 181 return ServiceResult<CombatSessionDto>.InvalidState("Advance requires recorded intents for both sides"); 182 } 183 184 if (session.Combat.Phase == CombatPhase.Planning) 185 { 186 session.TransitionTo(SessionPhase.Resolving); 187 session.Combat.BeginExecution(); 188 } 189 190 if (session.Combat.Phase == CombatPhase.Executing) 191 { 192 ResolveUntilPlanningOrEnd(session); 193 } 194 195 if (session.Combat.Phase == CombatPhase.Ended) 196 { 197 ApplyPostCombat(session); 198 session.TransitionTo(SessionPhase.Completed); 199 } 200 else 201 { 202 session.TransitionTo(SessionPhase.Planning); 203 session.AdvanceTurnCounter(); 204 } 205 206 session.RecordAction(); 207 await SaveAsync(session); 208 return ServiceResult<CombatSessionDto>.Success(SessionMapping.ToDto(session)); 209 } 210 211 public async Task<ServiceResult<PetStateDto>> ApplyPetInputAsync(Guid sessionId, PetInput input, DateTimeOffset now) 212 { 213 var session = await LoadAsync(sessionId); 214 if (session == null) 215 { 216 return ServiceResult<PetStateDto>.NotFound("Session not found"); 217 } 218 219 session.PetState = PetRules.Apply(session.PetState, input, now); 220 session.Player.Fatigue = session.PetState.Fatigue; 221 session.RecordAction(); 222 await SaveAsync(session); 223 return ServiceResult<PetStateDto>.Success(SessionMapping.ToDto(session.PetState)); 224 } 225 226 public async Task<ServiceResult<PetStateDto>> ApplyPetActionAsync(Guid sessionId, PetActionRequest request) 227 { 228 var now = DateTimeOffset.UtcNow; 229 var input = ResolvePetInput(request); 230 return await ApplyPetInputAsync(sessionId, input, now); 231 } 232 233 private async Task<CombatSession?> LoadAsync(Guid id) 234 { 235 var snapshot = await _store.LoadAsync(id); 236 return snapshot == null ? null : SessionMapping.FromSnapshot(snapshot); 237 } 238 239 private async Task SaveAsync(CombatSession session) 240 { 241 await _store.SaveAsync(SessionMapping.ToSnapshot(session)); 242 _updateHub?.Publish(session.Id); 243 } 244 245 /// <summary> 246 /// Validates that the operator associated with a session is in Infil mode. 247 /// Returns null if validation passes, or an error result if it fails. 248 /// </summary> 249 private async Task<ServiceResult?> ValidateOperatorInInfilModeAsync(CombatSession session, Guid? callerOperatorId = null) 250 { 251 // If operator event store is not available, skip validation (e.g., in-memory mode or tests) 252 if (_operatorEventStore == null) 253 { 254 return null; 255 } 256 257 // If session has no operator ID, skip validation (legacy sessions or test data) 258 if (session.OperatorId.IsEmpty) 259 { 260 return null; 261 } 262 263 // Validate the caller operator ID matches the session's owning operator (prevents cross-operator tamper) 264 if (callerOperatorId.HasValue && callerOperatorId.Value != session.OperatorId.Value) 265 { 266 return ServiceResult.InvalidState("Session does not belong to the specified operator"); 267 } 268 269 try 270 { 271 var events = await _operatorEventStore.LoadEventsAsync(session.OperatorId); 272 if (events.Count == 0) 273 { 274 return ServiceResult.NotFound("Operator not found"); 275 } 276 277 var aggregate = OperatorAggregate.FromEvents(events); 278 279 if (aggregate.CurrentMode != OperatorMode.Infil) 280 { 281 return ServiceResult.InvalidState("Combat actions are only allowed when operator is in Infil mode"); 282 } 283 284 if (aggregate.ActiveCombatSessionId == null || aggregate.ActiveCombatSessionId.Value != session.Id) 285 { 286 return ServiceResult.InvalidState("Session does not match the operator's active combat session"); 287 } 288 289 return null; 290 } 291 catch (InvalidOperationException ex) when (ex.Message.Contains("hash") || ex.Message.Contains("chain") || ex.Message.Contains("corrupted")) 292 { 293 // Event stream corruption - fail closed (reject action) 294 return ServiceResult.InvalidState($"Operator data corrupted: {ex.Message}"); 295 } 296 catch (Exception ex) 297 { 298 // For any other unexpected error, fail closed (reject action) to ensure security 299 return ServiceResult.InvalidState($"Failed to validate operator mode: {ex.Message}"); 300 } 301 } 302 303 private static void ResolveUntilPlanningOrEnd(CombatSession session) 304 { 305 if (session.Combat.Phase == CombatPhase.Planning) 306 { 307 return; 308 } 309 310 while (session.Combat.Phase == CombatPhase.Executing) 311 { 312 var hasMoreEvents = session.Combat.ExecuteUntilReactionWindow(); 313 if (!hasMoreEvents || session.Combat.Phase != CombatPhase.Executing) 314 { 315 break; 316 } 317 } 318 319 if (session.Combat.Phase == CombatPhase.Ended) 320 { 321 ApplyPostCombat(session); 322 } 323 } 324 325 private static void ApplyPostCombat(CombatSession session) 326 { 327 if (session.PostCombatResolved) 328 { 329 return; 330 } 331 332 session.OperatorManager.CompleteCombat(session.Player, session.Player.IsAlive); 333 334 float healthLost = session.Player.MaxHealth - session.Player.Health; 335 int hitsTaken = (int)Math.Ceiling(healthLost / 10f); 336 337 // TODO: Player level is no longer stored in session (removed as part of operator/combat separation). 338 // Options to fix difficulty calculation: 339 // 1. Load operator aggregate via session.OperatorId to get actual level (adds dependency) 340 // 2. Pass level from caller (requires API change) 341 // 3. Refactor OpponentDifficulty.Compute to not require player level 342 // For now, using 0 as a placeholder - this will treat all operators as level 0 for pet/mission calculations 343 float opponentDifficulty = OpponentDifficulty.Compute( 344 opponentLevel: session.EnemyLevel, 345 playerLevel: 0); // Placeholder - see TODO above 346 347 if (session.Player.IsAlive && !session.Enemy.IsAlive) 348 { 349 opponentDifficulty *= VictoryDifficultyModifier; 350 } 351 else if (!session.Player.IsAlive) 352 { 353 opponentDifficulty = Math.Min(100f, opponentDifficulty * DefeatDifficultyModifier); 354 } 355 356 session.PetState = PetRules.Apply(session.PetState, new MissionInput(hitsTaken, opponentDifficulty), DateTimeOffset.UtcNow); 357 358 // XP gain is now handled by OperatorExfilService, not in combat sessions 359 360 session.Player.Fatigue = session.PetState.Fatigue; 361 session.PostCombatResolved = true; 362 } 363 364 private static PetInput ResolvePetInput(PetActionRequest request) 365 { 366 var action = request.Action?.Trim().ToLowerInvariant() ?? "rest"; 367 return action switch 368 { 369 "eat" => new EatInput(request.Nutrition ?? 20f), 370 "drink" => new DrinkInput(request.Hydration ?? 20f), 371 "mission" => new MissionInput(request.HitsTaken ?? 0, request.OpponentDifficulty ?? 50f), 372 _ => new RestInput(TimeSpan.FromHours(request.Hours ?? 1f)) 373 }; 374 } 375 376 /// <summary> 377 /// Gets the combat outcome for a completed session. 378 /// </summary> 379 public async Task<ServiceResult<CombatOutcome>> GetCombatOutcomeAsync(Guid sessionId) 380 { 381 var session = await LoadAsync(sessionId); 382 if (session == null) 383 { 384 return ServiceResult<CombatOutcome>.NotFound("Session not found"); 385 } 386 387 if (session.Phase != SessionPhase.Completed) 388 { 389 return ServiceResult<CombatOutcome>.InvalidState("Combat session is not completed yet"); 390 } 391 392 try 393 { 394 var outcome = session.GetOutcome(); 395 return ServiceResult<CombatOutcome>.Success(outcome); 396 } 397 catch (Exception ex) 398 { 399 return ServiceResult<CombatOutcome>.InvalidState($"Failed to get outcome: {ex.Message}"); 400 } 401 } 402 403 /// <summary> 404 /// Combat sessions are retained as audit records and cannot be deleted. 405 /// </summary> 406 public async Task<ServiceResult> DeleteSessionAsync(Guid sessionId) 407 { 408 var existing = await LoadAsync(sessionId); 409 if (existing == null) 410 return ServiceResult.NotFound("Session not found"); 411 412 return ServiceResult.InvalidState("Combat sessions are audit records and cannot be deleted."); 413 } 414 }