/ GUNRPG.Application / Sessions / CombatSessionService.cs
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  }