Skip to content

Add GameProcessor event subsystem#40

Open
Qonfused wants to merge 9 commits intomainfrom
feat-gametracker
Open

Add GameProcessor event subsystem#40
Qonfused wants to merge 9 commits intomainfrom
feat-gametracker

Conversation

@Qonfused
Copy link
Copy Markdown
Member

Adds an event-driven subsystem that tracks game state changes (card mutations, zone transfers, prompt correlation, log messages, combat) and surfaces them as strongly-typed events. This required
refactoring game models with new enums (CardAbility, MagicProperty, StateElementType), expanded GameCard/GamePlayer properties, and improved CardAction target tracking.

To support this, we also switched the internal batch IPC responses (/batch_members, /batch_collection) from row-oriented dictionaries to a columnar format, thus eliminating ~2.4x key repetition on the wire. Additionally, we added batch hydration for ToJSON() serialization, reducing N individual IPC calls to 1 batch call.

Below show two types of ways in which you can register to this new subsystem: either explicitly (constructing or registering your own processors to GameProcessor.cs), or implicitly via Game.cs events.

Explicit processor registration (GameProcessor.cs):

using System;
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;

using MTGOSDK.API.Play.Games;
using MTGOSDK.API.Play.Games.Processors;
using MTGOSDK.API.Play.Games.Processors.EventArgs;
using MTGOSDK.Core.Logging;
using MTGOSDK.Core.Reflection.Proxy;
using MTGOSDK.Core.Reflection.Serialization;

internal static class Program
{
  private static readonly ConcurrentDictionary<int, GameProcessor> Games = new();

