/ GUNRPG.Core / Operators / OperatorAggregate.cs
OperatorAggregate.cs
  1  namespace GUNRPG.Core.Operators;
  2  
  3  using GUNRPG.Core.VirtualPet;
  4  
  5  /// <summary>
  6  /// Event-sourced aggregate representing a long-lived operator character.
  7  /// State is derived entirely by replaying events from the event store.
  8  /// This aggregate represents progression, identity, and long-term state.
  9  /// 
 10  /// IMPORTANT: This is the exfil-only representation of an operator.
 11  /// Combat sessions use a copy of operator stats, not this aggregate directly.
 12  /// </summary>
 13  public sealed class OperatorAggregate
 14  {
 15      private readonly List<OperatorEvent> _events = new();
 16  
 17      /// <summary>
 18      /// Unique identifier for this operator.
 19      /// </summary>
 20      public OperatorId Id { get; private set; }
 21  
 22      /// <summary>
 23      /// Operator's display name.
 24      /// </summary>
 25      public string Name { get; private set; } = string.Empty;
 26  
 27      /// <summary>
 28      /// Total experience points earned.
 29      /// </summary>
 30      public long TotalXp { get; private set; }
 31  
 32      /// <summary>
 33      /// Current health (out of combat). Max is derived from level/progression.
 34      /// </summary>
 35      public float CurrentHealth { get; private set; }
 36  
 37      /// <summary>
 38      /// Maximum health based on progression.
 39      /// </summary>
 40      public float MaxHealth { get; private set; }
 41  
 42      /// <summary>
 43      /// Currently equipped weapon name.
 44      /// </summary>
 45      public string EquippedWeaponName { get; private set; } = string.Empty;
 46  
 47      /// <summary>
 48      /// List of unlocked perks/skills.
 49      /// </summary>
 50      public IReadOnlyList<string> UnlockedPerks { get; private set; } = new List<string>();
 51  
 52      /// <summary>
 53      /// Number of consecutive successful exfils.
 54      /// Increments on ExfilSucceeded, resets on ExfilFailed, OperatorDied, or rollback.
 55      /// </summary>
 56      public int ExfilStreak { get; private set; }
 57  
 58      /// <summary>
 59      /// Whether this operator is dead.
 60      /// Once dead, no further state changes are allowed (enforced at service level).
 61      /// </summary>
 62      public bool IsDead { get; private set; }
 63  
 64      /// <summary>
 65      /// Current operational mode (Base or Infil).
 66      /// </summary>
 67      public OperatorMode CurrentMode { get; private set; }
 68  
 69      /// <summary>
 70      /// Time when the current infil started. Null if not in infil mode.
 71      /// Used to enforce the 30-minute infil timer.
 72      /// </summary>
 73      public DateTimeOffset? InfilStartTime { get; private set; }
 74  
 75      /// <summary>
 76      /// Unique identifier for the current infil session. Persists across multiple combats during a single infil.
 77      /// Null if not in Infil mode. Set when infil starts, cleared when infil ends.
 78      /// </summary>
 79      public Guid? InfilSessionId { get; private set; }
 80  
 81      /// <summary>
 82      /// Active combat session ID for the current combat encounter. Null if not in active combat or in Base mode.
 83      /// This can be null even when in Infil mode (between combats after victory).
 84      /// </summary>
 85      public Guid? ActiveCombatSessionId { get; private set; }
 86  
 87      /// <summary>
 88      /// Locked loadout snapshot when in Infil mode. Empty if in Base mode.
 89      /// Loadout is locked during infil to prevent mid-mission changes.
 90      /// </summary>
 91      public string LockedLoadout { get; private set; } = string.Empty;
 92  
 93      /// <summary>
 94      /// Virtual pet state for this operator.
 95      /// Tracks health, fatigue, stress, morale, hunger, hydration, and injury,
 96      /// along with the last updated timestamp. Updated through pet actions and background decay.
 97      /// </summary>
 98      public PetState? PetState { get; private set; }
 99  
100      /// <summary>
101      /// Current sequence number (number of events applied).
102      /// </summary>
103      public long CurrentSequence => _events.Count - 1;
104  
105      /// <summary>
106      /// All events that have been applied to this aggregate.
107      /// </summary>
108      public IReadOnlyList<OperatorEvent> Events => _events.AsReadOnly();
109  
110      /// <summary>
111      /// Creates a new operator aggregate from a creation event.
112      /// </summary>
113      public static OperatorAggregate Create(OperatorCreatedEvent createdEvent)
114      {
115          var aggregate = new OperatorAggregate();
116          aggregate.ApplyEvent(createdEvent, isNew: true);
117          return aggregate;
118      }
119  
120      /// <summary>
121      /// Reconstitutes an operator aggregate by replaying events from the event store.
122      /// Verifies hash chain integrity during replay.
123      /// If verification fails, rolls back to the last valid event.
124      /// </summary>
125      /// <param name="events">Ordered events from the event store</param>
126      /// <returns>Reconstituted aggregate</returns>
127      /// <exception cref="InvalidOperationException">If no valid events exist</exception>
128      public static OperatorAggregate FromEvents(IEnumerable<OperatorEvent> events)
129      {
130          var eventList = events.ToList();
131          if (eventList.Count == 0)
132              throw new InvalidOperationException("Cannot create aggregate from empty event list");
133  
134          var aggregate = new OperatorAggregate();
135          var validEvents = new List<OperatorEvent>();
136  
137          OperatorEvent? previousEvent = null;
138          foreach (var evt in eventList)
139          {
140              // Verify hash integrity
141              if (!evt.VerifyHash())
142              {
143                  // Hash verification failed - stop here and use only valid events up to this point
144                  break;
145              }
146  
147              // Verify chain integrity
148              if (!evt.VerifyChain(previousEvent))
149              {
150                  // Chain broken - stop here and use only valid events up to this point
151                  break;
152              }
153  
154              // Event is valid - apply it
155              validEvents.Add(evt);
156              aggregate.ApplyEvent(evt, isNew: false);
157              previousEvent = evt;
158          }
159  
160          // Must have at least one valid event
161          if (validEvents.Count == 0)
162              throw new InvalidOperationException("No valid events found - first event failed verification");
163  
164          return aggregate;
165      }
166  
167      /// <summary>
168      /// Applies a new event to this aggregate.
169      /// For new events, adds to the pending changes. For historical events, just updates state.
170      /// </summary>
171      private void ApplyEvent(OperatorEvent evt, bool isNew)
172      {
173          // Apply state change based on event type
174          switch (evt)
175          {
176              case OperatorCreatedEvent created:
177                  Id = created.OperatorId;
178                  Name = created.GetName();
179                  TotalXp = 0;
180                  MaxHealth = 100f; // Default starting health
181                  CurrentHealth = MaxHealth;
182                  EquippedWeaponName = string.Empty;
183                  UnlockedPerks = new List<string>();
184                  ExfilStreak = 0;
185                  IsDead = false;
186                  CurrentMode = OperatorMode.Base; // Operators start in Base mode
187                  InfilStartTime = null;
188                  InfilSessionId = null;
189                  ActiveCombatSessionId = null;
190                  LockedLoadout = string.Empty;
191                  // Initialize pet state with healthy defaults
192                  PetState = new PetState(
193                      OperatorId: Id.Value,
194                      Health: 100f,
195                      Fatigue: 0f,
196                      Injury: 0f,
197                      Stress: 0f,
198                      Morale: 100f,
199                      Hunger: 0f,
200                      Hydration: 100f,
201                      LastUpdated: created.Timestamp
202                  );
203                  break;
204  
205              case XpGainedEvent xpGained:
206                  var (xpAmount, _) = xpGained.GetPayload();
207                  TotalXp += xpAmount;
208                  break;
209  
210              case WoundsTreatedEvent woundsTreated:
211                  var healthRestored = woundsTreated.GetHealthRestored();
212                  CurrentHealth = Math.Min(MaxHealth, CurrentHealth + healthRestored);
213                  break;
214  
215              case LoadoutChangedEvent loadoutChanged:
216                  EquippedWeaponName = loadoutChanged.GetWeaponName();
217                  break;
218  
219              case PerkUnlockedEvent perkUnlocked:
220                  var perkName = perkUnlocked.GetPerkName();
221                  var perks = new List<string>(UnlockedPerks) { perkName };
222                  UnlockedPerks = perks;
223                  break;
224  
225              case CombatVictoryEvent:
226                  // Clear active combat session since this combat is complete
227                  // Operator stays in Infil mode with InfilSessionId intact to allow consecutive combats
228                  // Note: ExfilStreak is NOT incremented here - it only increments on successful infil completion
229                  ActiveCombatSessionId = null;
230                  break;
231  
232              case ExfilFailedEvent:
233                  ExfilStreak = 0;
234                  break;
235  
236              case OperatorDiedEvent:
237                  // Operator "died" in mission but is respawned at base with full health
238                  // This allows continued gameplay after mission failure
239                  IsDead = false; // Allow operator to continue after respawn
240                  CurrentHealth = MaxHealth;
241                  ExfilStreak = 0;
242                  // Death automatically ends infil if active
243                  if (CurrentMode == OperatorMode.Infil)
244                  {
245                      CurrentMode = OperatorMode.Base;
246                      InfilStartTime = null;
247                      InfilSessionId = null;
248                      ActiveCombatSessionId = null;
249                      LockedLoadout = string.Empty;
250                  }
251                  break;
252  
253              case InfilStartedEvent infilStarted:
254                  var (infilSessionId, lockedLoadout, infilStartTime) = infilStarted.GetPayload();
255                  CurrentMode = OperatorMode.Infil;
256                  InfilStartTime = infilStartTime;
257                  InfilSessionId = infilSessionId;
258                  // ActiveCombatSessionId is NOT set here - combat sessions are created separately
259                  LockedLoadout = lockedLoadout;
260                  break;
261  
262              case InfilEndedEvent infilEnded:
263                  CurrentMode = OperatorMode.Base;
264                  InfilStartTime = null;
265                  InfilSessionId = null;
266                  ActiveCombatSessionId = null;
267                  var (wasSuccessful, _) = infilEnded.GetPayload();
268                  if (wasSuccessful)
269                  {
270                      // Increment exfil streak on successful infil completion
271                      ExfilStreak++;
272                      // On success, preserve loadout
273                      LockedLoadout = string.Empty;
274                  }
275                  else
276                  {
277                      // On failure, clear loadout (gear loss) and reset streak
278                      ExfilStreak = 0;
279                      LockedLoadout = string.Empty;
280                      EquippedWeaponName = string.Empty;
281                  }
282                  break;
283  
284              case CombatSessionStartedEvent combatSessionStarted:
285                  var combatSessionId = combatSessionStarted.GetPayload();
286                  ActiveCombatSessionId = combatSessionId;
287                  break;
288  
289              case PetActionAppliedEvent petAction:
290                  var (_, health, fatigue, injury, stress, morale, hunger, hydration, lastUpdated) = petAction.GetPayload();
291                  PetState = new PetState(
292                      OperatorId: Id.Value,
293                      Health: health,
294                      Fatigue: fatigue,
295                      Injury: injury,
296                      Stress: stress,
297                      Morale: morale,
298                      Hunger: hunger,
299                      Hydration: hydration,
300                      LastUpdated: lastUpdated
301                  );
302                  break;
303  
304              default:
305                  throw new InvalidOperationException($"Unknown event type: {evt.EventType}");
306          }
307  
308          // Add to event list
309          _events.Add(evt);
310      }
311  
312      /// <summary>
313      /// Gets the hash of the most recent event, used for chaining new events.
314      /// Returns empty string if no events exist yet.
315      /// </summary>
316      public string GetLastEventHash()
317      {
318          return _events.Count > 0 ? _events[^1].Hash : string.Empty;
319      }
320  
321      /// <summary>
322      /// Applies a pet action to the operator's virtual pet.
323      /// Only allowed in Base mode. Updates pet state via event sourcing.
324      /// </summary>
325      /// <param name="input">The pet action to apply (Rest, Eat, Drink)</param>
326      /// <param name="now">Current timestamp for calculating decay and tracking update time</param>
327      /// <returns>The new pet action applied event</returns>
328      public PetActionAppliedEvent ApplyPetAction(PetInput input, DateTimeOffset now)
329      {
330          if (CurrentMode != OperatorMode.Base)
331          {
332              throw new InvalidOperationException("Pet actions can only be applied in Base mode");
333          }
334  
335          if (IsDead)
336          {
337              throw new InvalidOperationException("Cannot apply pet actions to a dead operator");
338          }
339  
340          if (PetState == null)
341          {
342              throw new InvalidOperationException("Operator has no pet state");
343          }
344  
345          // Apply the pet action using the pure rules engine
346          var newPetState = PetRules.Apply(PetState, input, now);
347  
348          // Determine action name for event tracking
349          string actionName = input switch
350          {
351              RestInput => "rest",
352              EatInput => "eat",
353              DrinkInput => "drink",
354              _ => throw new InvalidOperationException("Unsupported pet input type for pet action")
355          };
356  
357          // Create and apply the event
358          var evt = new PetActionAppliedEvent(
359              Id,
360              CurrentSequence + 1,
361              actionName,
362              newPetState.Health,
363              newPetState.Fatigue,
364              newPetState.Injury,
365              newPetState.Stress,
366              newPetState.Morale,
367              newPetState.Hunger,
368              newPetState.Hydration,
369              newPetState.LastUpdated,
370              GetLastEventHash(),
371              now
372          );
373  
374          ApplyEvent(evt, isNew: true);
375          return evt;
376      }
377  
378      /// <summary>
379      /// Creates a copy of this operator's stats for use in combat (infil).
380      /// Combat works with a snapshot and never mutates the aggregate directly.
381      /// </summary>
382      public Operator CreateCombatSnapshot()
383      {
384          var combatOp = new Operator(Name, Id.Value)
385          {
386              MaxHealth = MaxHealth,
387              Health = CurrentHealth
388              // Additional stats can be mapped here as the system evolves
389          };
390  
391          return combatOp;
392      }
393  
394      /// <summary>
395      /// Applies damage taken during combat. This should only be called during exfil
396      /// after reviewing combat outcomes. 
397      /// INTERNAL: This method mutates state without emitting an event.
398      /// Use with caution - prefer emitting a proper event when available.
399      /// </summary>
400      internal void TakeCombatDamage(float damageAmount)
401      {
402          CurrentHealth = Math.Max(0, CurrentHealth - damageAmount);
403      }
404  }