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 }