  private static void Main()
  {
    ILoggerFactory factory = LoggerFactory.Create(builder =>
    {
      builder.AddConsole();
      builder.SetMinimumLevel(LogLevel.Debug);
    });
    LoggerBase.SetFactoryInstance(factory);

    var hook = new EventHookProxy<int, (uint, DateTime)>(
      "WotC.MtGO.Client.Model.Play.InProgressGameEvent.Game",
      "HandleGamePlayStatus",
      new((instance, args) =>
      {
        dynamic message = args[0];
        int gameId = (int)(uint)message.GameID;
        var ts = ((uint)message.GameStateTimestamp, (DateTime)instance.__timestamp);

        // Atomically create and initialize the processor only once
        var processor = Games.GetOrAdd(gameId, _ =>
        {
          var p = new GameProcessor(new Game(instance));

          // Register all processors (which calls Initialize on each)
          p.Register(new ZoneChangeTracker());
          p.Register(new ActionProcessor());
          p.Register(new CombatProcessor());
          p.Register(new PropertyChangeTracker());
          p.Register(new PromptProcessor());
          p.Register(new LogMessageProcessor());

          // Subscribe to centralized events on the GameProcessor bus
          #region GameAction Callbacks
          p.On<ActionFinalizedEventArgs>(e =>
          {
            var action = e.Args.Action;
            Log.Information("[GameAction] {ActionType}: {ActionName} (ts={Timestamp}, nonce={Nonce})",
              action.GetType().Name,
              action.Name ?? "?",
              action.Timestamp,
              e.Args.Nonce);

            Log.Information("[GameAction] JSON: {Json}", action.ToJSON());
          });
          #endregion

          #region StateChange Callbacks
          p.On<CardChangedEventArgs>(e =>
          {
            var diff = GenerateGameCardDiff(e.Args.Previous, e.Args.Current);
            if (string.IsNullOrEmpty(diff)) return;

            Log.Information("[CardChange] {CardName} (ID:{CardId}) diff:",
              e.Args.Current.Name,
              e.Args.Current.Id);

            Log.Information("[CardChange] Diff:\n{Diff}", diff);
          });

          p.On<PlayerChangedEventArgs>(e =>
          {
            var diff = GeneratePlayerDiff(e.Args.Previous, e.Args.Current);
            if (string.IsNullOrEmpty(diff)) return;

            Log.Information("[PlayerChange] {PlayerName} (Index:{PlayerIndex}) diff:",
              e.Args.Current.Name,
              e.Args.PlayerIndex);

            Log.Information("[PlayerChange] Diff:\n{Diff}", diff);
          });
          #endregion

          #region ZoneChange Callbacks
          p.On<ZoneChangeEventArgs>(e =>
          {
            Log.Information("+{Arrived} -{Departed} ~{Moved}",
              e.Args.Arrived.Count,
              e.Args.Departed.Count,
              e.Args.Moved.Count);

            foreach (var (from, to) in e.Args.Moved)
            {
              Log.Information("[~] {CardName} (ID:{CardId}) {FromZone} -> {ToZone}",
                to.Name,
                to.Id,
                from.Zone?.Name ?? "Unknown",
                to.Zone?.Name ?? "Unknown");
            }

            foreach (var log in e.Args.ChainLogs)
            {
              Log.Information("{Entry}", log);
            }

            foreach (var d in e.Args.Departed)
            {
              Log.Information("[-] {CardName} (ID:{CardId}) left {FromZone}",
                d.Name,
                d.Id,
                d.Zone?.Name ?? "Unknown");
            }

            foreach (var a in e.Args.Arrived)
            {
              string fromZone = a.PreviousZone?.Name ?? "Unknown";
              string symbol = fromZone == "Library" || fromZone == "Sideboard" ? "[+]" : "[=>]";
              string log = fromZone != "Unknown"
                ? $"  {symbol} {a.Name} (ID:{a.Id}) {fromZone} -> {a.Zone?.Name ?? "Unknown"}"
                : $"  [+] {a.Name} (ID:{a.Id}) entered {a.Zone?.Name ?? "Unknown"}";

              if (a.SourceId != -1 && a.SourceId != a.Id)
                log += $" (src:{a.SourceId})";

              Log.Information("{Entry}", log);
            }

            foreach (var log in e.Args.UnresolvedOrigins)
            {
              Log.Information("{Entry}", log);
            }
          });
          #endregion

          #region Prompt Correlation Callbacks
          p.On<PromptChangedEventArgs>(e =>
          {
            Log.Information("[Prompt] Snapshot InteractionTs={InteractionTs} Prompt ts={PromptTs} Snapshot nonce={SnapshotNonce} Prompt nonce={PromptNonce}",
              e.Args.Snapshot.InteractionTimestamp,
              e.Args.Prompt.Timestamp,
              e.Args.Snapshot.Nonce,
              e.Args.Prompt.Nonce);

            Log.Information("[Prompt] Text: \"{Text}\" Player={Player}",
              e.Args.Prompt.Text,
              e.Args.Prompt.PromptedPlayer);
          });
          #endregion

          #region Log Message Correlation Callbacks
          p.On<LogMessageCorrelatedEventArgs>(e =>
          {
            Log.Information("[LogMsg] Delta={Delta:F3}s MsgServer={MsgServer} MsgClient={MsgClient} SnapServer={SnapServer} SnapClient={SnapClient}",
              e.Args.TimeDeltaSeconds,
              e.Args.MessageServerTimestamp,
              e.Args.MessageClientTimestamp,
              e.Args.SnapshotServerTimestamp,
              e.Args.SnapshotClientTimestamp);

            Log.Information("[LogMsg] Text: \"{Text}\"",
              e.Args.Message.Text);
          });
          #endregion

          Log.Information("All processors registered successfully");

          // Mark as initialized
          p.IsInitialized = true;

          return p;
        });

        processor.EnqueuePendingHook(instance, message, ts);

        return (gameId, ts);
      })
    );
    hook.EnsureInitialize();

    Console.WriteLine("Press Enter to exit...");
    Log.Information("BasicBot is running.");
    Console.ReadLine();
  }

