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 }