using System; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text; using MessagePack; using R3; namespace GameCult.Networking { /// /// One witness report about a deterministic simulation fact. /// [MessagePackObject] public sealed class CultNetSimulationObservation { /// /// Gets or sets the observing runtime id. /// [Key("witnessRuntimeId")] public string WitnessRuntimeId { get; set; } = string.Empty; /// /// Gets or sets the shard id containing the observed simulation. /// [Key("shardId")] public string ShardId { get; set; } = string.Empty; /// /// Gets or sets the shard epoch observed by the witness. /// [Key("shardEpoch")] public long ShardEpoch { get; set; } /// /// Gets or sets the simulation frame or tick. /// [Key("frame")] public long Frame { get; set; } /// /// Gets or sets the subject this observation is about. /// [Key("subjectId")] public string SubjectId { get; set; } = string.Empty; /// /// Gets or sets the kind of claim, such as hit, input, collision, or ownership. /// [Key("claimKind")] public string ClaimKind { get; set; } = string.Empty; /// /// Gets or sets the content hash of the witness claim. /// [Key("claimHash")] public string ClaimHash { get; set; } = string.Empty; /// /// Gets or sets optional human-readable or app-specific claim detail. /// [Key("claimSummary")] public string? ClaimSummary { get; set; } /// /// Gets or sets the witness weight. Normal clients should use 1. /// [Key("weight")] public double Weight { get; set; } = 1d; /// /// Gets or sets the observation timestamp. /// [Key("observedAt")] public string ObservedAt { get; set; } = string.Empty; /// /// Computes a stable claim hash from ordered claim parts. /// public static string ComputeClaimHash(params string[] parts) { if (parts == null) throw new ArgumentNullException(nameof(parts)); var canonical = string.Join("\u001F", parts.Select(part => part ?? string.Empty)); using var sha = SHA256.Create(); var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(canonical)); return string.Concat(bytes.Select(value => value.ToString("x2"))); } } /// /// Controls deterministic witness aggregation. /// public sealed class CultNetSimulationConsensusOptions { /// /// Gets or sets the minimum distinct witnesses required before a claim can be accepted. /// public int MinimumWitnesses { get; set; } = 1; /// /// Gets or sets the minimum weight required before a claim can be accepted. /// public double MinimumWeight { get; set; } = 1d; /// /// Gets or sets the fraction of observed weight required for quorum. /// public double QuorumRatio { get; set; } = 0.5d; } /// /// A deterministic consensus candidate derived from witness observations. /// public sealed class CultNetSimulationConsensusCandidate { /// /// Creates a consensus candidate. /// public CultNetSimulationConsensusCandidate( string shardId, long shardEpoch, long frame, string subjectId, string claimKind, string claimHash, string? claimSummary, int witnessCount, double supportWeight, double totalWeight, bool hasQuorum) { ShardId = shardId; ShardEpoch = shardEpoch; Frame = frame; SubjectId = subjectId; ClaimKind = claimKind; ClaimHash = claimHash; ClaimSummary = claimSummary; WitnessCount = witnessCount; SupportWeight = supportWeight; TotalWeight = totalWeight; HasQuorum = hasQuorum; } /// /// Gets the shard id. /// public string ShardId { get; } /// /// Gets the shard epoch. /// public long ShardEpoch { get; } /// /// Gets the simulation frame. /// public long Frame { get; } /// /// Gets the subject id. /// public string SubjectId { get; } /// /// Gets the claim kind. /// public string ClaimKind { get; } /// /// Gets the claim hash. /// public string ClaimHash { get; } /// /// Gets optional claim detail. /// public string? ClaimSummary { get; } /// /// Gets the distinct witness count supporting this candidate. /// public int WitnessCount { get; } /// /// Gets supporting witness weight. /// public double SupportWeight { get; } /// /// Gets total observed witness weight for the same subject/frame/kind. /// public double TotalWeight { get; } /// /// Gets whether the candidate crossed the configured quorum. /// public bool HasQuorum { get; } /// /// Gets support divided by total observed weight. /// public double Confidence => TotalWeight <= 0d ? 0d : SupportWeight / TotalWeight; } /// /// Aggregates simulation observations into deterministic consensus candidates. /// public sealed class CultNetSimulationConsensus { private readonly CultNetSimulationConsensusOptions _options; /// /// Creates a consensus aggregator. /// public CultNetSimulationConsensus(CultNetSimulationConsensusOptions? options = null) { _options = options ?? new CultNetSimulationConsensusOptions(); } /// /// Returns the strongest candidate for each observed shard/frame/subject/claim kind. /// public IReadOnlyList BuildCandidates( IEnumerable observations) { if (observations == null) throw new ArgumentNullException(nameof(observations)); var clean = observations .Where(IsValid) .GroupBy(DeduplicationKey) .Select(group => group .OrderByDescending(observation => observation.Weight) .ThenBy(observation => observation.WitnessRuntimeId, StringComparer.Ordinal) .First()) .ToArray(); return clean .GroupBy(SubjectKey) .Select(BuildCandidate) .OrderBy(candidate => candidate.ShardId, StringComparer.Ordinal) .ThenBy(candidate => candidate.ShardEpoch) .ThenBy(candidate => candidate.Frame) .ThenBy(candidate => candidate.SubjectId, StringComparer.Ordinal) .ThenBy(candidate => candidate.ClaimKind, StringComparer.Ordinal) .ToArray(); } private CultNetSimulationConsensusCandidate BuildCandidate( IGrouping subjectGroup) { var totalWeight = subjectGroup.Sum(WeightOf); var bestClaim = subjectGroup .GroupBy(observation => observation.ClaimHash, StringComparer.Ordinal) .Select(group => new { ClaimHash = group.Key, Observations = group.ToArray(), Weight = group.Sum(WeightOf) }) .OrderByDescending(candidate => candidate.Weight) .ThenBy(candidate => candidate.ClaimHash, StringComparer.Ordinal) .First(); var first = bestClaim.Observations .OrderBy(observation => observation.WitnessRuntimeId, StringComparer.Ordinal) .First(); var witnessCount = bestClaim.Observations .Select(observation => observation.WitnessRuntimeId) .Distinct(StringComparer.Ordinal) .Count(); var hasQuorum = witnessCount >= _options.MinimumWitnesses && bestClaim.Weight >= _options.MinimumWeight && totalWeight > 0d && bestClaim.Weight / totalWeight >= _options.QuorumRatio; return new CultNetSimulationConsensusCandidate( first.ShardId, first.ShardEpoch, first.Frame, first.SubjectId, first.ClaimKind, bestClaim.ClaimHash, first.ClaimSummary, witnessCount, bestClaim.Weight, totalWeight, hasQuorum); } private static bool IsValid(CultNetSimulationObservation observation) { return observation != null && !string.IsNullOrWhiteSpace(observation.WitnessRuntimeId) && !string.IsNullOrWhiteSpace(observation.ShardId) && !string.IsNullOrWhiteSpace(observation.SubjectId) && !string.IsNullOrWhiteSpace(observation.ClaimKind) && !string.IsNullOrWhiteSpace(observation.ClaimHash) && observation.Weight > 0d; } private static double WeightOf(CultNetSimulationObservation observation) { return observation.Weight <= 0d ? 0d : observation.Weight; } private static string DeduplicationKey(CultNetSimulationObservation observation) { return string.Join("|", observation.WitnessRuntimeId, observation.ShardId, observation.ShardEpoch, observation.Frame, observation.SubjectId, observation.ClaimKind); } private static string SubjectKey(CultNetSimulationObservation observation) { return string.Join("|", observation.ShardId, observation.ShardEpoch, observation.Frame, observation.SubjectId, observation.ClaimKind); } } /// /// Collects witness observations and publishes updated consensus candidates. /// public sealed class CultNetSimulationObservationHub : IDisposable { private readonly List _observations = new(); private readonly CultNetSimulationConsensus _consensus; private readonly Subject _observationStream = new(); private readonly Subject _candidateStream = new(); private bool _disposed; /// /// Creates an observation hub. /// public CultNetSimulationObservationHub(CultNetSimulationConsensusOptions? options = null) { _consensus = new CultNetSimulationConsensus(options); } /// /// Gets all observations currently held by the hub. /// public IReadOnlyList Observations => _observations; /// /// Watches accepted witness observations. /// public Observable WatchObservations() { ThrowIfDisposed(); return _observationStream; } /// /// Watches consensus candidate updates. /// public Observable WatchCandidates() { ThrowIfDisposed(); return _candidateStream; } /// /// Adds an observation and returns current candidates for the same observed subject. /// public IReadOnlyList Submit(CultNetSimulationObservation observation) { ThrowIfDisposed(); if (observation == null) throw new ArgumentNullException(nameof(observation)); _observations.Add(observation); _observationStream.OnNext(observation); var candidates = _consensus.BuildCandidates(_observations) .Where(candidate => MatchesSubject(candidate, observation)) .ToArray(); foreach (var candidate in candidates) { _candidateStream.OnNext(candidate); } return candidates; } /// /// Adds an observation message and returns current candidates for the same observed subject. /// public IReadOnlyList Submit(CultNetSimulationObservationMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); return Submit(message.Observation); } /// public void Dispose() { if (_disposed) { return; } _disposed = true; _observationStream.Dispose(); _candidateStream.Dispose(); } private static bool MatchesSubject( CultNetSimulationConsensusCandidate candidate, CultNetSimulationObservation observation) { return string.Equals(candidate.ShardId, observation.ShardId, StringComparison.Ordinal) && candidate.ShardEpoch == observation.ShardEpoch && candidate.Frame == observation.Frame && string.Equals(candidate.SubjectId, observation.SubjectId, StringComparison.Ordinal) && string.Equals(candidate.ClaimKind, observation.ClaimKind, StringComparison.Ordinal); } private void ThrowIfDisposed() { if (_disposed) { throw new ObjectDisposedException(nameof(CultNetSimulationObservationHub)); } } } }