  /// <summary>
  /// Generates a diff by comparing the JSON serialization of two GameCard objects.
  /// Shows only the lines that differ in a unified diff format.
  /// </summary>
  private static string GenerateGameCardDiff(
    GameCard oldCard,
    GameCard newCard)
  {
    var diff = new System.Text.StringBuilder();

    // Compare standard JSON properties
    string oldJson = oldCard.ToJSON();
    string newJson = newCard.ToJSON();

    var oldLines = oldJson.Split('\n');
    var newLines = newJson.Split('\n');

    // Find lines that differ
    int maxLines = Math.Max(oldLines.Length, newLines.Length);
    bool hasChanges = false;

    for (int i = 0; i < maxLines; i++)
    {
      string oldLine = i < oldLines.Length ? oldLines[i] : "";
      string newLine = i < newLines.Length ? newLines[i] : "";

      if (oldLine != newLine)
      {
        hasChanges = true;
        if (!string.IsNullOrEmpty(oldLine))
          diff.AppendLine($"- {oldLine.Trim()}");
        if (!string.IsNullOrEmpty(newLine))
          diff.AppendLine($"+ {newLine.Trim()}");
      }
    }

    return hasChanges ? diff.ToString() : string.Empty;
  }

  /// <summary>
  /// Generates a diff by comparing two GamePlayer objects.
  /// </summary>
  private static string GeneratePlayerDiff(
    GamePlayer oldPlayer,
    GamePlayer newPlayer)
  {
    var diff = new System.Text.StringBuilder();

    if (oldPlayer.Life != newPlayer.Life)
    {
      diff.AppendLine($"- life: {oldPlayer.Life}");
      diff.AppendLine($"+ life: {newPlayer.Life}");
    }
    if (oldPlayer.HandCount != newPlayer.HandCount)
    {
      diff.AppendLine($"- handCount: {oldPlayer.HandCount}");
      diff.AppendLine($"+ handCount: {newPlayer.HandCount}");
    }
    if (oldPlayer.LibraryCount != newPlayer.LibraryCount)
    {
      diff.AppendLine($"- libraryCount: {oldPlayer.LibraryCount}");
      diff.AppendLine($"+ libraryCount: {newPlayer.LibraryCount}");
    }
    if (oldPlayer.GraveyardCount != newPlayer.GraveyardCount)
    {
      diff.AppendLine($"- graveyardCount: {oldPlayer.GraveyardCount}");
      diff.AppendLine($"+ graveyardCount: {newPlayer.GraveyardCount}");
    }
    if (oldPlayer.IsActivePlayer != newPlayer.IsActivePlayer)
    {
      diff.AppendLine($"- isActivePlayer: {oldPlayer.IsActivePlayer}");
      diff.AppendLine($"+ isActivePlayer: {newPlayer.IsActivePlayer}");
    }
    if (oldPlayer.ChessClock != newPlayer.ChessClock)
    {
      diff.AppendLine($"- chessClock: {oldPlayer.ChessClock}");
      diff.AppendLine($"+ chessClock: {newPlayer.ChessClock}");
    }

    return diff.ToString();
  }
}

Implicit processor registration (Game.cs):

using System;
using Microsoft.Extensions.Logging;

using MTGOSDK.API.Play.Games;
using MTGOSDK.API.Play.Games.Processors;
using MTGOSDK.Core.Logging;
using MTGOSDK.Core.Reflection.Serialization;

