V2/Networking/ChatActionSnapshots.cs

Data-model records for chat and action progress snapshots used in networking. Defines ChatDeliveryEpoch to bind delivery to a connection and character, ChatMessageSnapshot and ChatSnapshot to hold immutable chat messages and a delivery revision, and ActionProgressSnapshot to represent a timed action instance.

Networking
#nullable enable

using System;
using System.Collections.Generic;
using System.Linq;
using Hexagon.V2.Domain;

namespace Hexagon.V2.Networking;

/// <summary>
/// Binds a chat delivery to one authenticated connection and active-character
/// lifetime so delayed private/channel messages cannot cross a realm boundary.
/// </summary>
public readonly record struct ChatDeliveryEpoch
{
	public ChatDeliveryEpoch(ConnectionEpoch connection, long character)
	{
		if (connection.Value == Guid.Empty) throw new ArgumentOutOfRangeException(nameof(connection));
		if (character < 0) throw new ArgumentOutOfRangeException(nameof(character));
		Connection = connection;
		Character = character;
	}

	public ConnectionEpoch Connection { get; }
	public long Character { get; }
}

public sealed record ChatMessageSnapshot(
	Guid MessageId,
	string ChannelId,
	CharacterId? AuthorCharacterId,
	string AuthorName,
	string Text,
	DateTimeOffset SentAtUtc
);

public sealed record ChatSnapshot
{
	public ChatSnapshot(ChatDeliveryEpoch epoch, long revision, IEnumerable<ChatMessageSnapshot> messages)
	{
		if (epoch.Connection.Value == Guid.Empty) throw new ArgumentOutOfRangeException(nameof(epoch));
		if (revision < 0) throw new ArgumentOutOfRangeException(nameof(revision));
		var copied = (messages ?? throw new ArgumentNullException(nameof(messages))).ToArray();
		if (copied.Select(message => message.MessageId).Distinct().Count() != copied.Length)
			throw new ArgumentException("Chat message IDs must be unique within a delivery.", nameof(messages));
		Epoch = epoch;
		Revision = revision;
		Messages = Array.AsReadOnly(copied);
	}

	public ChatDeliveryEpoch Epoch { get; }
	public long Revision { get; }
	public IReadOnlyList<ChatMessageSnapshot> Messages { get; }
}

public sealed record ActionProgressSnapshot
{
	public ActionProgressSnapshot(
		Guid instanceId,
		ActionId actionId,
		string label,
		DateTimeOffset startedAtUtc,
		TimeSpan duration,
		bool canCancel)
	{
		if (instanceId == Guid.Empty) throw new ArgumentOutOfRangeException(nameof(instanceId));
		if (duration <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(duration));
		InstanceId = instanceId;
		ActionId = actionId;
		Label = label ?? string.Empty;
		StartedAtUtc = startedAtUtc;
		Duration = duration;
		CanCancel = canCancel;
	}

	public Guid InstanceId { get; }
	public ActionId ActionId { get; }
	public string Label { get; }
	public DateTimeOffset StartedAtUtc { get; }
	public TimeSpan Duration { get; }
	public bool CanCancel { get; }
}