/ GUNRPG.Core / Operators / OperatorEvent.cs
OperatorEvent.cs
  1  using System.Security.Cryptography;
  2  using System.Text;
  3  using System.Text.Json;
  4  
  5  namespace GUNRPG.Core.Operators;
  6  
  7  /// <summary>
  8  /// Base class for all operator events in the event-sourced operator aggregate.
  9  /// Events are immutable, hash-chained, and ordered by sequence number.
 10  /// Each event contains a cryptographic hash of its contents plus the previous event's hash,
 11  /// creating a tamper-evident chain.
 12  /// </summary>
 13  public abstract class OperatorEvent
 14  {
 15      /// <summary>
 16      /// The operator this event applies to.
 17      /// </summary>
 18      public OperatorId OperatorId { get; }
 19  
 20      /// <summary>
 21      /// Sequential number of this event in the operator's event stream.
 22      /// Must be monotonically increasing with no gaps.
 23      /// </summary>
 24      public long SequenceNumber { get; }
 25  
 26      /// <summary>
 27      /// Discriminator for the event type (e.g., "XpGained", "WoundsTreated").
 28      /// </summary>
 29      public string EventType { get; }
 30  
 31      /// <summary>
 32      /// JSON-serialized payload containing event-specific data.
 33      /// </summary>
 34      public string Payload { get; }
 35  
 36      /// <summary>
 37      /// Hash of the previous event in the chain.
 38      /// Empty for the first event (sequence 0).
 39      /// </summary>
 40      public string PreviousHash { get; }
 41  
 42      /// <summary>
 43      /// Hash of this event's content (OperatorId + SequenceNumber + EventType + Payload + PreviousHash).
 44      /// Computed deterministically using SHA256.
 45      /// </summary>
 46      public string Hash { get; }
 47  
 48      /// <summary>
 49      /// When this event was created (UTC).
 50      /// </summary>
 51      public DateTimeOffset Timestamp { get; }
 52  
 53      protected OperatorEvent(
 54          OperatorId operatorId,
 55          long sequenceNumber,
 56          string eventType,
 57          string payload,
 58          string previousHash,
 59          DateTimeOffset? timestamp = null)
 60      {
 61          if (operatorId.IsEmpty)
 62              throw new ArgumentException("Operator ID cannot be empty", nameof(operatorId));
 63          
 64          if (sequenceNumber < 0)
 65              throw new ArgumentException("Sequence number must be non-negative", nameof(sequenceNumber));
 66          
 67          if (string.IsNullOrWhiteSpace(eventType))
 68              throw new ArgumentException("Event type cannot be empty", nameof(eventType));
 69          
 70          if (payload == null)
 71              throw new ArgumentNullException(nameof(payload));
 72          
 73          if (previousHash == null)
 74              throw new ArgumentNullException(nameof(previousHash));
 75  
 76          OperatorId = operatorId;
 77          SequenceNumber = sequenceNumber;
 78          EventType = eventType;
 79          Payload = payload;
 80          PreviousHash = previousHash;
 81          Timestamp = timestamp ?? DateTimeOffset.UtcNow;
 82  
 83          // Compute hash deterministically
 84          Hash = ComputeHash(operatorId, sequenceNumber, eventType, payload, previousHash);
 85      }
 86  
 87      /// <summary>
 88      /// Computes a deterministic SHA256 hash of the event contents.
 89      /// No cryptographic keys are used - this is for integrity verification only.
 90      /// </summary>
 91      private static string ComputeHash(
 92          OperatorId operatorId,
 93          long sequenceNumber,
 94          string eventType,
 95          string payload,
 96          string previousHash)
 97      {
 98          var hashInput = $"{operatorId.Value}|{sequenceNumber}|{eventType}|{payload}|{previousHash}";
 99          var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(hashInput));
100          return Convert.ToHexString(hashBytes).ToLowerInvariant();
101      }
102  
103      /// <summary>
104      /// Verifies that this event's hash matches its computed hash.
105      /// </summary>
106      public bool VerifyHash()
107      {
108          var computedHash = ComputeHash(OperatorId, SequenceNumber, EventType, Payload, PreviousHash);
109          return Hash == computedHash;
110      }
111  
112      /// <summary>
113      /// Verifies that this event follows the previous event in the chain.
114      /// Checks that sequence numbers are consecutive and hashes match.
115      /// </summary>
116      public bool VerifyChain(OperatorEvent? previousEvent)
117      {
118          if (previousEvent == null)
119          {
120              // First event: sequence must be 0, previous hash must be empty
121              return SequenceNumber == 0 && PreviousHash == string.Empty;
122          }
123  
124          // Subsequent events: sequence must increment by 1, previous hash must match
125          return SequenceNumber == previousEvent.SequenceNumber + 1 &&
126                 PreviousHash == previousEvent.Hash;
127      }
128  }
129  
130  /// <summary>
131  /// Event emitted when an operator is created.
132  /// This is always the first event (sequence 0) for a new operator.
133  /// </summary>
134  public sealed class OperatorCreatedEvent : OperatorEvent
135  {
136      public OperatorCreatedEvent(
137          OperatorId operatorId,
138          string name,
139          DateTimeOffset? timestamp = null)
140          : base(
141              operatorId,
142              sequenceNumber: 0,
143              eventType: "OperatorCreated",
144              payload: JsonSerializer.Serialize(new { Name = ValidateName(name) }),
145              previousHash: string.Empty,
146              timestamp: timestamp)
147      {
148      }
149  
150      private static string ValidateName(string name)
151      {
152          if (string.IsNullOrWhiteSpace(name))
153              throw new ArgumentException("Operator name cannot be empty or whitespace", nameof(name));
154          return name.Trim();
155      }
156  
157      public string GetName() => JsonSerializer.Deserialize<CreatedPayload>(Payload)!.Name;
158  
159      /// <summary>
160      /// Rehydrates an OperatorCreatedEvent from storage.
161      /// </summary>
162      public static OperatorCreatedEvent Rehydrate(
163          OperatorId operatorId,
164          string payload,
165          DateTimeOffset timestamp)
166      {
167          var data = JsonSerializer.Deserialize<CreatedPayload>(payload)!;
168          return new OperatorCreatedEvent(operatorId, data.Name, timestamp);
169      }
170  
171      private record CreatedPayload(string Name);
172  }
173  
174  /// <summary>
175  /// Event emitted when an operator gains experience points.
176  /// </summary>
177  public sealed class XpGainedEvent : OperatorEvent
178  {
179      public XpGainedEvent(
180          OperatorId operatorId,
181          long sequenceNumber,
182          long xpAmount,
183          string reason,
184          string previousHash,
185          DateTimeOffset? timestamp = null)
186          : base(
187              operatorId,
188              sequenceNumber,
189              eventType: "XpGained",
190              payload: JsonSerializer.Serialize(new { XpAmount = xpAmount, Reason = reason }),
191              previousHash: previousHash,
192              timestamp: timestamp)
193      {
194      }
195  
196      public (long XpAmount, string Reason) GetPayload()
197      {
198          var data = JsonSerializer.Deserialize<XpPayload>(Payload)!;
199          return (data.XpAmount, data.Reason);
200      }
201  
202      /// <summary>
203      /// Rehydrates an XpGainedEvent from storage.
204      /// </summary>
205      public static XpGainedEvent Rehydrate(
206          OperatorId operatorId,
207          long sequenceNumber,
208          string payload,
209          string previousHash,
210          DateTimeOffset timestamp)
211      {
212          var data = JsonSerializer.Deserialize<XpPayload>(payload)!;
213          return new XpGainedEvent(operatorId, sequenceNumber, data.XpAmount, data.Reason, previousHash, timestamp);
214      }
215  
216      private record XpPayload(long XpAmount, string Reason);
217  }
218  
219  /// <summary>
220  /// Event emitted when an operator's wounds are treated (health restored).
221  /// </summary>
222  public sealed class WoundsTreatedEvent : OperatorEvent
223  {
224      public WoundsTreatedEvent(
225          OperatorId operatorId,
226          long sequenceNumber,
227          float healthRestored,
228          string previousHash,
229          DateTimeOffset? timestamp = null)
230          : base(
231              operatorId,
232              sequenceNumber,
233              eventType: "WoundsTreated",
234              payload: JsonSerializer.Serialize(new { HealthRestored = healthRestored }),
235              previousHash: previousHash,
236              timestamp: timestamp)
237      {
238      }
239  
240      public float GetHealthRestored()
241      {
242          var data = JsonSerializer.Deserialize<WoundsPayload>(Payload)!;
243          return data.HealthRestored;
244      }
245  
246      /// <summary>
247      /// Rehydrates a WoundsTreatedEvent from storage.
248      /// </summary>
249      public static WoundsTreatedEvent Rehydrate(
250          OperatorId operatorId,
251          long sequenceNumber,
252          string payload,
253          string previousHash,
254          DateTimeOffset timestamp)
255      {
256          var data = JsonSerializer.Deserialize<WoundsPayload>(payload)!;
257          return new WoundsTreatedEvent(operatorId, sequenceNumber, data.HealthRestored, previousHash, timestamp);
258      }
259  
260      private record WoundsPayload(float HealthRestored);
261  }
262  
263  /// <summary>
264  /// Event emitted when an operator's loadout is changed.
265  /// </summary>
266  public sealed class LoadoutChangedEvent : OperatorEvent
267  {
268      public LoadoutChangedEvent(
269          OperatorId operatorId,
270          long sequenceNumber,
271          string weaponName,
272          string previousHash,
273          DateTimeOffset? timestamp = null)
274          : base(
275              operatorId,
276              sequenceNumber,
277              eventType: "LoadoutChanged",
278              payload: JsonSerializer.Serialize(new { WeaponName = weaponName }),
279              previousHash: previousHash,
280              timestamp: timestamp)
281      {
282      }
283  
284      public string GetWeaponName()
285      {
286          var data = JsonSerializer.Deserialize<LoadoutPayload>(Payload)!;
287          return data.WeaponName;
288      }
289  
290      /// <summary>
291      /// Rehydrates a LoadoutChangedEvent from storage.
292      /// </summary>
293      public static LoadoutChangedEvent Rehydrate(
294          OperatorId operatorId,
295          long sequenceNumber,
296          string payload,
297          string previousHash,
298          DateTimeOffset timestamp)
299      {
300          var data = JsonSerializer.Deserialize<LoadoutPayload>(payload)!;
301          return new LoadoutChangedEvent(operatorId, sequenceNumber, data.WeaponName, previousHash, timestamp);
302      }
303  
304      private record LoadoutPayload(string WeaponName);
305  }
306  
307  /// <summary>
308  /// Event emitted when an operator unlocks a new perk or skill.
309  /// </summary>
310  public sealed class PerkUnlockedEvent : OperatorEvent
311  {
312      public PerkUnlockedEvent(
313          OperatorId operatorId,
314          long sequenceNumber,
315          string perkName,
316          string previousHash,
317          DateTimeOffset? timestamp = null)
318          : base(
319              operatorId,
320              sequenceNumber,
321              eventType: "PerkUnlocked",
322              payload: JsonSerializer.Serialize(new { PerkName = perkName }),
323              previousHash: previousHash,
324              timestamp: timestamp)
325      {
326      }
327  
328      public string GetPerkName()
329      {
330          var data = JsonSerializer.Deserialize<PerkPayload>(Payload)!;
331          return data.PerkName;
332      }
333  
334      /// <summary>
335      /// Rehydrates a PerkUnlockedEvent from storage.
336      /// </summary>
337      public static PerkUnlockedEvent Rehydrate(
338          OperatorId operatorId,
339          long sequenceNumber,
340          string payload,
341          string previousHash,
342          DateTimeOffset timestamp)
343      {
344          var data = JsonSerializer.Deserialize<PerkPayload>(payload)!;
345          return new PerkUnlockedEvent(operatorId, sequenceNumber, data.PerkName, previousHash, timestamp);
346      }
347  
348      private record PerkPayload(string PerkName);
349  }
350  
351  /// <summary>
352  /// Event emitted when an operator wins a combat encounter.
353  /// Clears the active combat session ID so the operator can start a new combat within the same infil.
354  /// Does NOT increment ExfilStreak — the streak is incremented only when the infil completes successfully.
355  /// </summary>
356  public sealed class CombatVictoryEvent : OperatorEvent
357  {
358      public CombatVictoryEvent(
359          OperatorId operatorId,
360          long sequenceNumber,
361          string previousHash,
362          DateTimeOffset? timestamp = null)
363          : base(
364              operatorId,
365              sequenceNumber,
366              eventType: "CombatVictory",
367              payload: JsonSerializer.Serialize(new { }),
368              previousHash: previousHash,
369              timestamp: timestamp)
370      {
371      }
372  
373      private CombatVictoryEvent(
374          OperatorId operatorId,
375          long sequenceNumber,
376          string payload,
377          string previousHash,
378          DateTimeOffset timestamp)
379          : base(
380              operatorId,
381              sequenceNumber,
382              eventType: "CombatVictory",
383              payload: payload,
384              previousHash: previousHash,
385              timestamp: timestamp)
386      {
387      }
388  
389      /// <summary>
390      /// Rehydrates a CombatVictoryEvent from storage.
391      /// Accepts the persisted payload to enable hash chain verification by the caller.
392      /// </summary>
393      public static CombatVictoryEvent Rehydrate(
394          OperatorId operatorId,
395          long sequenceNumber,
396          string payload,
397          string previousHash,
398          DateTimeOffset timestamp)
399      {
400          return new CombatVictoryEvent(operatorId, sequenceNumber, payload, previousHash, timestamp);
401      }
402  }
403  
404  /// <summary>
405  /// Event emitted when an operator fails to complete exfil.
406  /// This resets the operator's exfil streak.
407  /// </summary>
408  public sealed class ExfilFailedEvent : OperatorEvent
409  {
410      public ExfilFailedEvent(
411          OperatorId operatorId,
412          long sequenceNumber,
413          string reason,
414          string previousHash,
415          DateTimeOffset? timestamp = null)
416          : base(
417              operatorId,
418              sequenceNumber,
419              eventType: "ExfilFailed",
420              payload: JsonSerializer.Serialize(new { Reason = reason }),
421              previousHash: previousHash,
422              timestamp: timestamp)
423      {
424      }
425  
426      public string GetReason()
427      {
428          var data = JsonSerializer.Deserialize<ExfilFailedPayload>(Payload)!;
429          return data.Reason;
430      }
431  
432      /// <summary>
433      /// Rehydrates an ExfilFailedEvent from storage.
434      /// </summary>
435      public static ExfilFailedEvent Rehydrate(
436          OperatorId operatorId,
437          long sequenceNumber,
438          string payload,
439          string previousHash,
440          DateTimeOffset timestamp)
441      {
442          var data = JsonSerializer.Deserialize<ExfilFailedPayload>(payload)!;
443          return new ExfilFailedEvent(operatorId, sequenceNumber, data.Reason, previousHash, timestamp);
444      }
445  
446      private record ExfilFailedPayload(string Reason);
447  }
448  
449  /// <summary>
450  /// Event emitted when an operator dies.
451  /// This marks the operator as dead, sets health to 0, and resets the exfil streak.
452  /// </summary>
453  public sealed class OperatorDiedEvent : OperatorEvent
454  {
455      public OperatorDiedEvent(
456          OperatorId operatorId,
457          long sequenceNumber,
458          string causeOfDeath,
459          string previousHash,
460          DateTimeOffset? timestamp = null)
461          : base(
462              operatorId,
463              sequenceNumber,
464              eventType: "OperatorDied",
465              payload: JsonSerializer.Serialize(new { CauseOfDeath = causeOfDeath }),
466              previousHash: previousHash,
467              timestamp: timestamp)
468      {
469      }
470  
471      public string GetCauseOfDeath()
472      {
473          var data = JsonSerializer.Deserialize<OperatorDiedPayload>(Payload)!;
474          return data.CauseOfDeath;
475      }
476  
477      /// <summary>
478      /// Rehydrates an OperatorDiedEvent from storage.
479      /// </summary>
480      public static OperatorDiedEvent Rehydrate(
481          OperatorId operatorId,
482          long sequenceNumber,
483          string payload,
484          string previousHash,
485          DateTimeOffset timestamp)
486      {
487          var data = JsonSerializer.Deserialize<OperatorDiedPayload>(payload)!;
488          return new OperatorDiedEvent(operatorId, sequenceNumber, data.CauseOfDeath, previousHash, timestamp);
489      }
490  
491      private record OperatorDiedPayload(string CauseOfDeath);
492  }
493  
494  /// <summary>
495  /// Event emitted when an operator starts an infil (deploys to the field).
496  /// Transitions from Base mode to Infil mode, locks loadout, and starts the 30-minute timer.
497  /// </summary>
498  public sealed class InfilStartedEvent : OperatorEvent
499  {
500      public InfilStartedEvent(
501          OperatorId operatorId,
502          long sequenceNumber,
503          Guid sessionId,
504          string lockedLoadout,
505          DateTimeOffset infilStartTime,
506          string previousHash,
507          DateTimeOffset? timestamp = null)
508          : base(
509              operatorId,
510              sequenceNumber,
511              eventType: "InfilStarted",
512              payload: JsonSerializer.Serialize(new 
513              { 
514                  SessionId = sessionId, 
515                  LockedLoadout = lockedLoadout,
516                  InfilStartTime = infilStartTime
517              }),
518              previousHash: previousHash,
519              timestamp: timestamp)
520      {
521      }
522  
523      public (Guid SessionId, string LockedLoadout, DateTimeOffset InfilStartTime) GetPayload()
524      {
525          var data = JsonSerializer.Deserialize<InfilStartedPayload>(Payload)!;
526          return (data.SessionId, data.LockedLoadout, data.InfilStartTime);
527      }
528  
529      /// <summary>
530      /// Rehydrates an InfilStartedEvent from storage.
531      /// </summary>
532      public static InfilStartedEvent Rehydrate(
533          OperatorId operatorId,
534          long sequenceNumber,
535          string payload,
536          string previousHash,
537          DateTimeOffset timestamp)
538      {
539          var data = JsonSerializer.Deserialize<InfilStartedPayload>(payload)!;
540          return new InfilStartedEvent(
541              operatorId, 
542              sequenceNumber, 
543              data.SessionId, 
544              data.LockedLoadout,
545              data.InfilStartTime,
546              previousHash, 
547              timestamp);
548      }
549  
550      private record InfilStartedPayload(Guid SessionId, string LockedLoadout, DateTimeOffset InfilStartTime);
551  }
552  
553  /// <summary>
554  /// Event emitted when an operator ends an infil (returns to base).
555  /// Transitions from Infil mode to Base mode.
556  /// Records whether the infil was successful or failed, and the reason.
557  /// </summary>
558  public sealed class InfilEndedEvent : OperatorEvent
559  {
560      public InfilEndedEvent(
561          OperatorId operatorId,
562          long sequenceNumber,
563          bool wasSuccessful,
564          string reason,
565          string previousHash,
566          DateTimeOffset? timestamp = null)
567          : base(
568              operatorId,
569              sequenceNumber,
570              eventType: "InfilEnded",
571              payload: JsonSerializer.Serialize(new { WasSuccessful = wasSuccessful, Reason = reason }),
572              previousHash: previousHash,
573              timestamp: timestamp)
574      {
575      }
576  
577      public (bool WasSuccessful, string Reason) GetPayload()
578      {
579          var data = JsonSerializer.Deserialize<InfilEndedPayload>(Payload)!;
580          return (data.WasSuccessful, data.Reason);
581      }
582  
583      /// <summary>
584      /// Rehydrates an InfilEndedEvent from storage.
585      /// </summary>
586      public static InfilEndedEvent Rehydrate(
587          OperatorId operatorId,
588          long sequenceNumber,
589          string payload,
590          string previousHash,
591          DateTimeOffset timestamp)
592      {
593          var data = JsonSerializer.Deserialize<InfilEndedPayload>(payload)!;
594          return new InfilEndedEvent(operatorId, sequenceNumber, data.WasSuccessful, data.Reason, previousHash, timestamp);
595      }
596  
597      private record InfilEndedPayload(bool WasSuccessful, string Reason);
598  }
599  
600  /// <summary>
601  /// Event emitted when a new combat session starts during an ongoing infil.
602  /// Updates the ActiveCombatSessionId while keeping the infil active.
603  /// Used after completing a combat to start the next combat in the same infil.
604  /// </summary>
605  public sealed class CombatSessionStartedEvent : OperatorEvent
606  {
607      public CombatSessionStartedEvent(
608          OperatorId operatorId,
609          long sequenceNumber,
610          Guid combatSessionId,
611          string previousHash,
612          DateTimeOffset? timestamp = null)
613          : base(
614              operatorId,
615              sequenceNumber,
616              eventType: "CombatSessionStarted",
617              payload: JsonSerializer.Serialize(new { CombatSessionId = combatSessionId }),
618              previousHash: previousHash,
619              timestamp: timestamp)
620      {
621      }
622  
623      public Guid GetPayload()
624      {
625          var data = JsonSerializer.Deserialize<CombatSessionStartedPayload>(Payload)!;
626          return data.CombatSessionId;
627      }
628  
629      /// <summary>
630      /// Rehydrates a CombatSessionStartedEvent from storage.
631      /// </summary>
632      public static CombatSessionStartedEvent Rehydrate(
633          OperatorId operatorId,
634          long sequenceNumber,
635          string payload,
636          string previousHash,
637          DateTimeOffset timestamp)
638      {
639          var data = JsonSerializer.Deserialize<CombatSessionStartedPayload>(payload)!;
640          return new CombatSessionStartedEvent(operatorId, sequenceNumber, data.CombatSessionId, previousHash, timestamp);
641      }
642  
643      private record CombatSessionStartedPayload(Guid CombatSessionId);
644  }
645  
646  /// <summary>
647  /// Event recording that a pet action was applied to the operator's virtual pet.
648  /// Stores the complete resulting pet state after applying the action.
649  /// </summary>
650  public sealed class PetActionAppliedEvent : OperatorEvent
651  {
652      private const string TypeName = "PetActionApplied";
653  
654      public PetActionAppliedEvent(
655          OperatorId operatorId,
656          long sequenceNumber,
657          string action,
658          float health,
659          float fatigue,
660          float injury,
661          float stress,
662          float morale,
663          float hunger,
664          float hydration,
665          DateTimeOffset lastUpdated,
666          string previousHash,
667          DateTimeOffset? timestamp = null)
668          : base(
669              operatorId,
670              sequenceNumber,
671              TypeName,
672              payload: JsonSerializer.Serialize(new PetActionPayload(
673                  action, health, fatigue, injury, stress, morale, hunger, hydration, lastUpdated)),
674              previousHash,
675              timestamp)
676      {
677      }
678  
679      /// <summary>
680      /// Gets the action name and resulting pet state from the payload.
681      /// </summary>
682      public (string action, float health, float fatigue, float injury, float stress, float morale, float hunger, float hydration, DateTimeOffset lastUpdated) GetPayload()
683      {
684          var data = JsonSerializer.Deserialize<PetActionPayload>(Payload)!;
685          return (data.Action, data.Health, data.Fatigue, data.Injury, data.Stress, data.Morale, data.Hunger, data.Hydration, data.LastUpdated);
686      }
687  
688      /// <summary>
689      /// Rehydrates a PetActionAppliedEvent from storage.
690      /// </summary>
691      public static PetActionAppliedEvent Rehydrate(
692          OperatorId operatorId,
693          long sequenceNumber,
694          string payload,
695          string previousHash,
696          DateTimeOffset timestamp)
697      {
698          var data = JsonSerializer.Deserialize<PetActionPayload>(payload)!;
699          return new PetActionAppliedEvent(
700              operatorId,
701              sequenceNumber,
702              data.Action,
703              data.Health,
704              data.Fatigue,
705              data.Injury,
706              data.Stress,
707              data.Morale,
708              data.Hunger,
709              data.Hydration,
710              data.LastUpdated,
711              previousHash,
712              timestamp);
713      }
714  
715      private record PetActionPayload(
716          string Action,
717          float Health,
718          float Fatigue,
719          float Injury,
720          float Stress,
721          float Morale,
722          float Hunger,
723          float Hydration,
724          DateTimeOffset LastUpdated);
725  }