internal static class Program
{
  private static void Main()
  {
    ILoggerFactory factory = LoggerFactory.Create(builder =>
    {
      builder.AddConsole();
      builder.SetMinimumLevel(LogLevel.Debug);
    });
    LoggerBase.SetFactoryInstance(factory);

    GameProcessor.OnNewGame += (Game game) =>
    {
      Log.Information("Game {GameId} detected, subscribing to events.", game.Id);

      #region GameAction Callbacks
      game.OnActionFinalized += e =>
      {
        var action = e.Action;
        Log.Information("[GameAction] {ActionType}: {ActionName} (ts={Timestamp}, nonce={Nonce})",
          action.GetType().Name,
          action.Name ?? "?",
          action.Timestamp,
          e.Nonce);

        Log.Information("[GameAction] JSON: {Json}", action.ToJSON());
      };
      #endregion

      #region StateChange Callbacks
      game.OnCardChanged += e =>
      {
        var diff = GenerateGameCardDiff(e.Previous, e.Current);
        if (string.IsNullOrEmpty(diff)) return;

        Log.Information("[CardChange] {CardName} (ID:{CardId}) diff:",
          e.Current.Name,
          e.Current.Id);

        Log.Information("[CardChange] Diff:\n{Diff}", diff);
      };

      game.OnPlayerChanged += e =>
      {
        var diff = GeneratePlayerDiff(e.Previous, e.Current);
        if (string.IsNullOrEmpty(diff)) return;

        Log.Information("[PlayerChange] {PlayerName} (Index:{PlayerIndex}) diff:",
          e.Current.Name,
          e.PlayerIndex);

        Log.Information("[PlayerChange] Diff:\n{Diff}", diff);
      };
      #endregion

      #region ZoneChange Callbacks
      game.OnZoneChanged += e =>
      {
        Log.Information("+{Arrived} -{Departed} ~{Moved}",
          e.Arrived.Count,
          e.Departed.Count,
          e.Moved.Count);

        foreach (var (from, to) in e.Moved)
        {
          Log.Information("[~] {CardName} (ID:{CardId}) {FromZone} -> {ToZone}",
            to.Name,
            to.Id,
            from.Zone?.Name ?? "Unknown",
            to.Zone?.Name ?? "Unknown");
        }

        foreach (var log in e.ChainLogs)
        {
          Log.Information("{Entry}", log);
        }

        foreach (var d in e.Departed)
        {
          Log.Information("[-] {CardName} (ID:{CardId}) left {FromZone}",
            d.Name,
            d.Id,
            d.Zone?.Name ?? "Unknown");
        }

        foreach (var a in e.Arrived)
        {
          string fromZone = a.PreviousZone?.Name ?? "Unknown";
          string symbol = fromZone == "Library" || fromZone == "Sideboard" ? "[+]" : "[=>]";
          string log = fromZone != "Unknown"
            ? $"  {symbol} {a.Name} (ID:{a.Id}) {fromZone} -> {a.Zone?.Name ?? "Unknown"}"
            : $"  [+] {a.Name} (ID:{a.Id}) entered {a.Zone?.Name ?? "Unknown"}";

          if (a.SourceId != -1 && a.SourceId != a.Id)
            log += $" (src:{a.SourceId})";

          Log.Information("{Entry}", log);
        }

        foreach (var log in e.UnresolvedOrigins)
        {
          Log.Information("{Entry}", log);
        }
      };
      #endregion

      #region Prompt Correlation Callbacks
      game.OnPromptChanged += e =>
      {
        Log.Information("[Prompt] Snapshot InteractionTs={InteractionTs} Prompt ts={PromptTs} Snapshot nonce={SnapshotNonce} Prompt nonce={PromptNonce}",
          e.Snapshot.InteractionTimestamp,
          e.Prompt.Timestamp,
          e.Snapshot.Nonce,
          e.Prompt.Nonce);

        Log.Information("[Prompt] Text: \"{Text}\" Player={Player}",
          e.Prompt.Text,
          e.Prompt.PromptedPlayer);
      };
      #endregion

      #region Log Message Correlation Callbacks
      game.OnLogMessage += e =>
      {
        Log.Information("[LogMsg] Delta={Delta:F3}s MsgServer={MsgServer} MsgClient={MsgClient} SnapServer={SnapServer} SnapClient={SnapClient}",
          e.TimeDeltaSeconds,
          e.MessageServerTimestamp,
          e.MessageClientTimestamp,
          e.SnapshotServerTimestamp,
          e.SnapshotClientTimestamp);

        Log.Information("[LogMsg] Text: \"{Text}\"",
          e.Message.Text);
      };
      #endregion
    };

    GameProcessor.EnsureHookInitialized();

    Console.WriteLine("Press Enter to exit...");
    Log.Information("BasicBot is running.");
    Console.ReadLine();
  }

  /// <summary>
  /// Generates a diff by comparing the JSON serialization of two GameCard objects.
  /// Shows only the lines that differ in a unified diff format.
  /// </summary>
  private static string GenerateGameCardDiff(
    GameCard oldCard,
    GameCard newCard)
  {
    var diff = new System.Text.StringBuilder();

    // Compare standard JSON properties
    string oldJson = oldCard.ToJSON();
    string newJson = newCard.ToJSON();

    var oldLines = oldJson.Split('\n');
    var newLines = newJson.Split('\n');

    // Find lines that differ
    int maxLines = Math.Max(oldLines.Length, newLines.Length);
    bool hasChanges = false;

    for (int i = 0; i < maxLines; i++)
    {
      string oldLine = i < oldLines.Length ? oldLines[i] : "";
      string newLine = i < newLines.Length ? newLines[i] : "";

      if (oldLine != newLine)
      {
        hasChanges = true;
        if (!string.IsNullOrEmpty(oldLine))
          diff.AppendLine($"- {oldLine.Trim()}");
        if (!string.IsNullOrEmpty(newLine))
          diff.AppendLine($"+ {newLine.Trim()}");
      }
    }

    return hasChanges ? diff.ToString() : string.Empty;
  }

  /// <summary>
  /// Generates a diff by comparing two GamePlayer objects.
  /// </summary>
  private static string GeneratePlayerDiff(
    GamePlayer oldPlayer,
    GamePlayer newPlayer)
  {
    var diff = new System.Text.StringBuilder();

    if (oldPlayer.Life != newPlayer.Life)
    {
      diff.AppendLine($"- life: {oldPlayer.Life}");
      diff.AppendLine($"+ life: {newPlayer.Life}");
    }
    if (oldPlayer.HandCount != newPlayer.HandCount)
    {
      diff.AppendLine($"- handCount: {oldPlayer.HandCount}");
      diff.AppendLine($"+ handCount: {newPlayer.HandCount}");
    }
    if (oldPlayer.LibraryCount != newPlayer.LibraryCount)
    {
      diff.AppendLine($"- libraryCount: {oldPlayer.LibraryCount}");
      diff.AppendLine($"+ libraryCount: {newPlayer.LibraryCount}");
    }
    if (oldPlayer.GraveyardCount != newPlayer.GraveyardCount)
    {
      diff.AppendLine($"- graveyardCount: {oldPlayer.GraveyardCount}");
      diff.AppendLine($"+ graveyardCount: {newPlayer.GraveyardCount}");
    }
    if (oldPlayer.IsActivePlayer != newPlayer.IsActivePlayer)
    {
      diff.AppendLine($"- isActivePlayer: {oldPlayer.IsActivePlayer}");
      diff.AppendLine($"+ isActivePlayer: {newPlayer.IsActivePlayer}");
    }
    if (oldPlayer.ChessClock != newPlayer.ChessClock)
    {
      diff.AppendLine($"- chessClock: {oldPlayer.ChessClock}");
      diff.AppendLine($"+ chessClock: {newPlayer.ChessClock}");
    }

    return diff.ToString();
  }
}

Removes redundant type/field metadata resolution from object endpoint handlers.
Extends DynamicRemoteObject and CollectionHelpers with additional collection
operations and remote LINQ support. Adds trace diagnostics to RemoteClient
and fixes PrimitivesEncoder edge cases.
Extends EventHookProxy with disposal guards and improves TypeProxy
interface binding.
Replaces row-oriented Dictionary<string, string> batch responses with a
columnar layout where property names and types appear once in a schema,
and values are stored as parallel arrays. Eliminates O(N*P) key repetition
on the wire for large collections.

Also adds HydrateForSerialization to batch-fetch properties before ToJSON()
serialization, reducing N individual IPC calls to 1 batch call.
Adds CardAbility, MagicProperty, PlayDrawResult, and StateElementType enums.
Moves PlayDrawResult from Types/ to Enums/ and removes the unused NamedValue
type.
Simplifies Game, GameAction, GameCard, GamePlayer, GamePrompt, and GameZone
wrappers. Expands GameCard with ability, counter, and association tracking.
Updates CardAction and SelectFromListAction for new action types.
Introduces a state-diffing processor pipeline for tracking game state changes.
Provides structured event callbacks for action finalization, card/player/zone
changes, prompt correlation, and log message timing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant