diff --git a/coverage.unit.runsettings b/coverage.unit.runsettings index 64b0fab..bbc9cff 100644 --- a/coverage.unit.runsettings +++ b/coverage.unit.runsettings @@ -12,7 +12,6 @@ [SimPle.Infrastructure]SimPle.Infrastructure.DependencyInjection, [SimPle.Application]SimPle.Application.DependencyInjection, [SimPle.Domain]SimPle.Domain.Chat.*, - [SimPle.Domain]SimPle.Domain.GameHost.*, [SimPle.Domain]SimPle.Domain.Games.*, [SimPle.Domain]SimPle.Domain.Hardware.*, [SimPle.Domain]SimPle.Domain.Lobbies.*, diff --git a/src/SimPle.Api/Program.cs b/src/SimPle.Api/Program.cs index eb2aaf1..08e402d 100644 --- a/src/SimPle.Api/Program.cs +++ b/src/SimPle.Api/Program.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Microsoft.IdentityModel.Tokens; @@ -18,6 +19,8 @@ using SimPle.Application.Auth.Validators; using SimPle.Application.Common.Interfaces; using SimPle.Application.Common.Options; +using SimPle.Application.GameHost.Services; +using SimPle.Domain.GameHost; using SimPle.Infrastructure; using SimPle.Infrastructure.Auth; using SimPle.Infrastructure.Games; @@ -71,6 +74,12 @@ builder.Services.AddApplicationServices(); builder.Services.AddInfrastructureServices(builder.Configuration); +// The composition root's list of installed Phase 2 game engines. Empty today — Module 5 hosts no product +// game yet, only the test-only HiddenTokenDraft reference engine, which is never registered here. A duplicate +// (Slug, EngineVersion) across two real entries throws from Create() and fails application startup, never a +// call-time ambiguity. +builder.Services.AddSingleton(_ => GameRegistry.Create(Array.Empty())); + builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(JwtSettings.SectionName)) .Validate(settings => @@ -316,6 +325,40 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse( var app = builder.Build(); +// Fail-fast: every installed game engine must agree with its Module 4 catalog row on player bounds and +// modes, or a lobby could advertise a match shape the engine will reject at runtime. Skipped entirely (no DB +// round trip) while zero engines are installed, which is the current state and also keeps WebApplicationFactory +// integration tests that don't touch GameHost from needing a live database just to boot the app. +using (var startupScope = app.Services.CreateScope()) +{ + var gameRegistry = startupScope.ServiceProvider.GetRequiredService(); + if (gameRegistry.RegisteredDefinitions.Count > 0) + { + var startupDb = startupScope.ServiceProvider.GetRequiredService(); + var games = startupDb.Games.AsNoTracking().ToList(); + var gameIds = games.Select(g => g.Id).ToList(); + var modesByGameId = startupDb.GameModeCapabilities.AsNoTracking() + .Where(c => gameIds.Contains(c.GameId)) + .ToList() + .GroupBy(c => c.GameId) + .ToDictionary(g => g.Key, g => (IEnumerable)g.Select(c => c.Mode).ToList()); + + var catalogSnapshots = games.Select(g => CatalogGameSnapshot.Create( + g.Slug, + g.MinPlayers, + g.MaxPlayers, + modesByGameId.TryGetValue(g.Id, out var modes) ? modes : Enumerable.Empty())); + + var catalogValidator = startupScope.ServiceProvider.GetRequiredService(); + var violations = catalogValidator.Validate(gameRegistry.RegisteredDefinitions, catalogSnapshots); + if (violations.Count > 0) + { + throw new InvalidOperationException( + "Game engine / catalog compatibility check failed at startup: " + string.Join("; ", violations)); + } + } +} + // Must be first — sets RemoteIpAddress from X-Forwarded-For before any other middleware reads it. app.UseForwardedHeaders(); app.UseMiddleware(); diff --git a/src/SimPle.Application/DependencyInjection.cs b/src/SimPle.Application/DependencyInjection.cs index 4545f94..01a6aec 100644 --- a/src/SimPle.Application/DependencyInjection.cs +++ b/src/SimPle.Application/DependencyInjection.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using SimPle.Application.Auth.Services; using SimPle.Application.Friends.Services; +using SimPle.Application.GameHost.Services; using SimPle.Application.Games.Services; using SimPle.Application.People.Services; using SimPle.Application.Profiles.Services; @@ -17,6 +18,12 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); + // IGameRegistry is registered separately by the composition root: building it requires the list of + // installed IHostedGameDefinition instances, which is composition-root knowledge (currently empty — + // no Phase 2 game is hosted yet), not something this generic module wiring can supply. + services.AddScoped(); + services.AddScoped(); + return services; } } diff --git a/src/SimPle.Application/GameHost/Serialization/GameHostJsonContext.cs b/src/SimPle.Application/GameHost/Serialization/GameHostJsonContext.cs new file mode 100644 index 0000000..6628aa7 --- /dev/null +++ b/src/SimPle.Application/GameHost/Serialization/GameHostJsonContext.cs @@ -0,0 +1,112 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using SimPle.Domain.GameHost; + +namespace SimPle.Application.GameHost.Serialization; + +/// +/// Thrown when a payload fails the codec's fail-closed checks. Never carries the offending bytes or the raw +/// exception text — only a stable the caller maps to a client-safe rejection. +/// +public sealed class GameHostSerializationException(EngineErrorCode code, string message) : Exception(message) +{ + public EngineErrorCode Code { get; } = code; +} + +/// +/// The one pinned instance every state/command/view/event payload in the +/// game-host tree is serialized and deserialized through. "Pinned" is the point: naming, number, and enum +/// handling are fixed here so a resolver misconfiguration cannot silently change envelope bytes and invalidate +/// a stored golden vector (risk #3 in the spec). +/// +/// No CLR type-name polymorphism is ever used. Every call site deserializes into a concrete, +/// caller-selected .NET type (Deserialize<T> with never used as +/// T). A payload's own TCommand union, if a game declares one, is resolved only through +/// compile-time string discriminators +/// the game author writes on their own sealed hierarchy — never through a CLR-qualified $type or a +/// reflection-based arbitrary activation. Combined with +/// set to , a payload that +/// smuggles a CLR-qualified $type/$id gadget at a non-polymorphic type is rejected as an unmapped +/// member rather than ever reaching a deserializer that would honor it. +/// +/// +/// AllowOutOfOrderMetadataProperties is not set anywhere in this codec because the property does +/// not exist on the .NET 8 surface the project targets — it was added in +/// .NET 9. .NET 8's polymorphic deserializer already requires the type-discriminator property to appear first +/// in a polymorphic object and throws on an out-of-order discriminator, which is exactly the "disabled" +/// (strict, in-order-only) behavior the spec mandates. There is deliberately no new package dependency added +/// solely to re-express a value that is already the platform default, matching the minimalism the D3 benchmark +/// deviation already established for this module. The serializer-hardening test suite exercises this with an +/// out-of-order-discriminator payload asserting the expected failure. +/// +/// +public static class GameHostJsonContext +{ + /// + /// The pinned options. Reflection-based (not source-generated) because game definitions are trusted, + /// compiled-in code registering their own polymorphic hierarchies with ordinary attributes; the safety + /// property comes from the fixed options below plus never deserializing into object, not from the + /// resolver strategy. + /// + public static readonly JsonSerializerOptions Options = CreateOptions(); + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.Strict, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow, + WriteIndented = false, + // Reject the exact escape-widening surface that lets a naive template smuggle control characters; + // game payloads are opaque data, never HTML/JS, so the strictest built-in encoder is correct here. + Encoder = JavaScriptEncoder.Default, + ReadCommentHandling = JsonCommentHandling.Disallow, + AllowTrailingCommas = false, + }; + options.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } + + /// + /// Serializes under the pinned options. Never throws for a well-formed in-process + /// value; a serialization failure here is a definition bug, not untrusted input, so it is allowed to + /// propagate as an ordinary exception for to map to + /// . + /// + public static byte[] Serialize(T value) => JsonSerializer.SerializeToUtf8Bytes(value, Options); + + /// + /// Deserializes untrusted bytes into the caller-selected concrete type . Every + /// failure mode — malformed JSON, an unknown/renamed/cross-definition discriminator, an unmapped member, a + /// trailing second value — surfaces as with a stable + /// rather than an unmapped , so a caller never + /// needs to catch directly and risk missing a new failure shape. + /// + public static T Deserialize(ReadOnlySpan utf8Json, EngineErrorCode onFailure) + { + try + { + var result = JsonSerializer.Deserialize(utf8Json, Options); + if (result is null) + { + throw new GameHostSerializationException(onFailure, "Deserialized value was null."); + } + + return result; + } + catch (JsonException) + { + throw new GameHostSerializationException(onFailure, "Payload failed fail-closed deserialization."); + } + catch (NotSupportedException) + { + // The polymorphic resolver throws NotSupportedException (not JsonException) for an undeclared + // derived type under UnknownDerivedTypeHandling.FailSerialization — both are "the payload's + // claimed shape is not one this type accepts" and must map to the same typed rejection. + throw new GameHostSerializationException(onFailure, "Payload declared an unrecognized derived type."); + } + } +} diff --git a/src/SimPle.Application/GameHost/Services/CatalogEngineCompatibilityValidator.cs b/src/SimPle.Application/GameHost/Services/CatalogEngineCompatibilityValidator.cs new file mode 100644 index 0000000..68eb9ec --- /dev/null +++ b/src/SimPle.Application/GameHost/Services/CatalogEngineCompatibilityValidator.cs @@ -0,0 +1,42 @@ +using SimPle.Domain.GameHost; + +namespace SimPle.Application.GameHost.Services; + +/// The one implementation. +public sealed class CatalogEngineCompatibilityValidator : ICatalogEngineCompatibilityValidator +{ + public IReadOnlyList Validate( + IEnumerable registeredDefinitions, + IEnumerable catalogSnapshots) + { + ArgumentNullException.ThrowIfNull(registeredDefinitions); + ArgumentNullException.ThrowIfNull(catalogSnapshots); + + var catalogBySlug = catalogSnapshots.ToDictionary(snapshot => snapshot.Slug, StringComparer.Ordinal); + var violations = new List(); + + foreach (var definition in registeredDefinitions) + { + if (!catalogBySlug.TryGetValue(definition.Slug, out var catalog)) + continue; + + if (definition.MinPlayers > catalog.MinPlayers || definition.MaxPlayers < catalog.MaxPlayers) + { + violations.Add(CatalogCompatibilityViolation.Create( + definition.Slug, + $"Engine supports {definition.MinPlayers}-{definition.MaxPlayers} players but the catalog " + + $"advertises {catalog.MinPlayers}-{catalog.MaxPlayers}.")); + } + + var missingModes = catalog.Modes.Except(definition.SupportedModes, StringComparer.Ordinal).ToList(); + if (missingModes.Count > 0) + { + violations.Add(CatalogCompatibilityViolation.Create( + definition.Slug, + $"Catalog advertises mode(s) [{string.Join(", ", missingModes)}] the engine does not support.")); + } + } + + return violations; + } +} diff --git a/src/SimPle.Application/GameHost/Services/CatalogGameSnapshot.cs b/src/SimPle.Application/GameHost/Services/CatalogGameSnapshot.cs new file mode 100644 index 0000000..a708d5c --- /dev/null +++ b/src/SimPle.Application/GameHost/Services/CatalogGameSnapshot.cs @@ -0,0 +1,56 @@ +namespace SimPle.Application.GameHost.Services; + +/// +/// A minimal, immutable read of one Module 4 catalog row — Slug, player bounds, and mode capabilities — +/// used only by . +/// +/// This is deliberately not M4's Game aggregate (D1 in the reconciliation ledger). The validator core +/// stays free of a direct dependency on M4's private-setter, invariant-guarded entity, so mismatched fixtures +/// for the zero/matching/mismatched test matrix are trivial to construct. The real composition root maps +/// Game.Slug/MinPlayers/MaxPlayers/Capabilities[].Mode into this shape. +/// +/// +public sealed class CatalogGameSnapshot +{ + public string Slug { get; } + public int MinPlayers { get; } + public int MaxPlayers { get; } + public IReadOnlySet Modes { get; } + + private CatalogGameSnapshot(string slug, int minPlayers, int maxPlayers, IReadOnlySet modes) + { + Slug = slug; + MinPlayers = minPlayers; + MaxPlayers = maxPlayers; + Modes = modes; + } + + public static CatalogGameSnapshot Create(string slug, int minPlayers, int maxPlayers, IEnumerable modes) + { + if (string.IsNullOrWhiteSpace(slug)) + throw new ArgumentException("Slug must not be empty.", nameof(slug)); + if (minPlayers < 1) + throw new ArgumentOutOfRangeException(nameof(minPlayers), minPlayers, "MinPlayers must be at least 1."); + if (minPlayers > maxPlayers) + throw new ArgumentException("MinPlayers must be <= MaxPlayers.", nameof(minPlayers)); + + return new CatalogGameSnapshot(slug, minPlayers, maxPlayers, new HashSet(modes, StringComparer.Ordinal)); + } +} + +/// One drift signal between a registered engine's metadata and its catalog row's advertised shape. +public sealed class CatalogCompatibilityViolation +{ + public string Slug { get; } + public string Reason { get; } + + private CatalogCompatibilityViolation(string slug, string reason) + { + Slug = slug; + Reason = reason; + } + + public static CatalogCompatibilityViolation Create(string slug, string reason) => new(slug, reason); + + public override string ToString() => $"{Slug}: {Reason}"; +} diff --git a/src/SimPle.Application/GameHost/Services/GameHostInvoker.cs b/src/SimPle.Application/GameHost/Services/GameHostInvoker.cs new file mode 100644 index 0000000..575b393 --- /dev/null +++ b/src/SimPle.Application/GameHost/Services/GameHostInvoker.cs @@ -0,0 +1,209 @@ +using Microsoft.Extensions.Logging; +using SimPle.Application.GameHost.Serialization; +using SimPle.Domain.GameHost; + +namespace SimPle.Application.GameHost.Services; + +/// +/// The one implementation. Every public method follows the same shape: resolve +/// the engine from , check the inbound size budget, run the call under the +/// cooperative-cancellation watchdog, then check the outbound size budget. +/// +public sealed class GameHostInvoker : IGameHostInvoker +{ + private readonly IGameRegistry _registry; + private readonly ILogger _logger; + + public GameHostInvoker(IGameRegistry registry, ILogger logger) + { + _registry = registry; + _logger = logger; + } + + public GameHostResult CreateMatch( + string gameSlug, + int engineVersion, + GameSetup setup, + UInt128 matchSeed, + CancellationToken cancellationToken) + { + if (!TryResolveOrFail(gameSlug, engineVersion, out var definition, out var resolutionFailure)) + return resolutionFailure!; + + var result = Execute(ct => definition!.CreateInitialState(setup, matchSeed, ct), definition!.Metadata, nameof(CreateMatch), cancellationToken); + if (!result.Succeeded) + return result; + + if (result.Value!.StateBytes.Length > EngineLimits.MaxSerializedStateBytes) + { + LogSizeBudgetExceeded(nameof(CreateMatch), definition.Metadata, "state", result.Value.StateBytes.Length, EngineLimits.MaxSerializedStateBytes); + return GameHostResult.Failure(EngineErrorCode.StateTooLarge); + } + + return result; + } + + public EngineTransition ApplyCommand(GameStateEnvelope state, GameCommandEnvelope command, CancellationToken cancellationToken) + { + if (!_registry.TryResolve(state.GameSlug, state.EngineVersion, out var definition) || definition is null) + return EngineTransition.Reject(state.Revision, ResolveUnknownGameOrVersion(state.GameSlug)); + + if (command.PayloadBytes.Length > EngineLimits.MaxCommandPayloadBytes) + { + _logger.LogWarning( + "GameHost {Operation} for {Definition} rejected an oversized command payload ({Bytes} bytes).", + nameof(ApplyCommand), definition.Metadata, command.PayloadBytes.Length); + return EngineTransition.Reject(state.Revision, EngineErrorCode.PayloadTooLarge); + } + + var result = Execute(ct => definition.ApplyCommand(state, command, ct), definition.Metadata, nameof(ApplyCommand), cancellationToken); + if (!result.Succeeded) + return EngineTransition.Reject(state.Revision, result.ErrorCode!.Value, result.ErrorDetail); + + var transition = result.Value!; + if (!transition.Accepted) + return transition; + + if (transition.NextState!.StateBytes.Length > EngineLimits.MaxSerializedStateBytes) + { + LogSizeBudgetExceeded(nameof(ApplyCommand), definition.Metadata, "next state", transition.NextState.StateBytes.Length, EngineLimits.MaxSerializedStateBytes); + return EngineTransition.Reject(state.Revision, EngineErrorCode.StateTooLarge); + } + + var eventBatchBytes = transition.PublicEvents.Sum(e => e.PayloadBytes.Length) + transition.PrivateEvents.Sum(e => e.PayloadBytes.Length); + if (eventBatchBytes > EngineLimits.MaxGameEventBatchBytes) + { + // No dedicated Engine.* code exists for an oversized event batch — the 13 codes are a published, + // immutable wire contract. A definition emitting more than its budget allows is a defect in the + // definition, not a client-triggerable condition, so PluginFailure is the closest honest fit. + LogSizeBudgetExceeded(nameof(ApplyCommand), definition.Metadata, "event batch", eventBatchBytes, EngineLimits.MaxGameEventBatchBytes); + return EngineTransition.Reject(state.Revision, EngineErrorCode.PluginFailure, "Event batch exceeded the size budget."); + } + + return transition; + } + + public GameHostResult ProjectView(GameStateEnvelope state, ViewerContext viewer, CancellationToken cancellationToken) + { + if (!TryResolveOrFail(state.GameSlug, state.EngineVersion, out var definition, out var resolutionFailure)) + return resolutionFailure!; + + var result = Execute(ct => definition!.ProjectView(state, viewer, ct), definition!.Metadata, nameof(ProjectView), cancellationToken); + if (!result.Succeeded) + return result; + + var view = result.Value!; + var viewBytes = view.PublicView.Length + (view.PrivateView?.Length ?? 0); + if (viewBytes > EngineLimits.MaxPlayerViewBytes) + { + LogSizeBudgetExceeded(nameof(ProjectView), definition.Metadata, "player view", viewBytes, EngineLimits.MaxPlayerViewBytes); + return GameHostResult.Failure(EngineErrorCode.PluginFailure, "Player view exceeded the size budget."); + } + + return result; + } + + public GameHostResult EvaluateResult(GameStateEnvelope state, CancellationToken cancellationToken) + { + if (!TryResolveOrFail(state.GameSlug, state.EngineVersion, out var definition, out var resolutionFailure)) + return resolutionFailure!; + + return Execute(ct => definition!.EvaluateResult(state, ct), definition!.Metadata, nameof(EvaluateResult), cancellationToken); + } + + private bool TryResolveOrFail( + string gameSlug, + int engineVersion, + out IHostedGameDefinition? definition, + out GameHostResult? failure) + { + if (_registry.TryResolve(gameSlug, engineVersion, out definition) && definition is not null) + { + failure = null; + return true; + } + + failure = GameHostResult.Failure(ResolveUnknownGameOrVersion(gameSlug)); + return false; + } + + private EngineErrorCode ResolveUnknownGameOrVersion(string gameSlug) + { + var slugKnown = _registry.RegisteredDefinitions.Any(m => m.Slug == gameSlug); + return slugKnown ? EngineErrorCode.UnknownVersion : EngineErrorCode.UnknownGame; + } + + /// + /// Runs under the cooperative-cancellation watchdog: the call receives a token that + /// is cancelled after , and this method gives it a + /// further to observe that token and return before giving + /// up on waiting. In-process code cannot be hard-preempted, so "giving up" means this method stops waiting + /// and reports — a definition that never returns has + /// leaked a background thread, which is a release-blocking defect, not a runtime-recoverable one. + /// + /// Every failure path — a caller-requested cancellation, a budget timeout, a fail-closed deserialization, + /// or an unexpected plugin exception — is normalized to a stable here. Only + /// this method logs the underlying exception (server-side, structured, never echoed to a client); every + /// caller sees just the code. + /// + /// + private GameHostResult Execute( + Func call, + GameDefinitionMetadata metadata, + string operationName, + CancellationToken callerToken) + { + if (callerToken.IsCancellationRequested) + return GameHostResult.Failure(EngineErrorCode.Cancelled); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(callerToken); + cts.CancelAfter(EngineLimits.CancellationRequestThreshold); + + var task = Task.Run(() => call(cts.Token), CancellationToken.None); + + bool completedInTime; + try + { + completedInTime = task.Wait(EngineLimits.CancellationRequestThreshold + EngineLimits.CooperativeReturnGrace); + } + catch (AggregateException) + { + // Wait() surfaces a faulted task by throwing; the task itself is still complete, so fall through to + // the IsFaulted branch below to classify the failure. + completedInTime = true; + } + + if (!completedInTime) + { + _logger.LogWarning( + "GameHost {Operation} for {Definition} did not return within its cooperative-cancellation budget.", + operationName, metadata); + return GameHostResult.Failure( + callerToken.IsCancellationRequested ? EngineErrorCode.Cancelled : EngineErrorCode.ExecutionBudgetExceeded); + } + + if (task.IsFaulted) + { + var inner = task.Exception!.GetBaseException(); + + if (inner is GameHostSerializationException serializationError) + return GameHostResult.Failure(serializationError.Code); + + if (inner is OperationCanceledException) + { + return GameHostResult.Failure( + callerToken.IsCancellationRequested ? EngineErrorCode.Cancelled : EngineErrorCode.ExecutionBudgetExceeded); + } + + _logger.LogError(inner, "GameHost plugin failure during {Operation} for {Definition}.", operationName, metadata); + return GameHostResult.Failure(EngineErrorCode.PluginFailure); + } + + return GameHostResult.Success(task.Result); + } + + private void LogSizeBudgetExceeded(string operationName, GameDefinitionMetadata metadata, string what, int actualBytes, int maxBytes) => + _logger.LogWarning( + "GameHost {Operation} for {Definition} produced a {What} of {ActualBytes} bytes, exceeding the {MaxBytes}-byte budget.", + operationName, metadata, what, actualBytes, maxBytes); +} diff --git a/src/SimPle.Application/GameHost/Services/GameHostResult.cs b/src/SimPle.Application/GameHost/Services/GameHostResult.cs new file mode 100644 index 0000000..011f781 --- /dev/null +++ b/src/SimPle.Application/GameHost/Services/GameHostResult.cs @@ -0,0 +1,29 @@ +using SimPle.Domain.GameHost; + +namespace SimPle.Application.GameHost.Services; + +/// +/// The outcome of a call that has no domain-level "rejection" shape of its own +/// (, , a terminal-result query). Unlike +/// — which encodes rejection natively because a command always has a prior +/// revision to report — these calls either produce a value or fail with a stable . +/// +public sealed class GameHostResult +{ + public bool Succeeded { get; } + public T? Value { get; } + public EngineErrorCode? ErrorCode { get; } + public string? ErrorDetail { get; } + + private GameHostResult(bool succeeded, T? value, EngineErrorCode? errorCode, string? errorDetail) + { + Succeeded = succeeded; + Value = value; + ErrorCode = errorCode; + ErrorDetail = errorDetail; + } + + public static GameHostResult Success(T value) => new(true, value, null, null); + + public static GameHostResult Failure(EngineErrorCode code, string? detail = null) => new(false, default, code, detail); +} diff --git a/src/SimPle.Application/GameHost/Services/GameRegistry.cs b/src/SimPle.Application/GameHost/Services/GameRegistry.cs new file mode 100644 index 0000000..4e70de8 --- /dev/null +++ b/src/SimPle.Application/GameHost/Services/GameRegistry.cs @@ -0,0 +1,55 @@ +using SimPle.Domain.GameHost; + +namespace SimPle.Application.GameHost.Services; + +/// +/// The single implementation. Built once from the full set of installed definitions +/// via — normally called from the Api composition root before app.Run() — and is +/// immutable and safe to share as a singleton afterward. +/// +public sealed class GameRegistry : IGameRegistry +{ + private readonly IReadOnlyDictionary<(string Slug, int EngineVersion), IHostedGameDefinition> _definitions; + private readonly IReadOnlyList _registeredDefinitions; + + private GameRegistry( + IReadOnlyDictionary<(string Slug, int EngineVersion), IHostedGameDefinition> definitions, + IReadOnlyList registeredDefinitions) + { + _definitions = definitions; + _registeredDefinitions = registeredDefinitions; + } + + /// + /// Builds the registry from every installed definition. Throws on a + /// duplicate (Slug, EngineVersion) key — a fail-fast startup error, per the spec's requirement that a + /// duplicate key can never surface as a call-time ambiguity. + /// + public static GameRegistry Create(IEnumerable definitions) + { + ArgumentNullException.ThrowIfNull(definitions); + + var map = new Dictionary<(string Slug, int EngineVersion), IHostedGameDefinition>(); + var metadata = new List(); + + foreach (var definition in definitions) + { + var key = (definition.Metadata.Slug, definition.Metadata.EngineVersion); + if (!map.TryAdd(key, definition)) + { + throw new InvalidOperationException( + $"Duplicate game engine registration for '{definition.Metadata}'. " + + "Each (Slug, EngineVersion) pair must be registered exactly once."); + } + + metadata.Add(definition.Metadata); + } + + return new GameRegistry(map, metadata); + } + + public bool TryResolve(string slug, int engineVersion, out IHostedGameDefinition? definition) => + _definitions.TryGetValue((slug, engineVersion), out definition); + + public IReadOnlyList RegisteredDefinitions => _registeredDefinitions; +} diff --git a/src/SimPle.Application/GameHost/Services/HostedGameDefinition.cs b/src/SimPle.Application/GameHost/Services/HostedGameDefinition.cs new file mode 100644 index 0000000..4ed230c --- /dev/null +++ b/src/SimPle.Application/GameHost/Services/HostedGameDefinition.cs @@ -0,0 +1,154 @@ +using SimPle.Application.GameHost.Serialization; +using SimPle.Domain.GameHost; + +namespace SimPle.Application.GameHost.Services; + +/// +/// The one implementation. Wraps a single typed +/// and does all of the envelope/byte/checksum work so +/// the typed definition stays pure game rules. +/// +public sealed class HostedGameDefinition : IHostedGameDefinition + where TState : class + where TCommand : class, IGameCommand + where TPlayerView : class +{ + private readonly IGameDefinition _definition; + + public HostedGameDefinition(IGameDefinition definition) + { + ArgumentNullException.ThrowIfNull(definition); + _definition = definition; + } + + public GameDefinitionMetadata Metadata => _definition.Metadata; + + public GameStateEnvelope CreateInitialState(GameSetup setup, UInt128 matchSeed, CancellationToken cancellationToken) + { + var rng = Pcg32.FromMatchSeed(matchSeed); + var state = _definition.CreateInitialState(setup, rng, cancellationToken); + var stateBytes = GameHostJsonContext.Serialize(state); + + return GameStateEnvelope.Create( + Metadata.Slug, + Metadata.EngineVersion, + Metadata.StateSchemaVersion, + revision: 0, + rng.Snapshot(), + stateBytes); + } + + public EngineTransition ApplyCommand(GameStateEnvelope state, GameCommandEnvelope command, CancellationToken cancellationToken) + { + if (!TryValidateStateIdentity(state, out var identityFailure)) + return EngineTransition.Reject(state.Revision, identityFailure); + + if (command.ExpectedRevision != state.Revision) + return EngineTransition.Reject(state.Revision, EngineErrorCode.StaleRevision); + + TState typedState; + TCommand typedCommand; + try + { + typedState = GameHostJsonContext.Deserialize(state.StateBytes.Span, EngineErrorCode.CorruptState); + typedCommand = GameHostJsonContext.Deserialize(command.PayloadBytes.Span, EngineErrorCode.InvalidCommandType); + } + catch (GameHostSerializationException ex) + { + return EngineTransition.Reject(state.Revision, ex.Code); + } + + // Defense-in-depth: the payload can only deserialize into a derived type this game's own TCommand union + // declares, so a cross-definition discriminator already fails above. This catches the narrower case + // where the envelope's out-of-band CommandType and the payload's own embedded discriminator disagree. + if (!string.Equals(typedCommand.CommandType, command.CommandType, StringComparison.Ordinal)) + { + return EngineTransition.Reject( + state.Revision, + EngineErrorCode.InvalidCommandType, + "Envelope CommandType does not match the payload's declared type."); + } + + var context = new CommandContext(command.CommandId, command.ActorUserId, command.ActorSeat, command.ExpectedRevision, state.Revision); + var rng = Pcg32.Restore(state.RngState); + + // A rejected decision leaves `rng` un-persisted: any draws it made before rejecting are discarded along + // with everything else, exactly as IGameDefinition.ApplyCommand's contract requires. An exception from + // the typed call is deliberately not caught here — it propagates to GameHostInvoker, which is the layer + // responsible for mapping an unexpected plugin failure to Engine.PluginFailure without ever logging or + // returning the raw exception text. + var decision = _definition.ApplyCommand(typedState, typedCommand, context, rng, cancellationToken); + + if (!decision.Accepted) + return EngineTransition.Reject(state.Revision, decision.RejectionCode!.Value, decision.RejectionDetail); + + var nextStateBytes = GameHostJsonContext.Serialize(decision.NextState); + var nextEnvelope = GameStateEnvelope.Create( + state.GameSlug, + state.EngineVersion, + state.StateSchemaVersion, + state.Revision + 1, + rng.Snapshot(), + nextStateBytes); + + return EngineTransition.Accept(state.Revision, nextEnvelope, decision.Events, decision.TerminalResult); + } + + public PlayerViewEnvelope ProjectView(GameStateEnvelope state, ViewerContext viewer, CancellationToken cancellationToken) + { + var typedState = DeserializeStateOrThrow(state); + var view = _definition.ProjectView(typedState, viewer, cancellationToken); + var viewBytes = GameHostJsonContext.Serialize(view); + var terminal = _definition.EvaluateResult(typedState, cancellationToken); + + // TPlayerView is already the complete, redacted-for-this-viewer projection — the typed contract returns + // one object per viewer, not a (shared, addressee-only) pair. The whole projection is carried as + // PublicView; PrivateView/hasPrivateView stays unused by this adapter. This does not weaken redaction: + // a spectator's TPlayerView and a player's TPlayerView are already distinct, correctly-scoped objects + // built by the definition's own ProjectView call for that specific viewer. + return PlayerViewEnvelope.Create( + state.Revision, + viewer, + viewBytes, + Metadata.StateSchemaVersion, + terminal is null ? EngineState.InProgress : EngineState.Terminal); + } + + public TerminalResultCandidate? EvaluateResult(GameStateEnvelope state, CancellationToken cancellationToken) + { + var typedState = DeserializeStateOrThrow(state); + return _definition.EvaluateResult(typedState, cancellationToken); + } + + private TState DeserializeStateOrThrow(GameStateEnvelope state) + { + if (!TryValidateStateIdentity(state, out var identityFailure)) + throw new GameHostSerializationException(identityFailure, "State envelope failed identity or integrity validation."); + + return GameHostJsonContext.Deserialize(state.StateBytes.Span, EngineErrorCode.CorruptState); + } + + private bool TryValidateStateIdentity(GameStateEnvelope state, out EngineErrorCode failureCode) + { + if (state.GameSlug != Metadata.Slug || state.EngineVersion != Metadata.EngineVersion) + { + failureCode = EngineErrorCode.CorruptState; + return false; + } + + if (state.StateSchemaVersion != Metadata.StateSchemaVersion) + { + failureCode = EngineErrorCode.UnsupportedStateVersion; + return false; + } + + if (!state.ChecksumMatches()) + { + failureCode = EngineErrorCode.CorruptState; + return false; + } + + failureCode = default; + return true; + } +} diff --git a/src/SimPle.Application/GameHost/Services/ICatalogEngineCompatibilityValidator.cs b/src/SimPle.Application/GameHost/Services/ICatalogEngineCompatibilityValidator.cs new file mode 100644 index 0000000..3d9c3ec --- /dev/null +++ b/src/SimPle.Application/GameHost/Services/ICatalogEngineCompatibilityValidator.cs @@ -0,0 +1,22 @@ +using SimPle.Domain.GameHost; + +namespace SimPle.Application.GameHost.Services; + +/// +/// Compares every registered engine's metadata against its Module 4 catalog row, so a startup check catches +/// drift (an engine that supports fewer players or fewer modes than the catalog advertises) before a client +/// ever hits it as a runtime rejection. +/// +public interface ICatalogEngineCompatibilityValidator +{ + /// + /// Checks each registered definition against the catalog snapshot with the same slug, if one exists. A + /// catalog row with no matching registered engine, or a registered engine with no matching catalog row, is + /// not itself a violation — Phase 1 catalog games have no Module 5 engine yet, and a newly registered + /// engine may briefly precede its catalog row in a non-production environment. Only a matched pair + /// with incompatible bounds or modes is reported. + /// + IReadOnlyList Validate( + IEnumerable registeredDefinitions, + IEnumerable catalogSnapshots); +} diff --git a/src/SimPle.Application/GameHost/Services/IGameHostInvoker.cs b/src/SimPle.Application/GameHost/Services/IGameHostInvoker.cs new file mode 100644 index 0000000..224e8be --- /dev/null +++ b/src/SimPle.Application/GameHost/Services/IGameHostInvoker.cs @@ -0,0 +1,29 @@ +using SimPle.Domain.GameHost; + +namespace SimPle.Application.GameHost.Services; + +/// +/// The safe host boundary — the exact shape Module 8 calls. Resolves the target engine from the registry, +/// enforces every size and timing budget before and after the underlying +/// call, and translates a plugin exception or a timeout into a stable +/// that never carries hidden state, seed material, or raw exception text. +/// +public interface IGameHostInvoker +{ + GameHostResult CreateMatch( + string gameSlug, + int engineVersion, + GameSetup setup, + UInt128 matchSeed, + CancellationToken cancellationToken); + + /// + /// Always returns an — never throws for an input-shaped or host-boundary + /// failure — because a transition already has a prior revision to report even on rejection. + /// + EngineTransition ApplyCommand(GameStateEnvelope state, GameCommandEnvelope command, CancellationToken cancellationToken); + + GameHostResult ProjectView(GameStateEnvelope state, ViewerContext viewer, CancellationToken cancellationToken); + + GameHostResult EvaluateResult(GameStateEnvelope state, CancellationToken cancellationToken); +} diff --git a/src/SimPle.Application/GameHost/Services/IGameRegistry.cs b/src/SimPle.Application/GameHost/Services/IGameRegistry.cs index d7a3384..2300431 100644 --- a/src/SimPle.Application/GameHost/Services/IGameRegistry.cs +++ b/src/SimPle.Application/GameHost/Services/IGameRegistry.cs @@ -3,11 +3,19 @@ namespace SimPle.Application.GameHost.Services; /// -/// Registry of all installed game engines. Future games register themselves here. +/// The set of game engines installed at startup, keyed by the immutable (Slug, EngineVersion) pair. Built +/// once during composition-root startup and never mutated afterward — there is no runtime registration API, +/// so a duplicate key is a startup failure, never a call-time race. /// public interface IGameRegistry { - IGameEngine GetEngine(string gameSlug); - IReadOnlyList RegisteredGameSlugs { get; } - bool IsRegistered(string gameSlug); + /// + /// Resolves the exact engine for at . Never falls + /// back to "latest" or to a different version: a match record always names the exact engine that produced + /// it, and replaying that match must resolve the same one. + /// + bool TryResolve(string slug, int engineVersion, out IHostedGameDefinition? definition); + + /// Every installed definition's metadata, for startup catalog-compatibility validation and diagnostics. + IReadOnlyList RegisteredDefinitions { get; } } diff --git a/src/SimPle.Application/GameHost/Services/IHostedGameDefinition.cs b/src/SimPle.Application/GameHost/Services/IHostedGameDefinition.cs new file mode 100644 index 0000000..d08dac7 --- /dev/null +++ b/src/SimPle.Application/GameHost/Services/IHostedGameDefinition.cs @@ -0,0 +1,50 @@ +using SimPle.Domain.GameHost; + +namespace SimPle.Application.GameHost.Services; + +/// +/// The non-generic adapter boundary — the exact shape Module 8 calls. Wraps one typed +/// registration and translates between the typed +/// definition and the byte/envelope world M8 stores and transports. +/// +/// An implementation deserializes only the concrete state/command/view types supplied by its own registered +/// typed definition. It never resolves a CLR type name from input, never uses reflection-based arbitrary +/// activation, and never falls back on an unknown type — a payload built for a different game definition is +/// rejected as a typed /, +/// never silently accepted. +/// +/// +public interface IHostedGameDefinition +{ + GameDefinitionMetadata Metadata { get; } + + /// + /// Builds the opening envelope at revision 0 from one server-drawn 128-bit match seed. Never called with a + /// caller-supplied seed — is the only place the seed is consumed. + /// + GameStateEnvelope CreateInitialState(GameSetup setup, UInt128 matchSeed, CancellationToken cancellationToken); + + /// + /// Deserializes , applies , and re-serializes the result. + /// Builds itself from .Revision and the envelope's + /// server-bound fields — a caller cannot pass a fabricated CurrentRevision, which is what makes the + /// staleness check trustworthy. + /// + /// Returns a rejection — never throws — for every input-shaped failure + /// (corrupt checksum, unsupported schema, stale revision, illegal actor, invalid command type/shape). A + /// definition-thrown exception during the typed call is the only case this rethrows; the caller + /// () maps it to so no plugin + /// exception text ever reaches a log or a client. + /// + /// + EngineTransition ApplyCommand( + GameStateEnvelope state, + GameCommandEnvelope command, + CancellationToken cancellationToken); + + /// Deserializes and builds the redacted projection for one viewer. + PlayerViewEnvelope ProjectView(GameStateEnvelope state, ViewerContext viewer, CancellationToken cancellationToken); + + /// Deserializes and asks the typed definition for its terminal verdict. + TerminalResultCandidate? EvaluateResult(GameStateEnvelope state, CancellationToken cancellationToken); +} diff --git a/src/SimPle.Domain/GameHost/EngineDecision.cs b/src/SimPle.Domain/GameHost/EngineDecision.cs new file mode 100644 index 0000000..ecd2d92 --- /dev/null +++ b/src/SimPle.Domain/GameHost/EngineDecision.cs @@ -0,0 +1,89 @@ +namespace SimPle.Domain.GameHost; + +/// +/// What a typed definition returns from ApplyCommand, in its own state type. The host adapter +/// serializes this into the non-generic that Module 8 consumes; a definition +/// never touches bytes, envelopes, or checksums. +/// +/// A rejection carries no next state and no events. That is enforced in +/// rather than left to the definition author, so a buggy engine cannot leak a partially-mutated board or a +/// hidden card through a failed move. +/// +/// +public sealed class EngineDecision where TState : class +{ + public bool Accepted { get; } + + /// The state after the command. Non-null exactly when is true. + public TState? NextState { get; } + + public EngineErrorCode? RejectionCode { get; } + + /// Client-safe detail. Must never contain hidden state, seed material, or exception text. + public string? RejectionDetail { get; } + + public IReadOnlyList Events { get; } + + /// Set when is . + public TerminalResultCandidate? TerminalResult { get; } + + public EngineState EngineState { get; } + + private EngineDecision( + bool accepted, + TState? nextState, + EngineErrorCode? rejectionCode, + string? rejectionDetail, + IReadOnlyList events, + TerminalResultCandidate? terminalResult, + EngineState engineState) + { + Accepted = accepted; + NextState = nextState; + RejectionCode = rejectionCode; + RejectionDetail = rejectionDetail; + Events = events; + TerminalResult = terminalResult; + EngineState = engineState; + } + + public static EngineDecision Accept( + TState nextState, + IEnumerable? events = null, + TerminalResultCandidate? terminalResult = null) + { + ArgumentNullException.ThrowIfNull(nextState); + + var emitted = events?.ToList() ?? []; + if (emitted.Count > EngineLimits.MaxEmittedEventsPerCommand) + { + throw new ArgumentOutOfRangeException( + nameof(events), + emitted.Count, + $"A command may emit at most {EngineLimits.MaxEmittedEventsPerCommand} events."); + } + + return new EngineDecision( + accepted: true, + nextState: nextState, + rejectionCode: null, + rejectionDetail: null, + events: emitted, + terminalResult: terminalResult, + engineState: terminalResult is null ? EngineState.InProgress : EngineState.Terminal); + } + + /// + /// Rejects the command. The revision and the RNG stream are left untouched by the host, so a rejected + /// command is indistinguishable from one that was never issued. + /// + public static EngineDecision Reject(EngineErrorCode code, string? detail = null) => + new( + accepted: false, + nextState: null, + rejectionCode: code, + rejectionDetail: detail, + events: [], + terminalResult: null, + engineState: EngineState.InProgress); +} diff --git a/src/SimPle.Domain/GameHost/EngineErrorCode.cs b/src/SimPle.Domain/GameHost/EngineErrorCode.cs new file mode 100644 index 0000000..adbca1c --- /dev/null +++ b/src/SimPle.Domain/GameHost/EngineErrorCode.cs @@ -0,0 +1,48 @@ +namespace SimPle.Domain.GameHost; + +/// +/// The stable, client-safe rejection codes a hosted game call can return. The wire form is the +/// Engine.* string produced by ; the enum member +/// names are an implementation detail, the strings are the contract and must not change. +/// +public enum EngineErrorCode +{ + UnknownGame, + UnknownVersion, + UnsupportedStateVersion, + CorruptState, + InvalidCommandType, + InvalidCommand, + IllegalActor, + StaleRevision, + PayloadTooLarge, + StateTooLarge, + Cancelled, + ExecutionBudgetExceeded, + PluginFailure, +} + +public static class EngineErrorCodeExtensions +{ + /// + /// Maps a code to its stable wire string. These 13 strings are a published contract shared with Module 8 + /// and any future client; renaming one is a breaking change. + /// + public static string ToStableCode(this EngineErrorCode code) => code switch + { + EngineErrorCode.UnknownGame => "Engine.UnknownGame", + EngineErrorCode.UnknownVersion => "Engine.UnknownVersion", + EngineErrorCode.UnsupportedStateVersion => "Engine.UnsupportedStateVersion", + EngineErrorCode.CorruptState => "Engine.CorruptState", + EngineErrorCode.InvalidCommandType => "Engine.InvalidCommandType", + EngineErrorCode.InvalidCommand => "Engine.InvalidCommand", + EngineErrorCode.IllegalActor => "Engine.IllegalActor", + EngineErrorCode.StaleRevision => "Engine.StaleRevision", + EngineErrorCode.PayloadTooLarge => "Engine.PayloadTooLarge", + EngineErrorCode.StateTooLarge => "Engine.StateTooLarge", + EngineErrorCode.Cancelled => "Engine.Cancelled", + EngineErrorCode.ExecutionBudgetExceeded => "Engine.ExecutionBudgetExceeded", + EngineErrorCode.PluginFailure => "Engine.PluginFailure", + _ => throw new ArgumentOutOfRangeException(nameof(code), code, "Unmapped engine error code."), + }; +} diff --git a/src/SimPle.Domain/GameHost/EngineLimits.cs b/src/SimPle.Domain/GameHost/EngineLimits.cs new file mode 100644 index 0000000..0f0f638 --- /dev/null +++ b/src/SimPle.Domain/GameHost/EngineLimits.cs @@ -0,0 +1,34 @@ +namespace SimPle.Domain.GameHost; + +/// +/// Hard host-boundary defaults. These caps protect the host call boundary, not Module 8's durable storage — +/// if M8 relaxes a column or transport limit the effective cap diverges, so the two must stay aligned. +/// Changing any value here requires benchmark and security evidence plus an ADR; the values are pinned by +/// EngineLimitsTests so a silent change fails the build. +/// +public static class EngineLimits +{ + public const int MaxCommandPayloadBytes = 16 * 1024; + public const int MaxSerializedStateBytes = 256 * 1024; + public const int MaxPlayerViewBytes = 256 * 1024; + public const int MaxGameEventBatchBytes = 64 * 1024; + + public const int MinPlayers = 1; + public const int MaxPlayers = 8; + + public const int MaxEmittedEventsPerCommand = 128; + + /// Target for a single non-AI command. Exceeding it is a benchmark failure, not a runtime error. + public static readonly TimeSpan SoftExecutionBudget = TimeSpan.FromMilliseconds(100); + + /// The host requests cooperative cancellation once a call has run this long. + public static readonly TimeSpan CancellationRequestThreshold = TimeSpan.FromMilliseconds(500); + + /// + /// Grace period a cooperative definition gets to observe its token and return after cancellation is + /// requested. Output arriving later is discarded and mapped to . + /// In-process code that ignores its token cannot be safely preempted: that is a release blocker, not a + /// runtime-recoverable condition. + /// + public static readonly TimeSpan CooperativeReturnGrace = TimeSpan.FromMilliseconds(50); +} diff --git a/src/SimPle.Domain/GameHost/EngineState.cs b/src/SimPle.Domain/GameHost/EngineState.cs new file mode 100644 index 0000000..cf1a18e --- /dev/null +++ b/src/SimPle.Domain/GameHost/EngineState.cs @@ -0,0 +1,12 @@ +namespace SimPle.Domain.GameHost; + +/// +/// The only lifecycle a game definition is allowed to report. Module 8 owns the full match lifecycle +/// (Created/Active/Paused/Completed/Aborted) and decides whether a command may reach the engine at all; +/// the engine itself only knows whether its own rules consider the state finished. +/// +public enum EngineState +{ + InProgress = 0, + Terminal = 1, +} diff --git a/src/SimPle.Domain/GameHost/EngineTransition.cs b/src/SimPle.Domain/GameHost/EngineTransition.cs new file mode 100644 index 0000000..88e3f73 --- /dev/null +++ b/src/SimPle.Domain/GameHost/EngineTransition.cs @@ -0,0 +1,107 @@ +namespace SimPle.Domain.GameHost; + +/// +/// The non-generic result of one hosted command — the exact shape Module 8 will consume. Produced by the host +/// adapter from a typed . +/// +/// On rejection, is null, no events are carried, and equals +/// : a failed command advances neither the revision nor the RNG, so retrying it or +/// replaying the log produces the same result. +/// +/// +public sealed class EngineTransition +{ + public bool Accepted { get; } + + public int PriorRevision { get; } + public int NextRevision { get; } + + /// Non-null exactly when is true. + public GameStateEnvelope? NextState { get; } + + /// Stable Engine.* code, or when accepted. + public string? RejectionCode { get; } + + /// Client-safe detail. Never hidden state, seed material, or exception text. + public string? RejectionDetail { get; } + + public IReadOnlyList PublicEvents { get; } + public IReadOnlyList PrivateEvents { get; } + + public TerminalResultCandidate? TerminalResult { get; } + public EngineState EngineState { get; } + + private EngineTransition( + bool accepted, + int priorRevision, + int nextRevision, + GameStateEnvelope? nextState, + string? rejectionCode, + string? rejectionDetail, + IReadOnlyList publicEvents, + IReadOnlyList privateEvents, + TerminalResultCandidate? terminalResult, + EngineState engineState) + { + Accepted = accepted; + PriorRevision = priorRevision; + NextRevision = nextRevision; + NextState = nextState; + RejectionCode = rejectionCode; + RejectionDetail = rejectionDetail; + PublicEvents = publicEvents; + PrivateEvents = privateEvents; + TerminalResult = terminalResult; + EngineState = engineState; + } + + public static EngineTransition Accept( + int priorRevision, + GameStateEnvelope nextState, + IEnumerable events, + TerminalResultCandidate? terminalResult) + { + ArgumentNullException.ThrowIfNull(nextState); + + if (nextState.Revision != priorRevision + 1) + { + throw new ArgumentException( + $"An accepted command must advance the revision by exactly one (prior {priorRevision}, next {nextState.Revision}).", + nameof(nextState)); + } + + var all = events.ToList(); + if (all.Count > EngineLimits.MaxEmittedEventsPerCommand) + { + throw new ArgumentOutOfRangeException( + nameof(events), + all.Count, + $"A command may emit at most {EngineLimits.MaxEmittedEventsPerCommand} events."); + } + + return new EngineTransition( + accepted: true, + priorRevision: priorRevision, + nextRevision: nextState.Revision, + nextState: nextState, + rejectionCode: null, + rejectionDetail: null, + publicEvents: all.Where(e => e.IsPublic).ToList(), + privateEvents: all.Where(e => !e.IsPublic).ToList(), + terminalResult: terminalResult, + engineState: terminalResult is null ? EngineState.InProgress : EngineState.Terminal); + } + + public static EngineTransition Reject(int priorRevision, EngineErrorCode code, string? detail = null) => + new( + accepted: false, + priorRevision: priorRevision, + nextRevision: priorRevision, + nextState: null, + rejectionCode: code.ToStableCode(), + rejectionDetail: detail, + publicEvents: [], + privateEvents: [], + terminalResult: null, + engineState: EngineState.InProgress); +} diff --git a/src/SimPle.Domain/GameHost/GameCommandEnvelope.cs b/src/SimPle.Domain/GameHost/GameCommandEnvelope.cs new file mode 100644 index 0000000..86e5ba7 --- /dev/null +++ b/src/SimPle.Domain/GameHost/GameCommandEnvelope.cs @@ -0,0 +1,76 @@ +namespace SimPle.Domain.GameHost; + +/// +/// One attempted move, as the host receives it. +/// +/// and are server-bound: Module 8 derives them from +/// authenticated lobby membership before constructing this envelope. Nothing inside +/// may override them — a payload that claims a different actor is simply ignored, which is what makes seat +/// spoofing structurally impossible rather than merely checked for. +/// +/// +/// is untrusted input. Size is deliberately not validated here: the host +/// boundary checks it and returns a typed rather than throwing, +/// so an oversized command is a clean rejection and not an exception to be mapped. +/// +/// +public sealed class GameCommandEnvelope +{ + /// Idempotency key. Module 8 stores the receipt; Module 5 only carries it. + public Guid CommandId { get; } + + /// The revision the caller believes it is acting on. A mismatch is . + public int ExpectedRevision { get; } + + public Guid ActorUserId { get; } + public int ActorSeat { get; } + + /// Stable command discriminator, resolved against the definition's allow-list — never a CLR type name. + public string CommandType { get; } + + public ReadOnlyMemory PayloadBytes { get; } + + private GameCommandEnvelope( + Guid commandId, + int expectedRevision, + Guid actorUserId, + int actorSeat, + string commandType, + ReadOnlyMemory payloadBytes) + { + CommandId = commandId; + ExpectedRevision = expectedRevision; + ActorUserId = actorUserId; + ActorSeat = actorSeat; + CommandType = commandType; + PayloadBytes = payloadBytes; + } + + public static GameCommandEnvelope Create( + Guid commandId, + int expectedRevision, + Guid actorUserId, + int actorSeat, + string commandType, + ReadOnlySpan payloadBytes) + { + if (commandId == Guid.Empty) + throw new ArgumentException("CommandId must not be empty.", nameof(commandId)); + if (expectedRevision < 0) + throw new ArgumentOutOfRangeException(nameof(expectedRevision), expectedRevision, "ExpectedRevision must be non-negative."); + if (actorUserId == Guid.Empty) + throw new ArgumentException("ActorUserId must not be empty.", nameof(actorUserId)); + if (actorSeat < 0) + throw new ArgumentOutOfRangeException(nameof(actorSeat), actorSeat, "ActorSeat must be non-negative."); + if (string.IsNullOrWhiteSpace(commandType)) + throw new ArgumentException("CommandType must not be empty.", nameof(commandType)); + + return new GameCommandEnvelope( + commandId, + expectedRevision, + actorUserId, + actorSeat, + commandType, + payloadBytes.ToArray()); + } +} diff --git a/src/SimPle.Domain/GameHost/GameDefinitionMetadata.cs b/src/SimPle.Domain/GameHost/GameDefinitionMetadata.cs new file mode 100644 index 0000000..91b513e --- /dev/null +++ b/src/SimPle.Domain/GameHost/GameDefinitionMetadata.cs @@ -0,0 +1,122 @@ +using SimPle.Domain.Games; + +namespace SimPle.Domain.GameHost; + +/// +/// The immutable identity and capability declaration of a game definition. The +/// (Slug, EngineVersion) pair is the registry key: it is chosen at compile time, never derived from +/// input, and never falls back to "latest" — an explicit version is mandatory so a replay of an old match +/// resolves the engine that actually produced it. +/// +public sealed class GameDefinitionMetadata +{ + public string Slug { get; } + public int EngineVersion { get; } + public int StateSchemaVersion { get; } + public int MinPlayers { get; } + public int MaxPlayers { get; } + + /// Modes this engine can host, from the shared catalog allow-list (). + public IReadOnlySet SupportedModes { get; } + + /// The engine keeps state that at least one seat may not see (a hand, a hidden board). + public bool HasHiddenInformation { get; } + + public bool SupportsSpectatorView { get; } + public bool SupportsAi { get; } + public bool SupportsTimer { get; } + public bool SupportsRanked { get; } + + /// The engine guarantees byte-identical output for identical inputs. Required for golden vectors. + public bool SupportsDeterministicReplay { get; } + + private GameDefinitionMetadata( + string slug, + int engineVersion, + int stateSchemaVersion, + int minPlayers, + int maxPlayers, + IReadOnlySet supportedModes, + bool hasHiddenInformation, + bool supportsSpectatorView, + bool supportsAi, + bool supportsTimer, + bool supportsRanked, + bool supportsDeterministicReplay) + { + Slug = slug; + EngineVersion = engineVersion; + StateSchemaVersion = stateSchemaVersion; + MinPlayers = minPlayers; + MaxPlayers = maxPlayers; + SupportedModes = supportedModes; + HasHiddenInformation = hasHiddenInformation; + SupportsSpectatorView = supportsSpectatorView; + SupportsAi = supportsAi; + SupportsTimer = supportsTimer; + SupportsRanked = supportsRanked; + SupportsDeterministicReplay = supportsDeterministicReplay; + } + + public static GameDefinitionMetadata Create( + string slug, + int engineVersion, + int stateSchemaVersion, + int minPlayers, + int maxPlayers, + IEnumerable supportedModes, + bool hasHiddenInformation = false, + bool supportsSpectatorView = false, + bool supportsAi = false, + bool supportsTimer = false, + bool supportsRanked = false, + bool supportsDeterministicReplay = true) + { + if (string.IsNullOrWhiteSpace(slug)) + throw new ArgumentException("Slug must not be empty.", nameof(slug)); + if (engineVersion <= 0) + throw new ArgumentOutOfRangeException(nameof(engineVersion), engineVersion, "EngineVersion must be positive."); + if (stateSchemaVersion <= 0) + throw new ArgumentOutOfRangeException(nameof(stateSchemaVersion), stateSchemaVersion, "StateSchemaVersion must be positive."); + if (minPlayers < EngineLimits.MinPlayers) + throw new ArgumentOutOfRangeException(nameof(minPlayers), minPlayers, $"MinPlayers must be at least {EngineLimits.MinPlayers}."); + if (maxPlayers > EngineLimits.MaxPlayers) + throw new ArgumentOutOfRangeException(nameof(maxPlayers), maxPlayers, $"MaxPlayers must be at most {EngineLimits.MaxPlayers}."); + if (minPlayers > maxPlayers) + throw new ArgumentException("MinPlayers must be <= MaxPlayers.", nameof(minPlayers)); + + var modes = new HashSet(supportedModes, StringComparer.Ordinal); + if (modes.Count == 0) + throw new ArgumentException("At least one supported mode is required.", nameof(supportedModes)); + + // Reuse the catalog's allow-list rather than a parallel copy: the engine and the Module 4 catalog row + // are compared mode-for-mode by the compatibility validator, so they must speak the same vocabulary. + foreach (var mode in modes) + { + if (!GameCatalogAllowLists.Modes.Contains(mode)) + throw new ArgumentException($"Mode '{mode}' is not in the catalog allow-list.", nameof(supportedModes)); + } + + if (supportsRanked && !modes.Contains("ranked")) + throw new ArgumentException("SupportsRanked requires the 'ranked' mode to be declared.", nameof(supportsRanked)); + if (modes.Contains("ai") && !supportsAi) + throw new ArgumentException("Declaring the 'ai' mode requires SupportsAi.", nameof(supportsAi)); + + return new GameDefinitionMetadata( + slug, + engineVersion, + stateSchemaVersion, + minPlayers, + maxPlayers, + modes, + hasHiddenInformation, + supportsSpectatorView, + supportsAi, + supportsTimer, + supportsRanked, + supportsDeterministicReplay); + } + + /// The registry key. Immutable; a duplicate fails application startup. + public override string ToString() => $"{Slug}@v{EngineVersion}"; +} diff --git a/src/SimPle.Domain/GameHost/GameEvent.cs b/src/SimPle.Domain/GameHost/GameEvent.cs new file mode 100644 index 0000000..81eed16 --- /dev/null +++ b/src/SimPle.Domain/GameHost/GameEvent.cs @@ -0,0 +1,55 @@ +namespace SimPle.Domain.GameHost; + +/// +/// A versioned fact a definition emits about what just happened. Events are the engine's only outward +/// narration — a definition performs no logging, no metrics, and no I/O of its own. +/// +/// Visibility is decided by the definition, not by the transport: an event placed in the public batch is +/// broadcast to every seat and spectator, so anything that would reveal hidden state must go in the private +/// batch addressed to a specific seat. +/// +/// +public sealed class GameEvent +{ + /// Stable discriminator, e.g. TokenDrawn. Part of the compatibility contract. + public string EventType { get; } + + public int SchemaVersion { get; } + + /// Serialized event body. Empty for events that carry no data beyond their type. + public ReadOnlyMemory PayloadBytes { get; } + + /// The seat this event is addressed to, or if it is public. + public int? TargetSeat { get; } + + private GameEvent(string eventType, int schemaVersion, ReadOnlyMemory payloadBytes, int? targetSeat) + { + EventType = eventType; + SchemaVersion = schemaVersion; + PayloadBytes = payloadBytes; + TargetSeat = targetSeat; + } + + public static GameEvent Public(string eventType, int schemaVersion, ReadOnlyMemory payloadBytes = default) => + new(RequireEventType(eventType), RequireSchemaVersion(schemaVersion), payloadBytes, targetSeat: null); + + public static GameEvent Private(string eventType, int schemaVersion, int targetSeat, ReadOnlyMemory payloadBytes = default) + { + if (targetSeat < 0) + throw new ArgumentOutOfRangeException(nameof(targetSeat), targetSeat, "Target seat must be non-negative."); + + return new GameEvent(RequireEventType(eventType), RequireSchemaVersion(schemaVersion), payloadBytes, targetSeat); + } + + public bool IsPublic => TargetSeat is null; + + private static string RequireEventType(string eventType) => + string.IsNullOrWhiteSpace(eventType) + ? throw new ArgumentException("EventType must not be empty.", nameof(eventType)) + : eventType; + + private static int RequireSchemaVersion(int schemaVersion) => + schemaVersion <= 0 + ? throw new ArgumentOutOfRangeException(nameof(schemaVersion), schemaVersion, "SchemaVersion must be positive.") + : schemaVersion; +} diff --git a/src/SimPle.Domain/GameHost/GameHostContexts.cs b/src/SimPle.Domain/GameHost/GameHostContexts.cs new file mode 100644 index 0000000..e549f49 --- /dev/null +++ b/src/SimPle.Domain/GameHost/GameHostContexts.cs @@ -0,0 +1,97 @@ +namespace SimPle.Domain.GameHost; + +/// +/// One seat at the table, bound by Module 8 from authenticated lobby membership. A game definition reads +/// seats; it never authenticates or authorizes anyone. +/// +/// Zero-based seat index. Stable for the life of the match. +/// The account occupying the seat, or for an AI seat. +/// Whether Module 9 drives this seat. +public readonly record struct SeatAssignment(int Seat, Guid? UserId, bool IsAi); + +/// +/// Everything a definition needs to build its opening state. The seed is not here: it reaches the +/// definition only as a live stream, so a definition cannot read, store, or echo the raw +/// seed value even by accident. +/// +public sealed class GameSetup +{ + public IReadOnlyList Seats { get; } + + /// The catalog mode this match is being played in (e.g. multiplayer, solo). + public string Mode { get; } + + private GameSetup(IReadOnlyList seats, string mode) + { + Seats = seats; + Mode = mode; + } + + public static GameSetup Create(IEnumerable seats, string mode) + { + if (string.IsNullOrWhiteSpace(mode)) + throw new ArgumentException("Mode must not be empty.", nameof(mode)); + + var list = seats.ToList(); + if (list.Count is < EngineLimits.MinPlayers or > EngineLimits.MaxPlayers) + { + throw new ArgumentOutOfRangeException( + nameof(seats), + list.Count, + $"Seat count must be between {EngineLimits.MinPlayers} and {EngineLimits.MaxPlayers}."); + } + + // Seats are the definition's only notion of "who": a gap or duplicate would let a command target an + // ambiguous seat, so the shape is validated before any engine code sees it. + var expected = Enumerable.Range(0, list.Count).ToHashSet(); + if (!list.Select(s => s.Seat).ToHashSet().SetEquals(expected)) + throw new ArgumentException("Seats must be a contiguous zero-based range with no duplicates.", nameof(seats)); + + var humanIds = list.Where(s => !s.IsAi && s.UserId is not null).Select(s => s.UserId!.Value).ToList(); + if (humanIds.Distinct().Count() != humanIds.Count) + throw new ArgumentException("A user may not occupy two seats in the same match.", nameof(seats)); + + return new GameSetup(list, mode); + } +} + +/// +/// The server's account of who is issuing a command. Every field is bound by Module 8 from authenticated +/// membership — a definition must treat these as authoritative and must never read an actor identity out of +/// the command payload, which is client-supplied. +/// +/// Client-generated idempotency key. Module 8 stores the receipt; the engine only echoes it. +/// Server-bound account issuing the command. +/// Server-bound seat of that account. +/// The revision the caller believes it is acting on. +/// The revision the authoritative state is actually at. +public readonly record struct CommandContext( + Guid CommandId, + Guid ActorUserId, + int ActorSeat, + int ExpectedRevision, + int CurrentRevision) +{ + /// A command issued against a revision that is no longer current must be rejected, never applied. + public bool IsStale => ExpectedRevision != CurrentRevision; +} + +public enum ViewerRole +{ + Player = 0, + Spectator = 1, +} + +/// +/// Who a projection is being built for. A spectator has no seat and is entitled to no private data: there is +/// no default full-state serialization to fall back on, so a definition must build the spectator projection +/// explicitly or expose nothing. +/// +public readonly record struct ViewerContext(ViewerRole Role, int? Seat, Guid? UserId) +{ + public static ViewerContext ForPlayer(int seat, Guid userId) => new(ViewerRole.Player, seat, userId); + + public static ViewerContext ForSpectator(Guid? userId = null) => new(ViewerRole.Spectator, null, userId); + + public bool IsSpectator => Role == ViewerRole.Spectator; +} diff --git a/src/SimPle.Domain/GameHost/GameSession.cs b/src/SimPle.Domain/GameHost/GameSession.cs deleted file mode 100644 index 3cbc67c..0000000 --- a/src/SimPle.Domain/GameHost/GameSession.cs +++ /dev/null @@ -1,79 +0,0 @@ -using SimPle.Domain.Common; - -namespace SimPle.Domain.GameHost; - -/// -/// A hosted game session. Game-specific logic is delegated to IGameEngine implementations. -/// -public class GameSession : Entity -{ - public Guid GameId { get; private set; } - public Guid? LobbyId { get; private set; } - public Guid HostUserId { get; private set; } - public GameSessionStatus Status { get; private set; } = GameSessionStatus.Waiting; - public GameMode Mode { get; private set; } - public string TimeControl { get; private set; } = "Blitz 3+2"; - public bool IsRanked { get; private set; } - public int CurrentRound { get; private set; } - public int TotalRounds { get; private set; } = 1; - public string? StateJson { get; private set; } - public DateTime? StartedAt { get; private set; } - public DateTime? EndedAt { get; private set; } - - private readonly List _players = []; - public IReadOnlyList Players => _players.AsReadOnly(); - - private readonly List _moves = []; - public IReadOnlyList Moves => _moves.AsReadOnly(); - - private GameSession() { } - - public static GameSession Create(Guid gameId, Guid hostUserId, GameMode mode, bool isRanked = false) => new() - { - GameId = gameId, - HostUserId = hostUserId, - Mode = mode, - IsRanked = isRanked, - }; - - public void AddPlayer(Guid userId, int seatIndex) => - _players.Add(new MatchPlayer { UserId = userId, SeatIndex = seatIndex, SessionId = Id }); - - public void Start() { Status = GameSessionStatus.Active; StartedAt = DateTime.UtcNow; Touch(); } - public void Pause() { Status = GameSessionStatus.Paused; Touch(); } - public void Resume() { Status = GameSessionStatus.Active; Touch(); } - public void Finish() { Status = GameSessionStatus.Completed; EndedAt = DateTime.UtcNow; Touch(); } - public void Cancel() { Status = GameSessionStatus.Cancelled; EndedAt = DateTime.UtcNow; Touch(); } - - public void UpdateState(string stateJson) { StateJson = stateJson; Touch(); } - - public void RecordMove(Guid playerId, string moveData) => - _moves.Add(new Move { SessionId = Id, PlayerId = playerId, MoveData = moveData, PlayedAt = DateTime.UtcNow }); -} - -public class MatchPlayer -{ - public Guid SessionId { get; set; } - public Guid UserId { get; set; } - public int SeatIndex { get; set; } - public bool IsReady { get; set; } - public bool IsAi { get; set; } - public string? AiDifficulty { get; set; } - public MatchResult? Result { get; set; } - public int EloDelta { get; set; } - public int Score { get; set; } -} - -public class Move -{ - public Guid Id { get; set; } = Guid.NewGuid(); - public Guid SessionId { get; set; } - public Guid PlayerId { get; set; } - public string MoveData { get; set; } = default!; - public DateTime PlayedAt { get; set; } - public bool WasValid { get; set; } = true; -} - -public enum GameSessionStatus { Waiting, Active, Paused, Completed, Cancelled } -public enum GameMode { SoloVsAi, FriendPrivate, FriendPublic, QuickMatch, Ranked } -public enum MatchResult { Win, Loss, Draw, Forfeit, Disconnect, Timeout } diff --git a/src/SimPle.Domain/GameHost/GameStateEnvelope.cs b/src/SimPle.Domain/GameHost/GameStateEnvelope.cs new file mode 100644 index 0000000..27a9c6e --- /dev/null +++ b/src/SimPle.Domain/GameHost/GameStateEnvelope.cs @@ -0,0 +1,103 @@ +using System.Security.Cryptography; + +namespace SimPle.Domain.GameHost; + +/// +/// The authoritative, server-only snapshot of a match. Module 8 stores it, transports it, and replays it; +/// Module 5 produces and consumes it. +/// +/// This envelope is not safe to hand to a client. It carries the raw serialized state (including every +/// player's hidden information) and (from which all future draws are predictable). +/// Clients receive a instead — never this. +/// +/// +/// The checksum is integrity, not authenticity. It detects a corrupted or truncated byte range. It is +/// not a signature and must never be used as an authorization or anti-tamper control: anyone who can +/// alter the bytes can recompute it. Authority stays with Module 8's authenticated actor/seat binding. +/// +/// +public sealed class GameStateEnvelope +{ + public string GameSlug { get; } + public int EngineVersion { get; } + public int StateSchemaVersion { get; } + + /// Monotonic counter, incremented only by an accepted command. + public int Revision { get; } + + public string RngAlgorithm { get; } + + /// Server-only. Never project this into a view, a client payload, or a log line. + public Pcg32State RngState { get; } + + /// The exact serialized state bytes the checksum is computed over. + public ReadOnlyMemory StateBytes { get; } + + /// Lowercase hex SHA-256 of . + public string Checksum { get; } + + private GameStateEnvelope( + string gameSlug, + int engineVersion, + int stateSchemaVersion, + int revision, + string rngAlgorithm, + Pcg32State rngState, + ReadOnlyMemory stateBytes, + string checksum) + { + GameSlug = gameSlug; + EngineVersion = engineVersion; + StateSchemaVersion = stateSchemaVersion; + Revision = revision; + RngAlgorithm = rngAlgorithm; + RngState = rngState; + StateBytes = stateBytes; + Checksum = checksum; + } + + public static GameStateEnvelope Create( + string gameSlug, + int engineVersion, + int stateSchemaVersion, + int revision, + Pcg32State rngState, + ReadOnlySpan stateBytes, + string rngAlgorithm = Pcg32.AlgorithmId) + { + if (string.IsNullOrWhiteSpace(gameSlug)) + throw new ArgumentException("GameSlug must not be empty.", nameof(gameSlug)); + if (engineVersion <= 0) + throw new ArgumentOutOfRangeException(nameof(engineVersion), engineVersion, "EngineVersion must be positive."); + if (stateSchemaVersion <= 0) + throw new ArgumentOutOfRangeException(nameof(stateSchemaVersion), stateSchemaVersion, "StateSchemaVersion must be positive."); + if (revision < 0) + throw new ArgumentOutOfRangeException(nameof(revision), revision, "Revision must be non-negative."); + if (string.IsNullOrWhiteSpace(rngAlgorithm)) + throw new ArgumentException("RngAlgorithm must not be empty.", nameof(rngAlgorithm)); + + // Defensive copy: the checksum is only meaningful if the bytes it was computed over cannot be mutated + // out from under it by whoever handed us the buffer. + var copy = stateBytes.ToArray(); + + return new GameStateEnvelope( + gameSlug, + engineVersion, + stateSchemaVersion, + revision, + rngAlgorithm, + rngState, + copy, + ComputeChecksum(copy)); + } + + /// + /// Recomputes the checksum over the carried bytes. A mismatch means the payload was corrupted in storage or + /// transport and the host must fail closed with rather than + /// deserialize it. + /// + public bool ChecksumMatches() => Checksum == ComputeChecksum(StateBytes.Span); + + public static string ComputeChecksum(ReadOnlySpan bytes) => + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); +} diff --git a/src/SimPle.Domain/GameHost/IGameCommand.cs b/src/SimPle.Domain/GameHost/IGameCommand.cs new file mode 100644 index 0000000..57f3013 --- /dev/null +++ b/src/SimPle.Domain/GameHost/IGameCommand.cs @@ -0,0 +1,20 @@ +namespace SimPle.Domain.GameHost; + +/// +/// The contract every member of a game's TCommand union must satisfy. must equal +/// the literal a derived command is declared under via [JsonDerivedType(typeof(X), "literal")] on the +/// union's base type. +/// +/// The host adapter deserializes into the concrete +/// TCommand union — which only recognizes derived types declared by that game's own hierarchy, so a +/// payload built for a different game's command type fails deserialization outright — and then compares +/// against . A mismatch between the two +/// is rejected before the typed definition ever sees the command: it means the envelope's out-of-band +/// discriminator and the payload's embedded discriminator disagree, which is only possible under tampering or a +/// client bug, never under normal operation. +/// +/// +public interface IGameCommand +{ + string CommandType { get; } +} diff --git a/src/SimPle.Domain/GameHost/IGameDefinition.cs b/src/SimPle.Domain/GameHost/IGameDefinition.cs new file mode 100644 index 0000000..34a971f --- /dev/null +++ b/src/SimPle.Domain/GameHost/IGameDefinition.cs @@ -0,0 +1,81 @@ +namespace SimPle.Domain.GameHost; + +/// +/// The one contract a Phase 2 game implements. A definition is pure game rules and nothing else: it has +/// no HTTP, SignalR, EF Core, or app-shell dependency, and the platform can host it without knowing anything +/// about how it is played. +/// +/// +/// +/// Determinism is the load-bearing property. For a completed call, identical inputs must produce +/// byte-identical output — that is what makes replay, golden vectors, and dispute resolution possible. An +/// implementation therefore must not read the system clock, touch the network, database, filesystem, or +/// environment, hold static mutable state, iterate a collection whose order is not defined (a +/// or +/// without an explicit sort), or use any randomness other than the supplied . This cannot be +/// enforced by the type system: it is guaranteed by code review and by the two-process golden-hash comparison, +/// and a violation is a release blocker. +/// +/// +/// Purity, literally. Methods must not mutate the state they are given — they return a new one. +/// The host may reuse an input state instance across calls (for example when re-projecting a view per seat), +/// so in-place mutation corrupts other callers. +/// +/// +/// Cancellation is cooperative. Definitions receive a token and must check it at every bounded loop. +/// Trusted in-process code cannot be hard-preempted, so a definition that ignores its token cannot be stopped +/// by the host; the execution budget in only lets the host discard the late result. +/// +/// +/// Authority is not yours. is client-supplied and untrusted. The actor +/// and seat come from , which the server binds from authenticated membership. Never +/// read an identity out of the command payload. +/// +/// +/// The authoritative state. JSON-serializable, immutable by convention. Holds hidden information. +/// The command union. Members are allow-listed by stable string discriminator, never by CLR type name. +/// The redacted, per-viewer projection. This is the only thing a client ever sees. +public interface IGameDefinition + where TState : class + where TCommand : class + where TPlayerView : class +{ + /// Immutable identity and capabilities. The (Slug, EngineVersion) pair is the registry key. + GameDefinitionMetadata Metadata { get; } + + /// + /// Builds the opening state. All randomness (a shuffled deck, a starting layout) must be drawn from + /// , so the same match seed always deals the same game. + /// + TState CreateInitialState(GameSetup setup, Pcg32 rng, CancellationToken cancellationToken); + + /// + /// Validates and applies one command, returning either a new state or a typed rejection. Must not mutate + /// . + /// + /// Draw from only on a path that ends in + /// : the host discards the advanced stream on rejection, so a + /// draw taken before a rejection is silently thrown away and will be re-taken by the next command — + /// which is correct, but only if the definition does not also assume the draw "happened". + /// + /// + EngineDecision ApplyCommand( + TState state, + TCommand command, + CommandContext context, + Pcg32 rng, + CancellationToken cancellationToken); + + /// + /// Projects the state for one viewer. This is the redaction boundary: whatever is not put into the + /// projection cannot leak, and a spectator must be given strictly less than any seated player. + /// The RNG state and the raw seed must never appear in a projection. + /// + TPlayerView ProjectView(TState state, ViewerContext viewer, CancellationToken cancellationToken); + + /// + /// Returns the terminal result when the rules consider the state finished, or while + /// the match is still in progress. Pure, so asking twice returns the same candidate. + /// + TerminalResultCandidate? EvaluateResult(TState state, CancellationToken cancellationToken); +} diff --git a/src/SimPle.Domain/GameHost/IGameEngine.cs b/src/SimPle.Domain/GameHost/IGameEngine.cs deleted file mode 100644 index ca6f7f4..0000000 --- a/src/SimPle.Domain/GameHost/IGameEngine.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace SimPle.Domain.GameHost; - -/// -/// Each game type provides an implementation. The platform calls these interfaces -/// without knowing the game-specific rules. -/// -public interface IGameEngine -{ - string GameSlug { get; } - object CreateInitialState(GameSession session); - MoveValidationResult ValidateMove(object state, Move move); - object ApplyMove(object state, Move move); - bool IsGameOver(object state, GameSession session); - MatchResult DetermineWinner(object state, GameSession session, Guid playerId); - int CalculateEloDelta(bool won, int playerElo, int opponentElo, bool isRanked); -} - -public interface IGameStateSerializer -{ - string GameSlug { get; } - string Serialize(object state); - object Deserialize(string json); -} - -public interface IAIOpponentService -{ - string GameSlug { get; } - Move GenerateMove(object state, string difficulty, Guid aiPlayerId); -} - -public interface IGameStatsService -{ - string GameSlug { get; } - Task RecordMatchResultAsync(GameSession session, CancellationToken ct = default); -} - -public sealed record MoveValidationResult(bool IsValid, string? RejectionReason = null) -{ - public static MoveValidationResult Valid() => new(true); - public static MoveValidationResult Invalid(string reason) => new(false, reason); -} diff --git a/src/SimPle.Domain/GameHost/Pcg32.cs b/src/SimPle.Domain/GameHost/Pcg32.cs new file mode 100644 index 0000000..9b56016 --- /dev/null +++ b/src/SimPle.Domain/GameHost/Pcg32.cs @@ -0,0 +1,164 @@ +namespace SimPle.Domain.GameHost; + +/// +/// The resumable state of a stream. This is server-only: it lives in the +/// authoritative state envelope and must never reach a player view, a spectator view, a client payload, or a +/// log line. Knowing and lets a player predict every future draw. +/// +/// The LCG state (PCG's state). +/// The odd stream selector (PCG's inc). Always odd by construction. +/// Number of 32-bit draws taken since seeding. Bookkeeping/observability only — it does +/// not feed the generator, but it makes an unexpected divergence visible in a golden vector diff. +public readonly record struct Pcg32State(ulong State, ulong Inc, ulong Cursor); + +/// +/// PCG32-v1 — the reference pcg_setseq_64_xsh_rr_32 generator (64-bit state, 32-bit output, +/// xorshift-high + random-rotation output function), with the published seeding sequence. +/// +/// This is the only source of randomness a game definition may use. Engine code has no ambient RNG, no +/// clock, and no environment access, so identical inputs produce byte-identical output. The generator advances +/// only when the host accepts a command: a rejected or cancelled command discards the advanced state along with +/// everything else it produced. +/// +/// +/// PCG is not cryptographically secure and is not used as such. Its secrecy requirement is met by +/// keeping server-only, exactly as the hidden game state itself is. +/// +/// +public sealed class Pcg32 +{ + /// Versioned algorithm identifier recorded in every state envelope. + public const string AlgorithmId = "PCG32-v1"; + + private const ulong Multiplier = 6364136223846793005UL; + + private ulong _state; + private readonly ulong _inc; + private ulong _cursor; + + private Pcg32(ulong state, ulong inc, ulong cursor) + { + _state = state; + _inc = inc; + _cursor = cursor; + } + + /// + /// Seeds a fresh stream from the single 128-bit match seed that Module 8 draws from a cryptographic RNG. + /// + /// The split is normative and must never change: the high 64 bits become the reference + /// initstate and the low 64 bits become the reference initseq. Changing the split + /// changes every future draw for a given seed, which is a determinism break — it requires an engine + /// version bump, not an edit. + /// + /// + public static Pcg32 FromMatchSeed(UInt128 matchSeed) + { + var initState = (ulong)(matchSeed >> 64); + var initSeq = (ulong)matchSeed; + return FromSeedParts(initState, initSeq); + } + + /// + /// Seeds from the two reference parameters directly. Applies the published pcg32_srandom_r + /// sequence: zero the state, derive the odd stream selector, step once, add initstate, step again. + /// + public static Pcg32 FromSeedParts(ulong initState, ulong initSeq) + { + var rng = new Pcg32(state: 0UL, inc: (initSeq << 1) | 1UL, cursor: 0UL); + rng.Step(); + rng._state += initState; + rng.Step(); + + // The two seeding steps are part of initialization, not draws the game asked for. + rng._cursor = 0UL; + return rng; + } + + /// Restores a stream from a persisted envelope so a replay continues exactly where it left off. + public static Pcg32 Restore(Pcg32State state) + { + if ((state.Inc & 1UL) == 0UL) + throw new ArgumentException("PCG32 stream selector must be odd; the state is corrupt.", nameof(state)); + + return new Pcg32(state.State, state.Inc, state.Cursor); + } + + /// Captures the current stream position for storage in the server-only state envelope. + public Pcg32State Snapshot() => new(_state, _inc, _cursor); + + /// Number of 32-bit draws taken since seeding. + public ulong Cursor => _cursor; + + /// Draws the next 32-bit value and advances the stream. + public uint NextUInt32() + { + var value = Step(); + _cursor++; + return value; + } + + /// + /// Draws a value in [0, exclusiveBound) with no modulo bias, using the reference + /// pcg32_boundedrand_r rejection loop. The loop is bounded in expectation, not in the worst case, + /// but each iteration consumes a draw, so it terminates with probability 1 and is fully deterministic for + /// a given stream position. + /// + public uint NextBounded(uint exclusiveBound) + { + if (exclusiveBound == 0) + throw new ArgumentOutOfRangeException(nameof(exclusiveBound), "Bound must be positive."); + + // The reference `-bound % bound`, i.e. 2^32 mod bound. Draws below this would be over-represented by + // the final modulo, so they are rejected rather than folded in. + var threshold = unchecked(0u - exclusiveBound) % exclusiveBound; + + while (true) + { + var draw = NextUInt32(); + if (draw >= threshold) + return draw % exclusiveBound; + } + } + + /// Draws a value in [minInclusive, maxExclusive) without bias. + public int NextInt(int minInclusive, int maxExclusive) + { + if (maxExclusive <= minInclusive) + throw new ArgumentOutOfRangeException(nameof(maxExclusive), "maxExclusive must be greater than minInclusive."); + + var range = (uint)((long)maxExclusive - minInclusive); + return (int)(minInclusive + NextBounded(range)); + } + + /// + /// In-place unbiased Fisher-Yates shuffle. The iteration order is fixed and the draws come only from this + /// stream, so the resulting permutation is a pure function of the stream position — which is what makes a + /// shuffled deck reproducible from a golden vector. + /// + public void Shuffle(IList items) + { + ArgumentNullException.ThrowIfNull(items); + + for (var i = items.Count - 1; i > 0; i--) + { + var j = (int)NextBounded((uint)(i + 1)); + (items[i], items[j]) = (items[j], items[i]); + } + } + + /// + /// One iteration of the reference generator: advance the LCG, then apply the XSH-RR output function to the + /// previous state. Does not touch the cursor — owns that, so the two + /// seeding steps are not counted as draws. + /// + private uint Step() + { + var oldState = _state; + _state = unchecked((oldState * Multiplier) + _inc); + + var xorshifted = (uint)(((oldState >> 18) ^ oldState) >> 27); + var rot = (int)(oldState >> 59); + return (xorshifted >> rot) | (xorshifted << ((-rot) & 31)); + } +} diff --git a/src/SimPle.Domain/GameHost/PlayerViewEnvelope.cs b/src/SimPle.Domain/GameHost/PlayerViewEnvelope.cs new file mode 100644 index 0000000..525c3a0 --- /dev/null +++ b/src/SimPle.Domain/GameHost/PlayerViewEnvelope.cs @@ -0,0 +1,93 @@ +namespace SimPle.Domain.GameHost; + +/// +/// The only shape of game state a client is ever allowed to see. There is no default full-state serialization +/// to fall back on: a definition builds each viewer's projection explicitly, so forgetting to redact is a +/// compile-time absence of data rather than a runtime leak. +/// +/// A spectator gets and nothing else — is +/// for them by construction, enforced in . +/// +/// +public sealed class PlayerViewEnvelope +{ + public int Revision { get; } + public ViewerRole ViewerRole { get; } + + /// The seat this view belongs to, or for a spectator. + public int? ViewerSeat { get; } + + /// Data every viewer may see. + public ReadOnlyMemory PublicView { get; } + + /// Data only this seat may see (its hand, its hidden objective). Always null for a spectator. + public ReadOnlyMemory? PrivateView { get; } + + public int ViewSchemaVersion { get; } + + /// Whether the engine considers the match finished, so a client can stop expecting commands. + public EngineState EngineState { get; } + + private PlayerViewEnvelope( + int revision, + ViewerRole viewerRole, + int? viewerSeat, + ReadOnlyMemory publicView, + ReadOnlyMemory? privateView, + int viewSchemaVersion, + EngineState engineState) + { + Revision = revision; + ViewerRole = viewerRole; + ViewerSeat = viewerSeat; + PublicView = publicView; + PrivateView = privateView; + ViewSchemaVersion = viewSchemaVersion; + EngineState = engineState; + } + + public static PlayerViewEnvelope Create( + int revision, + ViewerContext viewer, + ReadOnlySpan publicView, + int viewSchemaVersion, + EngineState engineState, + ReadOnlySpan privateView = default, + bool hasPrivateView = false) + { + if (revision < 0) + throw new ArgumentOutOfRangeException(nameof(revision), revision, "Revision must be non-negative."); + if (viewSchemaVersion <= 0) + throw new ArgumentOutOfRangeException(nameof(viewSchemaVersion), viewSchemaVersion, "ViewSchemaVersion must be positive."); + + if (viewer.IsSpectator) + { + if (hasPrivateView) + throw new ArgumentException("A spectator projection must not carry private data.", nameof(hasPrivateView)); + if (viewer.Seat is not null) + throw new ArgumentException("A spectator has no seat.", nameof(viewer)); + } + else if (viewer.Seat is null) + { + throw new ArgumentException("A player projection requires a seat.", nameof(viewer)); + } + + // Deliberately not a ternary. ReadOnlyMemory has an implicit conversion from byte[], and the null + // literal converts to byte[], so `cond ? memory : null` takes ReadOnlyMemory as its natural type + // and the null branch silently becomes an *empty* memory that then lifts to HasValue = true. A spectator + // would report PrivateView as present-but-empty, and every `is not null` check downstream would read that + // as "this viewer has private data". Absent must mean absent. + ReadOnlyMemory? carriedPrivateView = null; + if (hasPrivateView) + carriedPrivateView = new ReadOnlyMemory(privateView.ToArray()); + + return new PlayerViewEnvelope( + revision, + viewer.Role, + viewer.Seat, + publicView.ToArray(), + carriedPrivateView, + viewSchemaVersion, + engineState); + } +} diff --git a/src/SimPle.Domain/GameHost/TerminalResultCandidate.cs b/src/SimPle.Domain/GameHost/TerminalResultCandidate.cs new file mode 100644 index 0000000..c5501ac --- /dev/null +++ b/src/SimPle.Domain/GameHost/TerminalResultCandidate.cs @@ -0,0 +1,42 @@ +namespace SimPle.Domain.GameHost; + +public enum SeatOutcome +{ + Win = 0, + Loss = 1, + Draw = 2, +} + +/// Zero-based seat index this outcome belongs to. +/// Engine-defined final score. Not a rating — Module 10 owns rating and Elo. +public readonly record struct SeatResult(int Seat, SeatOutcome Outcome, int Score); + +/// +/// The engine's verdict on a terminal state. It is a candidate, not a recorded result: Module 5 is a +/// pure function and claims nothing about "exactly once". Module 8 persists the unique terminal result row and +/// its outbox event; consumers deliver at-least-once and deduplicate. +/// +/// Because EvaluateResult is pure, asking a terminal state for its result twice returns the same +/// candidate — replay is safe by construction rather than by a guard. +/// +/// +public sealed class TerminalResultCandidate +{ + public IReadOnlyList SeatResults { get; } + + private TerminalResultCandidate(IReadOnlyList seatResults) => SeatResults = seatResults; + + public static TerminalResultCandidate Create(IEnumerable seatResults) + { + var results = seatResults.ToList(); + if (results.Count == 0) + throw new ArgumentException("A terminal result must cover at least one seat.", nameof(seatResults)); + + if (results.Select(r => r.Seat).Distinct().Count() != results.Count) + throw new ArgumentException("A seat may not appear twice in a terminal result.", nameof(seatResults)); + + return new TerminalResultCandidate(results); + } + + public bool IsDraw => SeatResults.All(r => r.Outcome == SeatOutcome.Draw); +} diff --git a/tests/SimPle.IntegrationTests/GameHost/GameHostCatalogValidationStartupTests.cs b/tests/SimPle.IntegrationTests/GameHost/GameHostCatalogValidationStartupTests.cs new file mode 100644 index 0000000..bf834f0 --- /dev/null +++ b/tests/SimPle.IntegrationTests/GameHost/GameHostCatalogValidationStartupTests.cs @@ -0,0 +1,83 @@ +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SimPle.Application.GameHost.Services; +using SimPle.Domain.Games; +using SimPle.UnitTests.GameHost.Reference; + +namespace SimPle.IntegrationTests.GameHost; + +/// +/// Program.cs's D1 startup drift check () only runs its +/// database round trip when at least one engine is installed (see the "skipped entirely while zero engines are +/// installed" comment in Program.cs); +/// already covers that zero-engine branch. These tests cover the two branches that only exist once an engine is +/// installed: a matching catalog row lets the host boot, and a drifted one fails the host closed at startup +/// rather than letting a broken engine/catalog pairing serve traffic. Catalog rows must be seeded through a raw +/// pointed at the same InMemory database name — not through +/// the factory's own DI container — because Program.cs's compatibility check runs during host construction, +/// before any test code gets a chance to seed via the usual post-boot DI-resolved context. +/// +public sealed class GameHostCatalogValidationStartupTests +{ + private static HostedGameDefinition ReferenceEngine() => + new(new HiddenTokenDraftDefinition()); + + [Fact] + public void CatalogRowCompatibleWithTheInstalledEngine_HostBootsSuccessfully() + { + using var factory = new GameHostTestWebApplicationFactory(ReferenceEngine()); + SeedGame(factory, HiddenTokenDraftDefinition.Slug, minPlayers: 2, maxPlayers: 4, "multiplayer"); + + var act = () => factory.Services.GetRequiredService(); + + act.Should().NotThrow(); + } + + [Fact] + public void CatalogRowAdvertisesAModeTheInstalledEngineDoesNotSupport_HostStartupFailsClosed() + { + using var factory = new GameHostTestWebApplicationFactory(ReferenceEngine()); + SeedGame(factory, HiddenTokenDraftDefinition.Slug, minPlayers: 2, maxPlayers: 4, "multiplayer", "cooperative"); + + var act = () => factory.Services.GetRequiredService(); + + var thrown = act.Should().Throw().Which; + FullChainText(thrown).Should().Contain( + "compatibility check failed", "an engine/catalog drift must fail the host closed at startup, not serve traffic silently"); + } + + [Fact] + public void CatalogRowAdvertisesANarrowerPlayerRangeThanTheEngineRequires_HostStartupFailsClosed() + { + using var factory = new GameHostTestWebApplicationFactory(ReferenceEngine()); + // The engine requires 2-4 players; a catalog row promising a 5-player match is drift in the other + // direction from the mode case above (an engine capability gap rather than a stricter engine minimum). + SeedGame(factory, HiddenTokenDraftDefinition.Slug, minPlayers: 2, maxPlayers: 5, "multiplayer"); + + var act = () => factory.Services.GetRequiredService(); + + var thrown = act.Should().Throw().Which; + FullChainText(thrown).Should().Contain("compatibility check failed"); + } + + private static void SeedGame( + GameHostTestWebApplicationFactory factory, string slug, int minPlayers, int maxPlayers, params string[] modes) + { + using var db = factory.OpenDbContext(); + db.Games.Add(Game.Create( + slug, "Test Game", "A test game summary.", "Test rules summary.", GameDifficulty.Medium, + 5, 25, minPlayers, maxPlayers, GameLifecycle.Available, null, 0, + "art-token", "#111111", "#222222", "Test game abstract artwork", "2026.1", + "strategy", Array.Empty(), modes)); + db.SaveChanges(); + } + + private static string FullChainText(Exception ex) + { + var sb = new StringBuilder(); + for (var e = ex; e is not null; e = e.InnerException) + sb.AppendLine(e.Message); + return sb.ToString(); + } +} diff --git a/tests/SimPle.IntegrationTests/GameHost/GameHostCompositionRootTests.cs b/tests/SimPle.IntegrationTests/GameHost/GameHostCompositionRootTests.cs new file mode 100644 index 0000000..f51d116 --- /dev/null +++ b/tests/SimPle.IntegrationTests/GameHost/GameHostCompositionRootTests.cs @@ -0,0 +1,107 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SimPle.Application.GameHost.Serialization; +using SimPle.Application.GameHost.Services; +using SimPle.Domain.GameHost; +using SimPle.UnitTests.GameHost.Reference; + +namespace SimPle.IntegrationTests.GameHost; + +/// +/// The "M8 boundary": Module 8 (not yet built) will resolve and +/// from the real SimPle.Api DI container and drive a match through exactly the +/// call sequence exercised here. These tests boot the actual composition root (all of Program.cs's service +/// registrations, options validation, and startup checks — not a hand-rolled service collection) and prove the +/// resolved services round-trip a full match lifecycle for the reference +/// engine. Unit tests already cover every branch of the invoker/adapter in isolation; this suite's job is only to +/// prove the wiring — wrong-lifetime registrations, missing services, DI resolution order — is correct end to end. +/// +public sealed class GameHostCompositionRootTests : IDisposable +{ + private static readonly Guid Seat0User = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000"); + private static readonly Guid Seat1User = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000"); + + private readonly GameHostTestWebApplicationFactory _factory = new( + new HostedGameDefinition( + new HiddenTokenDraftDefinition())); + + [Fact] + public void DefaultCompositionRoot_WithZeroInstalledEngines_ResolvesAnEmptyRegistry() + { + using var defaultFactory = new GameHostTestWebApplicationFactory(); + + var registry = defaultFactory.Services.GetRequiredService(); + + registry.RegisteredDefinitions.Should().BeEmpty("production installs no Phase-2 engine yet"); + } + + [Fact] + public void CompositionRoot_ResolvesGameHostInvokerAndRegistryAsExpectedLifetimesWithoutThrowing() + { + using var scope = _factory.Services.CreateScope(); + + var invoker = scope.ServiceProvider.GetRequiredService(); + var registry = scope.ServiceProvider.GetRequiredService(); + + invoker.Should().NotBeNull(); + registry.RegisteredDefinitions.Should().ContainSingle(m => m.Slug == HiddenTokenDraftDefinition.Slug); + } + + [Fact] + public void FullMatchLifecycle_ThroughTheDIResolvedInvoker_PlaysToATerminalResult() + { + using var scope = _factory.Services.CreateScope(); + var invoker = scope.ServiceProvider.GetRequiredService(); + + var setup = GameSetup.Create( + [new SeatAssignment(0, Seat0User, false), new SeatAssignment(1, Seat1User, false)], + "multiplayer"); + + var createResult = invoker.CreateMatch( + HiddenTokenDraftDefinition.Slug, engineVersion: 1, setup, matchSeed: 42UL, CancellationToken.None); + + createResult.Succeeded.Should().BeTrue(); + var state = createResult.Value!; + state.Revision.Should().Be(0); + + var pass0 = invoker.ApplyCommand(state, PassEnvelope(expectedRevision: 0, actorSeat: 0, Seat0User), CancellationToken.None); + pass0.Accepted.Should().BeTrue(); + + var pass1 = invoker.ApplyCommand(pass0.NextState!, PassEnvelope(expectedRevision: 1, actorSeat: 1, Seat1User), CancellationToken.None); + pass1.Accepted.Should().BeTrue(); + pass1.EngineState.Should().Be(EngineState.Terminal, "a full round of consecutive passes ends the match"); + + var view = invoker.ProjectView(pass1.NextState!, ViewerContext.ForSpectator(), CancellationToken.None); + view.Succeeded.Should().BeTrue(); + view.Value!.EngineState.Should().Be(EngineState.Terminal); + + var result = invoker.EvaluateResult(pass1.NextState!, CancellationToken.None); + result.Succeeded.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.SeatResults.Should().HaveCount(2); + } + + [Fact] + public void CreateMatch_UnknownEngineVersion_ReturnsUnknownVersionThroughTheRealInvoker() + { + using var scope = _factory.Services.CreateScope(); + var invoker = scope.ServiceProvider.GetRequiredService(); + + var setup = GameSetup.Create( + [new SeatAssignment(0, Seat0User, false), new SeatAssignment(1, Seat1User, false)], + "multiplayer"); + + var result = invoker.CreateMatch(HiddenTokenDraftDefinition.Slug, engineVersion: 999, setup, matchSeed: 1UL, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.ErrorCode.Should().Be(EngineErrorCode.UnknownVersion); + } + + private static GameCommandEnvelope PassEnvelope(int expectedRevision, int actorSeat, Guid actorUserId) + { + var payload = GameHostJsonContext.Serialize(new PassTurnCommand()); + return GameCommandEnvelope.Create(Guid.NewGuid(), expectedRevision, actorUserId, actorSeat, "pass", payload); + } + + public void Dispose() => _factory.Dispose(); +} diff --git a/tests/SimPle.IntegrationTests/GameHost/GameHostNoEfDeltaTests.cs b/tests/SimPle.IntegrationTests/GameHost/GameHostNoEfDeltaTests.cs new file mode 100644 index 0000000..aad43e0 --- /dev/null +++ b/tests/SimPle.IntegrationTests/GameHost/GameHostNoEfDeltaTests.cs @@ -0,0 +1,61 @@ +using System.Runtime.CompilerServices; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SimPle.Infrastructure.Persistence; + +namespace SimPle.IntegrationTests.GameHost; + +/// +/// Module 5 is deliberately pure/in-memory: no EF entity, DbSet, or migration of its own (see the module spec's +/// "no-EF-delta" requirement). These tests assert that invariant directly against the real +/// EF model and the committed migrations on disk, so a future change that +/// accidentally adds persistence to the game-host tree fails a test instead of silently drifting from the spec. +/// +public sealed class GameHostNoEfDeltaTests +{ + [Fact] + public void AppDbContextModel_HasNoEntityTypeInTheGameHostNamespace() + { + using var db = new AppDbContext(new DbContextOptionsBuilder().UseInMemoryDatabase("no-ef-delta-model-check").Options); + + var gameHostEntityTypes = db.Model.GetEntityTypes() + .Where(t => t.ClrType.Namespace is not null && t.ClrType.Namespace.Contains("GameHost", StringComparison.Ordinal)) + .Select(t => t.ClrType.FullName) + .ToList(); + + gameHostEntityTypes.Should().BeEmpty("Module 5 is pure/in-memory and must never register an EF entity type"); + } + + [Fact] + public void AppDbContext_ExposesNoGameHostDbSet() + { + var gameHostDbSetProperties = typeof(AppDbContext).GetProperties() + .Where(p => p.PropertyType.IsGenericType && p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) + .Where(p => p.PropertyType.GetGenericArguments()[0].Namespace?.Contains("GameHost", StringComparison.Ordinal) == true) + .Select(p => p.Name) + .ToList(); + + gameHostDbSetProperties.Should().BeEmpty(); + } + + [Fact] + public void MigrationsDirectory_HasNoGameHostMigration() + { + var migrationsDirectory = RepoRelativePath("..", "..", "..", "src", "SimPle.Infrastructure", "Migrations"); + Directory.Exists(migrationsDirectory).Should().BeTrue($"expected the migrations directory at '{migrationsDirectory}'"); + + var migrationFiles = Directory.GetFiles(migrationsDirectory, "*.cs") + .Select(Path.GetFileName) + .Where(name => name is not null && !name.EndsWith("ModelSnapshot.cs", StringComparison.Ordinal)) + .ToList(); + + migrationFiles.Should().NotContain( + name => name!.Contains("GameHost", StringComparison.OrdinalIgnoreCase), + "Module 5 must ship with zero migrations — its state lives only in serialized envelopes, never a table"); + } + + private static string RepoRelativePath(params string[] segments) => + Path.GetFullPath(Path.Combine([Path.GetDirectoryName(SourceFile())!, .. segments])); + + private static string SourceFile([CallerFilePath] string path = "") => path; +} diff --git a/tests/SimPle.IntegrationTests/GameHost/GameHostSerializerHardeningTests.cs b/tests/SimPle.IntegrationTests/GameHost/GameHostSerializerHardeningTests.cs new file mode 100644 index 0000000..411bb5b --- /dev/null +++ b/tests/SimPle.IntegrationTests/GameHost/GameHostSerializerHardeningTests.cs @@ -0,0 +1,75 @@ +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SimPle.Application.GameHost.Serialization; +using SimPle.Application.GameHost.Services; +using SimPle.Domain.GameHost; +using SimPle.UnitTests.GameHost.Reference; + +namespace SimPle.IntegrationTests.GameHost; + +/// +/// 's own XML doc explicitly flags one adversarial shape as owed to "the +/// serializer-hardening test suite": a polymorphic payload whose type-discriminator property is not first in the +/// JSON object. .NET 8's polymorphic deserializer requires the discriminator first and throws otherwise — the +/// behavior AllowOutOfOrderMetadataProperties (added in .NET 9, not available/used here) would relax. This +/// suite proves that failure is caught fail-closed at the real DI-resolved +/// boundary — the actual ingestion path untrusted command bytes travel — not just at the codec in isolation +/// (already covered by GameHostJsonContextTests in the unit suite). +/// +public sealed class GameHostSerializerHardeningTests : IDisposable +{ + private static readonly Guid Seat0User = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000"); + private static readonly Guid Seat1User = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000"); + + private readonly GameHostTestWebApplicationFactory _factory = new( + new HostedGameDefinition( + new HiddenTokenDraftDefinition())); + + [Fact] + public void ApplyCommand_OutOfOrderTypeDiscriminator_IsRejectedNotThrownThroughTheInvoker() + { + using var scope = _factory.Services.CreateScope(); + var invoker = scope.ServiceProvider.GetRequiredService(); + var state = CreateTwoSeatMatch(invoker); + + // A well-formed "draw" command, except the "type" discriminator is the second property rather than the + // first — the exact adversarial shape .NET 8's strict (in-order-only) polymorphic reader must reject. + var outOfOrderPayload = Encoding.UTF8.GetBytes("""{"decoy":1,"type":"draw"}"""); + var envelope = GameCommandEnvelope.Create(Guid.NewGuid(), expectedRevision: 0, Seat0User, actorSeat: 0, "draw", outOfOrderPayload); + + var transition = invoker.ApplyCommand(state, envelope, CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.InvalidCommandType.ToStableCode()); + transition.NextRevision.Should().Be(transition.PriorRevision); + } + + [Fact] + public void ApplyCommand_WellFormedInOrderDiscriminator_IsAcceptedForComparison() + { + using var scope = _factory.Services.CreateScope(); + var invoker = scope.ServiceProvider.GetRequiredService(); + var state = CreateTwoSeatMatch(invoker); + + var payload = GameHostJsonContext.Serialize(new DrawTokenCommand()); + var envelope = GameCommandEnvelope.Create(Guid.NewGuid(), expectedRevision: 0, Seat0User, actorSeat: 0, "draw", payload); + + var transition = invoker.ApplyCommand(state, envelope, CancellationToken.None); + + transition.Accepted.Should().BeTrue("a correctly-ordered discriminator must not be caught by the hardening check"); + } + + private GameStateEnvelope CreateTwoSeatMatch(IGameHostInvoker invoker) + { + var setup = GameSetup.Create( + [new SeatAssignment(0, Seat0User, false), new SeatAssignment(1, Seat1User, false)], + "multiplayer"); + + var result = invoker.CreateMatch(HiddenTokenDraftDefinition.Slug, engineVersion: 1, setup, matchSeed: 7UL, CancellationToken.None); + result.Succeeded.Should().BeTrue(); + return result.Value!; + } + + public void Dispose() => _factory.Dispose(); +} diff --git a/tests/SimPle.IntegrationTests/GameHost/GameHostTestWebApplicationFactory.cs b/tests/SimPle.IntegrationTests/GameHost/GameHostTestWebApplicationFactory.cs new file mode 100644 index 0000000..b253556 --- /dev/null +++ b/tests/SimPle.IntegrationTests/GameHost/GameHostTestWebApplicationFactory.cs @@ -0,0 +1,134 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SimPle.Application.Auth.DTOs; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.GameHost.Services; +using SimPle.Domain.GameHost; +using SimPle.Infrastructure.Persistence; + +namespace SimPle.IntegrationTests.GameHost; + +/// +/// Boots the real SimPle.Api composition root — the same Program.cs every other integration suite +/// exercises — with two Module-5-specific overrides: an pinned to an isolated +/// InMemory database this test controls the name of (so a test can seed catalog rows before the host's startup +/// scope reads them), and an optional replacement standing in for the "zero engines +/// installed" production default. Everything else (JWT/Recaptcha option validation, other DbContext-dependent +/// services, etc.) is configured exactly as configures it, since the +/// same startup path runs regardless of which module's tests are booting it. +/// +public sealed class GameHostTestWebApplicationFactory : WebApplicationFactory +{ + /// + /// The EF Core InMemory provider shares one process-wide store per database name when no explicit + /// InMemoryDatabaseRoot is supplied, regardless of which service provider created the + /// . Seeding a database with this name via a context built outside the factory's DI + /// container — before touching — is therefore + /// visible to Program.cs's own startup catalog-compatibility scope, which runs before any test code can reach + /// into the DI-resolved context to seed it the usual (post-boot) way. + /// + public string DatabaseName { get; } = "gamehost-tests-" + Guid.NewGuid(); + + private readonly IReadOnlyList _engines; + + public GameHostTestWebApplicationFactory(params IHostedGameDefinition[] engines) + { + _engines = engines; + } + + /// Opens a context bound to , independent of this factory's DI container. + public AppDbContext OpenDbContext() => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(DatabaseName).Options); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Jwt:SecretKey"] = "integration-tests-use-only-this-secret-value-123456", + ["Jwt:Issuer"] = "SimPle.Tests", + ["Jwt:Audience"] = "SimPle.Tests", + ["Jwt:ExpiryMinutes"] = "15", + ["Auth:RefreshTokenExpiryDays"] = "7", + ["Auth:MaxFailedLoginAttempts"] = "10", + ["Auth:LockoutDurationMinutes"] = "15", + ["Recaptcha:SecretKey"] = "integration-tests-recaptcha-secret", + ["Recaptcha:VerificationUrl"] = "https://captcha.invalid/siteverify", + ["Email:From"] = "test@example.com", + ["Email:FromName"] = "SimPle Tests", + ["Email:SmtpHost"] = "smtp.example.com", + ["Email:SmtpPort"] = "587", + ["Email:Username"] = "test@example.com", + ["Email:Password"] = "test-password", + ["Email:AppUrl"] = "http://localhost:3000", + ["Google:ClientId"] = "integration-tests-google-client-id", + ["ConnectionStrings:DefaultConnection"] = "unused-for-in-memory-tests", + }); + }); + builder.ConfigureServices(services => + { + services.RemoveAll>(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.AddDbContext(options => options.UseInMemoryDatabase(DatabaseName)); + services.AddSingleton(new AlwaysSucceedsCaptchaService()); + services.AddSingleton(new DiscardingEmailService()); + services.AddSingleton(new NeverValidatesGoogleService()); + services.AddSingleton(new NullFileStorageService()); + + // Production installs zero engines (see Program.cs); tests override with fakes/references to exercise + // the "at least one engine installed" paths that the default composition root cannot reach today. + services.RemoveAll(); + services.AddSingleton(_ => GameRegistry.Create(_engines)); + }); + } + + private sealed class AlwaysSucceedsCaptchaService : ICaptchaVerificationService + { + public Task VerifyAsync(string responseToken, string? remoteIpAddress, CancellationToken ct = default) => + Task.FromResult(true); + } + + private sealed class DiscardingEmailService : IEmailService + { + public Task SendVerificationEmailAsync(string toEmail, string toName, string verificationUrl, CancellationToken ct = default) => + Task.CompletedTask; + + public Task SendWelcomeEmailAsync(string toEmail, string toName, CancellationToken ct = default) => + Task.CompletedTask; + + public Task SendPasswordResetEmailAsync(string toEmail, string toName, string resetUrl, CancellationToken ct = default) => + Task.CompletedTask; + + public Task SendPasswordChangedEmailAsync(string toEmail, string toName, CancellationToken ct = default) => + Task.CompletedTask; + } + + private sealed class NeverValidatesGoogleService : IGoogleTokenValidationService + { + public Task ValidateAsync(string idToken, CancellationToken ct = default) => + Task.FromResult(null); + } + + private sealed class NullFileStorageService : IFileStorageService + { + public Task CreatePresignedPutUrlAsync(string objectKey, string contentType, TimeSpan expiresIn, CancellationToken ct = default) => + Task.FromResult($"https://s3-upload.test/{Uri.EscapeDataString(objectKey)}"); + + public Task CreatePresignedReadUrlAsync(string objectKey, TimeSpan expiresIn, CancellationToken ct = default) => + Task.FromResult($"https://s3-read.test/{Uri.EscapeDataString(objectKey)}"); + + public Task ObjectExistsAsync(string objectKey, CancellationToken ct = default) => Task.FromResult(false); + + public Task DeleteObjectAsync(string objectKey, CancellationToken ct = default) => Task.CompletedTask; + } +} diff --git a/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj b/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj index bffce66..af10372 100644 --- a/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj +++ b/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj @@ -27,6 +27,11 @@ + + + diff --git a/tests/SimPle.UnitTests/GameHost/Benchmark/GameHostBenchmark.cs b/tests/SimPle.UnitTests/GameHost/Benchmark/GameHostBenchmark.cs new file mode 100644 index 0000000..dcc7fe1 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/Benchmark/GameHostBenchmark.cs @@ -0,0 +1,134 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using SimPle.Application.GameHost.Serialization; +using SimPle.Application.GameHost.Services; +using SimPle.Domain.GameHost; +using SimPle.UnitTests.GameHost.Reference; + +namespace SimPle.UnitTests.GameHost.Benchmark; + +/// +/// The D3 benchmark deviation: a minimal in-repo measurement of the fixed reference +/// workload (10,000 accepted commands after warm-up), instead of a BenchmarkDotNet dependency. Runs +/// matches back-to-back through HostedGameDefinition.ApplyCommand +/// — the same call path Module 8 will drive in production — timing each accepted command individually. +/// +public static class GameHostBenchmark +{ + public const int WarmupCommands = 500; + public const int MeasuredCommands = 10_000; + + public sealed class BenchmarkResult + { + public double P50Ms { get; init; } + public double P95Ms { get; init; } + public double P99Ms { get; init; } + public double MaxMs { get; init; } + public long AllocatedBytes { get; init; } + public int MaxSerializedStateBytes { get; init; } + public int MaxSerializedCommandBytes { get; init; } + public int MeasuredCommandCount { get; init; } + public string Environment { get; init; } = ""; + + /// Acceptance: p95 < 25 ms. + public bool MeetsP95Budget => P95Ms < 25.0; + + /// Acceptance: no command exceeds the 100 ms soft execution budget. + public bool NoCommandOverSoftBudget => MaxMs <= EngineLimits.SoftExecutionBudget.TotalMilliseconds; + } + + public static BenchmarkResult Run() + { + RunWorkload(WarmupCommands, samples: null, out _, out _); + + var samples = new List(MeasuredCommands); + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + RunWorkload(MeasuredCommands, samples, out var maxStateBytes, out var maxCommandBytes); + var allocatedAfter = GC.GetAllocatedBytesForCurrentThread(); + + samples.Sort(); + + return new BenchmarkResult + { + P50Ms = Percentile(samples, 0.50), + P95Ms = Percentile(samples, 0.95), + P99Ms = Percentile(samples, 0.99), + MaxMs = samples[^1], + AllocatedBytes = allocatedAfter - allocatedBefore, + MaxSerializedStateBytes = maxStateBytes, + MaxSerializedCommandBytes = maxCommandBytes, + MeasuredCommandCount = samples.Count, + Environment = + $"{RuntimeInformation.FrameworkDescription}; {RuntimeInformation.OSDescription}; " + + $"{RuntimeInformation.ProcessArchitecture}; ProcessorCount={System.Environment.ProcessorCount}", + }; + } + + /// + /// Plays fixed-seat matches back-to-back, always drawing (the more expensive path — deserialize state, + /// draw from the RNG, mutate hand, reserialize, checksum), until commands + /// have been accepted. Not seeded for cross-process reproducibility: unlike the golden vectors, the + /// benchmark's numeric results are environment-dependent by nature — only the pass/fail thresholds matter. + /// + private static void RunWorkload(int commandCount, List? samples, out int maxStateBytes, out int maxCommandBytes) + { + var hosted = new HostedGameDefinition( + new HiddenTokenDraftDefinition()); + + maxStateBytes = 0; + maxCommandBytes = 0; + var issued = 0; + + while (issued < commandCount) + { + var seats = new[] + { + new SeatAssignment(0, Guid.NewGuid(), false), + new SeatAssignment(1, Guid.NewGuid(), false), + new SeatAssignment(2, Guid.NewGuid(), false), + new SeatAssignment(3, Guid.NewGuid(), false), + }; + var setup = GameSetup.Create(seats, "multiplayer"); + var matchSeed = ((UInt128)(ulong)Random.Shared.NextInt64() << 64) | (ulong)Random.Shared.NextInt64(); + var state = hosted.CreateInitialState(setup, matchSeed, CancellationToken.None); + + var seat = 0; + var expectedRevision = 0; + + while (issued < commandCount) + { + var command = new DrawTokenCommand(); + var payload = GameHostJsonContext.Serialize(command); + maxCommandBytes = Math.Max(maxCommandBytes, payload.Length); + + var envelope = GameCommandEnvelope.Create( + Guid.NewGuid(), expectedRevision, seats[seat].UserId!.Value, seat, command.CommandType, payload); + + var stopwatch = Stopwatch.StartNew(); + var transition = hosted.ApplyCommand(state, envelope, CancellationToken.None); + stopwatch.Stop(); + + if (!transition.Accepted) + break; // Deck exhausted mid-round under this always-draw driver; start a fresh match. + + samples?.Add(stopwatch.Elapsed.TotalMilliseconds); + issued++; + maxStateBytes = Math.Max(maxStateBytes, transition.NextState!.StateBytes.Length); + + state = transition.NextState!; + expectedRevision = transition.NextRevision; + seat = (seat + 1) % seats.Length; + + if (transition.EngineState == EngineState.Terminal) + break; + } + } + } + + private static double Percentile(List sortedSamples, double percentile) + { + var index = (int)Math.Ceiling(percentile * sortedSamples.Count) - 1; + index = Math.Clamp(index, 0, sortedSamples.Count - 1); + return sortedSamples[index]; + } +} diff --git a/tests/SimPle.UnitTests/GameHost/Benchmark/GameHostBenchmarkTests.cs b/tests/SimPle.UnitTests/GameHost/Benchmark/GameHostBenchmarkTests.cs new file mode 100644 index 0000000..414e5d4 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/Benchmark/GameHostBenchmarkTests.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using FluentAssertions; + +namespace SimPle.UnitTests.GameHost.Benchmark; + +/// +/// Runs the D3 Stopwatch benchmark once and asserts its acceptance criteria: p95 < 25 ms, and no single +/// command exceeds the 100 ms EngineLimits.SoftExecutionBudget. Results are also written to a local +/// evidence artifact (mirrors 's pattern) — +/// the canonical cross-repo checkpoint recording happens separately, outside the test run. +/// +public class GameHostBenchmarkTests +{ + public static string ArtifactPath => Path.Combine(AppContext.BaseDirectory, "gamehost-benchmark.generated.json"); + + [Fact] + public void ReferenceWorkload_MeetsLatencyAndBudgetAcceptanceCriteria() + { + var result = GameHostBenchmark.Run(); + + File.WriteAllText( + ArtifactPath, + JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + + result.MeasuredCommandCount.Should().Be(GameHostBenchmark.MeasuredCommands); + result.MeetsP95Budget.Should().BeTrue($"p95 was {result.P95Ms:F3} ms, acceptance is < 25 ms"); + result.NoCommandOverSoftBudget.Should().BeTrue( + $"max observed command latency was {result.MaxMs:F3} ms, acceptance is <= 100 ms (EngineLimits.SoftExecutionBudget)"); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/CatalogEngineCompatibilityValidatorTests.cs b/tests/SimPle.UnitTests/GameHost/CatalogEngineCompatibilityValidatorTests.cs new file mode 100644 index 0000000..2950aa2 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/CatalogEngineCompatibilityValidatorTests.cs @@ -0,0 +1,106 @@ +using FluentAssertions; +using SimPle.Application.GameHost.Services; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost; + +/// +/// The startup drift check (D1): a registered engine and its Module 4 catalog row are compared independently +/// via , not M4's real entity, so the zero/matching/mismatched matrix here +/// stays fixture-only. +/// +public sealed class CatalogEngineCompatibilityValidatorTests +{ + private readonly CatalogEngineCompatibilityValidator _validator = new(); + + private static GameDefinitionMetadata Metadata( + string slug, int minPlayers, int maxPlayers, params string[] modes) => GameDefinitionMetadata.Create( + slug: slug, engineVersion: 1, stateSchemaVersion: 1, minPlayers: minPlayers, maxPlayers: maxPlayers, supportedModes: modes); + + private static CatalogGameSnapshot Catalog(string slug, int minPlayers, int maxPlayers, params string[] modes) => + CatalogGameSnapshot.Create(slug, minPlayers, maxPlayers, modes); + + [Fact] + public void Validate_NoCatalogRowMatchesTheEngine_ProducesNoViolation() + { + var violations = _validator.Validate( + [Metadata("hidden-token-draft", 2, 4, "multiplayer")], + []); + + violations.Should().BeEmpty(); + } + + [Fact] + public void Validate_NoEngineMatchesTheCatalogRow_ProducesNoViolation() + { + var violations = _validator.Validate( + [], + [Catalog("some-catalog-only-game", 2, 4, "multiplayer")]); + + violations.Should().BeEmpty(); + } + + [Fact] + public void Validate_MatchedPairWithCompatibleBoundsAndModes_ProducesNoViolation() + { + var violations = _validator.Validate( + [Metadata("hidden-token-draft", 2, 4, "multiplayer", "ranked")], + [Catalog("hidden-token-draft", 2, 4, "multiplayer")]); + + violations.Should().BeEmpty(); + } + + [Fact] + public void Validate_EngineSupportsFewerPlayersThanCatalogAdvertises_ProducesAViolation() + { + var violations = _validator.Validate( + [Metadata("hidden-token-draft", 2, 3, "multiplayer")], + [Catalog("hidden-token-draft", 2, 4, "multiplayer")]); + + violations.Should().ContainSingle(v => v.Slug == "hidden-token-draft"); + } + + [Fact] + public void Validate_EngineRequiresMorePlayersThanCatalogMinimum_ProducesAViolation() + { + var violations = _validator.Validate( + [Metadata("hidden-token-draft", 3, 4, "multiplayer")], + [Catalog("hidden-token-draft", 2, 4, "multiplayer")]); + + violations.Should().ContainSingle(v => v.Slug == "hidden-token-draft"); + } + + [Fact] + public void Validate_CatalogAdvertisesAModeTheEngineDoesNotSupport_ProducesAViolation() + { + var violations = _validator.Validate( + [Metadata("hidden-token-draft", 2, 4, "multiplayer")], + [Catalog("hidden-token-draft", 2, 4, "multiplayer", "cooperative")]); + + violations.Should().ContainSingle(v => v.Slug == "hidden-token-draft" && v.Reason.Contains("cooperative")); + } + + [Fact] + public void Validate_EngineSupportsMoreModesThanCatalogAdvertises_ProducesNoViolation() + { + // An engine capable of more than the catalog currently advertises is not drift — only the reverse is. + var violations = _validator.Validate( + [Metadata("hidden-token-draft", 2, 4, "multiplayer", "cooperative")], + [Catalog("hidden-token-draft", 2, 4, "multiplayer")]); + + violations.Should().BeEmpty(); + } + + [Fact] + public void Validate_MultipleRegisteredEngineVersionsForTheSameSlug_EachComparedAgainstTheSameCatalogRow() + { + var violations = _validator.Validate( + [ + Metadata("hidden-token-draft", 2, 4, "multiplayer"), + Metadata("hidden-token-draft", 1, 2, "multiplayer"), + ], + [Catalog("hidden-token-draft", 2, 4, "multiplayer")]); + + violations.Should().ContainSingle(v => v.Reason.Contains("1-2")); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/EngineErrorCodeTests.cs b/tests/SimPle.UnitTests/GameHost/EngineErrorCodeTests.cs new file mode 100644 index 0000000..c1e1897 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/EngineErrorCodeTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost; + +/// +/// The 13 Engine.* strings are a published contract shared with Module 8 and, through it, with clients. +/// The enum member names are free to be refactored; the strings are not. This test is the thing that makes +/// that distinction real. +/// +public sealed class EngineErrorCodeTests +{ + [Theory] + [InlineData(EngineErrorCode.UnknownGame, "Engine.UnknownGame")] + [InlineData(EngineErrorCode.UnknownVersion, "Engine.UnknownVersion")] + [InlineData(EngineErrorCode.UnsupportedStateVersion, "Engine.UnsupportedStateVersion")] + [InlineData(EngineErrorCode.CorruptState, "Engine.CorruptState")] + [InlineData(EngineErrorCode.InvalidCommandType, "Engine.InvalidCommandType")] + [InlineData(EngineErrorCode.InvalidCommand, "Engine.InvalidCommand")] + [InlineData(EngineErrorCode.IllegalActor, "Engine.IllegalActor")] + [InlineData(EngineErrorCode.StaleRevision, "Engine.StaleRevision")] + [InlineData(EngineErrorCode.PayloadTooLarge, "Engine.PayloadTooLarge")] + [InlineData(EngineErrorCode.StateTooLarge, "Engine.StateTooLarge")] + [InlineData(EngineErrorCode.Cancelled, "Engine.Cancelled")] + [InlineData(EngineErrorCode.ExecutionBudgetExceeded, "Engine.ExecutionBudgetExceeded")] + [InlineData(EngineErrorCode.PluginFailure, "Engine.PluginFailure")] + public void ToStableCode_ReturnsThePublishedWireString(EngineErrorCode code, string expected) + { + code.ToStableCode().Should().Be(expected); + } + + [Fact] + public void EveryDeclaredCode_HasAStableMapping() + { + // Guards the gap the Theory above cannot see: a 14th code added to the enum without a mapping would + // otherwise only blow up at runtime, inside a rejection path, in production. + var declared = Enum.GetValues(); + + declared.Should().HaveCount(13); + foreach (var code in declared) + code.ToStableCode().Should().StartWith("Engine."); + } + + [Fact] + public void ToStableCode_ForAnUndeclaredValue_Throws() + { + var bogus = (EngineErrorCode)999; + + var map = () => bogus.ToStableCode(); + + map.Should().Throw(); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/EngineLimitsTests.cs b/tests/SimPle.UnitTests/GameHost/EngineLimitsTests.cs new file mode 100644 index 0000000..06e3335 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/EngineLimitsTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost; + +/// +/// These caps are a security control, not a tuning knob: they are what stops a payload bomb or an algorithmic +/// exhaustion attack at the host boundary. Pinning each value here means a change cannot be slipped in as an +/// incidental edit — it has to break a test, which forces the benchmark/security evidence and ADR the brief +/// requires. +/// +public sealed class EngineLimitsTests +{ + [Fact] + public void SizeCaps_MatchThePublishedHardDefaults() + { + EngineLimits.MaxCommandPayloadBytes.Should().Be(16 * 1024); + EngineLimits.MaxSerializedStateBytes.Should().Be(256 * 1024); + EngineLimits.MaxPlayerViewBytes.Should().Be(256 * 1024); + EngineLimits.MaxGameEventBatchBytes.Should().Be(64 * 1024); + } + + [Fact] + public void CountCaps_MatchThePublishedHardDefaults() + { + EngineLimits.MinPlayers.Should().Be(1); + EngineLimits.MaxPlayers.Should().Be(8); + EngineLimits.MaxEmittedEventsPerCommand.Should().Be(128); + } + + [Fact] + public void ExecutionBudget_MatchesThePublishedHardDefaults() + { + EngineLimits.SoftExecutionBudget.Should().Be(TimeSpan.FromMilliseconds(100)); + EngineLimits.CancellationRequestThreshold.Should().Be(TimeSpan.FromMilliseconds(500)); + EngineLimits.CooperativeReturnGrace.Should().Be(TimeSpan.FromMilliseconds(50)); + } + + [Fact] + public void ExecutionBudget_IsOrderedSoftThenCancelThenGrace() + { + // The three thresholds only make sense as an escalation; an ordering mistake would make the host either + // cancel work that is still within budget, or never cancel at all. + EngineLimits.SoftExecutionBudget.Should().BeLessThan(EngineLimits.CancellationRequestThreshold); + EngineLimits.CooperativeReturnGrace.Should().BeGreaterThan(TimeSpan.Zero); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/EngineTransitionTests.cs b/tests/SimPle.UnitTests/GameHost/EngineTransitionTests.cs new file mode 100644 index 0000000..822d064 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/EngineTransitionTests.cs @@ -0,0 +1,199 @@ +using System.Text; +using FluentAssertions; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost; + +/// +/// The central safety property of a rejection: it must be indistinguishable from a command that was never +/// issued. No next state, no events, no revision bump — and therefore, at the host, no RNG advance. A partially +/// applied rejection would leak a mutated board or a drawn card through a move the rules refused. +/// +public sealed class EngineTransitionTests +{ + private sealed class FakeState + { + public int Turn { get; init; } + } + + private static GameStateEnvelope Envelope(int revision) => GameStateEnvelope.Create( + gameSlug: "hidden-token-draft", + engineVersion: 1, + stateSchemaVersion: 1, + revision: revision, + rngState: Pcg32.FromSeedParts(42UL, 54UL).Snapshot(), + stateBytes: Encoding.UTF8.GetBytes($$"""{"turn":{{revision}}}""")); + + // ── EngineTransition (non-generic, the shape Module 8 consumes) ────────────────────────────────────── + + [Fact] + public void Reject_CarriesNoStateNoEventsAndDoesNotAdvanceTheRevision() + { + var transition = EngineTransition.Reject(priorRevision: 7, EngineErrorCode.IllegalActor, "not your turn"); + + transition.Accepted.Should().BeFalse(); + transition.NextState.Should().BeNull("a rejection must never hand back state, hidden or otherwise"); + transition.PublicEvents.Should().BeEmpty(); + transition.PrivateEvents.Should().BeEmpty(); + transition.NextRevision.Should().Be(7, "a rejected command leaves the match exactly where it was"); + transition.PriorRevision.Should().Be(7); + transition.TerminalResult.Should().BeNull(); + transition.RejectionCode.Should().Be("Engine.IllegalActor"); + transition.RejectionDetail.Should().Be("not your turn"); + } + + [Fact] + public void Accept_AdvancesTheRevisionByExactlyOne() + { + var transition = EngineTransition.Accept(priorRevision: 3, Envelope(4), [], terminalResult: null); + + transition.Accepted.Should().BeTrue(); + transition.PriorRevision.Should().Be(3); + transition.NextRevision.Should().Be(4); + transition.NextState.Should().NotBeNull(); + transition.EngineState.Should().Be(EngineState.InProgress); + } + + [Fact] + public void Accept_WithAStateThatSkipsARevision_Throws() + { + // A revision that jumps is either a lost command or a replayed one. Both are bugs the host must not + // paper over, because the revision is what makes stale-command detection work at all. + var accept = () => EngineTransition.Accept(priorRevision: 3, Envelope(5), [], terminalResult: null); + + accept.Should().Throw().WithMessage("*exactly one*"); + } + + [Fact] + public void Accept_PartitionsEventsIntoPublicAndPrivateBatches() + { + // Visibility is decided by the definition, not the transport. Getting this partition wrong is precisely + // how a hidden card ends up broadcast to the table. + var events = new[] + { + GameEvent.Public("TurnEnded", 1), + GameEvent.Private("TokenDrawn", 1, targetSeat: 2), + GameEvent.Public("CountChanged", 1), + }; + + var transition = EngineTransition.Accept(priorRevision: 0, Envelope(1), events, terminalResult: null); + + transition.PublicEvents.Should().HaveCount(2); + transition.PrivateEvents.Should().ContainSingle() + .Which.TargetSeat.Should().Be(2); + } + + [Fact] + public void Accept_WithATerminalResult_ReportsTerminalEngineState() + { + var result = TerminalResultCandidate.Create([ + new SeatResult(0, SeatOutcome.Win, 10), + new SeatResult(1, SeatOutcome.Loss, 4), + ]); + + var transition = EngineTransition.Accept(priorRevision: 8, Envelope(9), [], result); + + transition.EngineState.Should().Be(EngineState.Terminal); + transition.TerminalResult.Should().BeSameAs(result); + } + + [Fact] + public void Accept_WithMoreThanTheEventCap_Throws() + { + var tooMany = Enumerable + .Range(0, EngineLimits.MaxEmittedEventsPerCommand + 1) + .Select(_ => GameEvent.Public("Noise", 1)) + .ToList(); + + var accept = () => EngineTransition.Accept(priorRevision: 0, Envelope(1), tooMany, terminalResult: null); + + accept.Should().Throw(); + } + + // ── EngineDecision (typed, what a definition returns) ──────────────────────────────────────── + + [Fact] + public void Decision_Reject_CarriesNoNextStateAndNoEvents() + { + var decision = EngineDecision.Reject(EngineErrorCode.InvalidCommand, "no such move"); + + decision.Accepted.Should().BeFalse(); + decision.NextState.Should().BeNull(); + decision.Events.Should().BeEmpty(); + decision.TerminalResult.Should().BeNull(); + decision.RejectionCode.Should().Be(EngineErrorCode.InvalidCommand); + decision.EngineState.Should().Be(EngineState.InProgress); + } + + [Fact] + public void Decision_Accept_CarriesTheNewStateAndInfersEngineState() + { + var decision = EngineDecision.Accept(new FakeState { Turn = 1 }); + + decision.Accepted.Should().BeTrue(); + decision.NextState!.Turn.Should().Be(1); + decision.EngineState.Should().Be(EngineState.InProgress); + } + + [Fact] + public void Decision_AcceptWithTerminalResult_InfersTerminalEngineState() + { + var result = TerminalResultCandidate.Create([new SeatResult(0, SeatOutcome.Draw, 0)]); + + var decision = EngineDecision.Accept(new FakeState { Turn = 9 }, terminalResult: result); + + decision.EngineState.Should().Be(EngineState.Terminal); + decision.TerminalResult.Should().BeSameAs(result); + } + + [Fact] + public void Decision_AcceptWithMoreThanTheEventCap_Throws() + { + var tooMany = Enumerable + .Range(0, EngineLimits.MaxEmittedEventsPerCommand + 1) + .Select(_ => GameEvent.Public("Noise", 1)) + .ToList(); + + var accept = () => EngineDecision.Accept(new FakeState(), tooMany); + + accept.Should().Throw(); + } + + // ── TerminalResultCandidate ────────────────────────────────────────────────────────────────────────── + + [Fact] + public void TerminalResult_WithADuplicateSeat_Throws() + { + var create = () => TerminalResultCandidate.Create([ + new SeatResult(0, SeatOutcome.Win, 10), + new SeatResult(0, SeatOutcome.Loss, 2), + ]); + + create.Should().Throw().WithMessage("*twice*"); + } + + [Fact] + public void TerminalResult_WithNoSeats_Throws() + { + var create = () => TerminalResultCandidate.Create([]); + + create.Should().Throw(); + } + + [Fact] + public void TerminalResult_IsDraw_OnlyWhenEverySeatDrew() + { + var drawn = TerminalResultCandidate.Create([ + new SeatResult(0, SeatOutcome.Draw, 5), + new SeatResult(1, SeatOutcome.Draw, 5), + ]); + + var decided = TerminalResultCandidate.Create([ + new SeatResult(0, SeatOutcome.Win, 6), + new SeatResult(1, SeatOutcome.Draw, 5), + ]); + + drawn.IsDraw.Should().BeTrue(); + decided.IsDraw.Should().BeFalse(); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/GameCommandEnvelopeTests.cs b/tests/SimPle.UnitTests/GameHost/GameCommandEnvelopeTests.cs new file mode 100644 index 0000000..c00d0dd --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GameCommandEnvelopeTests.cs @@ -0,0 +1,105 @@ +using System.Text; +using FluentAssertions; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost; + +/// +/// The command envelope is where client input meets server authority. The actor and seat are bound by Module 8 +/// from authenticated membership and live outside the payload, so a payload claiming a different actor +/// is structurally unable to influence anything — that is the anti-spoofing design, and these tests pin it. +/// +public sealed class GameCommandEnvelopeTests +{ + private static GameCommandEnvelope Create( + Guid? commandId = null, + int expectedRevision = 0, + Guid? actorUserId = null, + int actorSeat = 0, + string commandType = "Draw", + byte[]? payload = null) => + GameCommandEnvelope.Create( + commandId ?? Guid.NewGuid(), + expectedRevision, + actorUserId ?? Guid.NewGuid(), + actorSeat, + commandType, + payload ?? Encoding.UTF8.GetBytes("{}")); + + [Fact] + public void Create_CarriesTheServerBoundActorOutsideThePayload() + { + var actor = Guid.NewGuid(); + + var envelope = Create(actorUserId: actor, actorSeat: 3); + + envelope.ActorUserId.Should().Be(actor); + envelope.ActorSeat.Should().Be(3); + } + + [Fact] + public void Create_WithAnOversizedPayload_DoesNotThrow() + { + // Deliberate: size is a host-boundary concern that must surface as a typed Engine.PayloadTooLarge + // rejection, not as an exception the host then has to catch and translate. The envelope stays a dumb + // carrier so the invoker (slice 5B) owns that decision in one place. + var oversized = new byte[EngineLimits.MaxCommandPayloadBytes + 1]; + + var envelope = Create(payload: oversized); + + envelope.PayloadBytes.Length.Should().Be(EngineLimits.MaxCommandPayloadBytes + 1); + } + + [Fact] + public void Create_CopiesTheCallersPayloadBuffer() + { + var buffer = Encoding.UTF8.GetBytes("""{"token":"A"}"""); + var envelope = Create(payload: buffer); + + buffer[2] = (byte)'X'; + + envelope.PayloadBytes.ToArray().Should().NotEqual(buffer); + } + + [Fact] + public void Create_WithAnEmptyCommandId_Throws() + { + var create = () => Create(commandId: Guid.Empty); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithAnEmptyActorUserId_Throws() + { + var create = () => Create(actorUserId: Guid.Empty); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithANegativeSeat_Throws() + { + var create = () => Create(actorSeat: -1); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithANegativeExpectedRevision_Throws() + { + var create = () => Create(expectedRevision: -1); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithAnEmptyCommandType_Throws() + { + // The command type is a stable allow-listed discriminator, never a CLR type name. An empty one would + // mean the codec had nothing to resolve against. + var create = () => Create(commandType: " "); + + create.Should().Throw(); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/GameDefinitionMetadataTests.cs b/tests/SimPle.UnitTests/GameHost/GameDefinitionMetadataTests.cs new file mode 100644 index 0000000..01e3561 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GameDefinitionMetadataTests.cs @@ -0,0 +1,129 @@ +using FluentAssertions; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost; + +/// +/// Metadata is what the registry keys on and what the Module 4 catalog is reconciled against, so a definition +/// that declares an impossible shape (zero players, a nine-seat table, an unknown mode) must be impossible to +/// construct — not merely rejected later at startup. +/// +public sealed class GameDefinitionMetadataTests +{ + private static GameDefinitionMetadata Create( + int engineVersion = 1, + int stateSchemaVersion = 1, + int minPlayers = 2, + int maxPlayers = 4, + IEnumerable? modes = null, + bool supportsAi = false, + bool supportsRanked = false) => + GameDefinitionMetadata.Create( + slug: "hidden-token-draft", + engineVersion: engineVersion, + stateSchemaVersion: stateSchemaVersion, + minPlayers: minPlayers, + maxPlayers: maxPlayers, + supportedModes: modes ?? ["multiplayer"], + supportsAi: supportsAi, + supportsRanked: supportsRanked); + + [Fact] + public void Create_WithAValidShape_ExposesTheRegistryKey() + { + var metadata = Create(); + + metadata.Slug.Should().Be("hidden-token-draft"); + metadata.EngineVersion.Should().Be(1); + metadata.ToString().Should().Be("hidden-token-draft@v1", "the (slug, engineVersion) pair is the registry key"); + metadata.SupportsDeterministicReplay.Should().BeTrue("determinism is the default expectation, not an opt-in"); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Create_WithNonPositiveEngineVersion_Throws(int engineVersion) + { + var create = () => Create(engineVersion: engineVersion); + + create.Should().Throw(); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Create_WithNonPositiveStateSchemaVersion_Throws(int stateSchemaVersion) + { + var create = () => Create(stateSchemaVersion: stateSchemaVersion); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithFewerThanOnePlayer_Throws() + { + var create = () => Create(minPlayers: 0, maxPlayers: 4); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithMoreThanEightPlayers_Throws() + { + // Eight is a hard host cap, not a suggestion: it bounds every per-seat projection the host must build. + var create = () => Create(minPlayers: 2, maxPlayers: 9); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithMinPlayersAboveMaxPlayers_Throws() + { + var create = () => Create(minPlayers: 4, maxPlayers: 2); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithNoModes_Throws() + { + var create = () => Create(modes: []); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithAModeOutsideTheCatalogAllowList_Throws() + { + // The engine and the Module 4 catalog row are compared mode-for-mode by the compatibility validator, so + // an engine inventing its own vocabulary would fail that comparison in a confusing way. Fail here first. + var create = () => Create(modes: ["battle-royale"]); + + create.Should().Throw().WithMessage("*allow-list*"); + } + + [Fact] + public void Create_DeclaringRankedCapabilityWithoutTheRankedMode_Throws() + { + var create = () => Create(modes: ["multiplayer"], supportsRanked: true); + + create.Should().Throw(); + } + + [Fact] + public void Create_DeclaringTheAiModeWithoutTheAiCapability_Throws() + { + var create = () => Create(modes: ["multiplayer", "ai"], supportsAi: false); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithRankedModeAndCapability_Succeeds() + { + var metadata = Create(modes: ["multiplayer", "ranked"], supportsRanked: true); + + metadata.SupportedModes.Should().BeEquivalentTo(["multiplayer", "ranked"]); + metadata.SupportsRanked.Should().BeTrue(); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/GameHostContextsTests.cs b/tests/SimPle.UnitTests/GameHost/GameHostContextsTests.cs new file mode 100644 index 0000000..c74c4c9 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GameHostContextsTests.cs @@ -0,0 +1,112 @@ +using FluentAssertions; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost; + +/// +/// Seats are the definition's only notion of "who". If the seat set could contain a gap, a duplicate, or the +/// same user twice, a command would be able to target an ambiguous seat — so the shape is validated before any +/// engine code sees it. +/// +public sealed class GameHostContextsTests +{ + private static SeatAssignment Human(int seat) => new(seat, Guid.NewGuid(), IsAi: false); + + [Fact] + public void GameSetup_WithAContiguousSeatRange_Succeeds() + { + var setup = GameSetup.Create([Human(0), Human(1), Human(2)], "multiplayer"); + + setup.Seats.Should().HaveCount(3); + setup.Mode.Should().Be("multiplayer"); + } + + [Fact] + public void GameSetup_WithASeatGap_Throws() + { + var setup = () => GameSetup.Create([Human(0), Human(2)], "multiplayer"); + + setup.Should().Throw().WithMessage("*contiguous*"); + } + + [Fact] + public void GameSetup_WithADuplicateSeat_Throws() + { + var setup = () => GameSetup.Create([Human(0), Human(0)], "multiplayer"); + + setup.Should().Throw().WithMessage("*contiguous*"); + } + + [Fact] + public void GameSetup_WithTheSameUserInTwoSeats_Throws() + { + // Otherwise one account could act for two seats and see both hands — a hidden-information break dressed + // up as a seating mistake. + var userId = Guid.NewGuid(); + + var setup = () => GameSetup.Create( + [new SeatAssignment(0, userId, false), new SeatAssignment(1, userId, false)], + "multiplayer"); + + setup.Should().Throw().WithMessage("*two seats*"); + } + + [Fact] + public void GameSetup_AllowsMultipleAiSeatsWithNoUserId() + { + var setup = GameSetup.Create( + [Human(0), new SeatAssignment(1, null, true), new SeatAssignment(2, null, true)], + "ai"); + + setup.Seats.Count(s => s.IsAi).Should().Be(2); + } + + [Fact] + public void GameSetup_WithNoSeats_Throws() + { + var setup = () => GameSetup.Create([], "solo"); + + setup.Should().Throw(); + } + + [Fact] + public void GameSetup_WithMoreThanEightSeats_Throws() + { + var seats = Enumerable.Range(0, 9).Select(Human).ToList(); + + var setup = () => GameSetup.Create(seats, "multiplayer"); + + setup.Should().Throw(); + } + + [Fact] + public void CommandContext_IsStale_WhenTheExpectedRevisionIsNotCurrent() + { + var current = new CommandContext(Guid.NewGuid(), Guid.NewGuid(), 0, ExpectedRevision: 4, CurrentRevision: 4); + var stale = new CommandContext(Guid.NewGuid(), Guid.NewGuid(), 0, ExpectedRevision: 3, CurrentRevision: 4); + + current.IsStale.Should().BeFalse(); + stale.IsStale.Should().BeTrue(); + } + + [Fact] + public void ViewerContext_ForSpectator_HasNoSeat() + { + var spectator = ViewerContext.ForSpectator(); + + spectator.IsSpectator.Should().BeTrue(); + spectator.Seat.Should().BeNull("a spectator holds no seat and is entitled to no seat-private data"); + } + + [Fact] + public void ViewerContext_ForPlayer_CarriesTheSeatAndAccount() + { + var userId = Guid.NewGuid(); + + var player = ViewerContext.ForPlayer(seat: 2, userId: userId); + + player.IsSpectator.Should().BeFalse(); + player.Seat.Should().Be(2); + player.UserId.Should().Be(userId); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/GameHostInvokerTests.cs b/tests/SimPle.UnitTests/GameHost/GameHostInvokerTests.cs new file mode 100644 index 0000000..f7ad174 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GameHostInvokerTests.cs @@ -0,0 +1,298 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using SimPle.Application.GameHost.Serialization; +using SimPle.Application.GameHost.Services; +using SimPle.Domain.GameHost; +using SimPle.UnitTests.GameHost.Support; + +namespace SimPle.UnitTests.GameHost; + +/// +/// is the host boundary every call crosses: resolve, check inbound size, run +/// under the cooperative-cancellation watchdog, check outbound size, normalize every failure to a stable +/// . Each of those steps gets its own fault-injected case here via +/// , since the real reference engine cannot be made to hang, throw, or +/// overflow a size budget on demand. +/// +public sealed class GameHostInvokerTests +{ + private const string Slug = "hidden-token-draft"; + private static readonly Guid AnyUser = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid AnotherUser = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Pcg32State AnyRngState = Pcg32.FromSeedParts(1UL, 2UL).Snapshot(); + + private static GameDefinitionMetadata Metadata(string slug = Slug, int engineVersion = 1) => GameDefinitionMetadata.Create( + slug: slug, engineVersion: engineVersion, stateSchemaVersion: 1, minPlayers: 2, maxPlayers: 4, supportedModes: ["multiplayer"]); + + private static GameSetup TwoSeatSetup() => GameSetup.Create( + [new SeatAssignment(0, AnyUser, false), new SeatAssignment(1, AnotherUser, false)], "multiplayer"); + + private static GameStateEnvelope StateEnvelope(string slug = Slug, int engineVersion = 1, int stateBytesLength = 16, int revision = 0) => + GameStateEnvelope.Create(slug, engineVersion, stateSchemaVersion: 1, revision, AnyRngState, new byte[stateBytesLength]); + + private static GameCommandEnvelope CommandEnvelope(int payloadLength = 16, int expectedRevision = 0) => + GameCommandEnvelope.Create(Guid.NewGuid(), expectedRevision, AnyUser, actorSeat: 0, "draw", new byte[payloadLength]); + + private static GameHostInvoker InvokerWith(params FakeHostedGameDefinition[] definitions) => + new(GameRegistry.Create(definitions), NullLogger.Instance); + + // ── Resolution ────────────────────────────────────────────────────────── + + [Fact] + public void CreateMatch_UnknownSlug_ReturnsUnknownGame() + { + var invoker = InvokerWith(new FakeHostedGameDefinition(Metadata())); + + var result = invoker.CreateMatch("no-such-game", 1, TwoSeatSetup(), 0, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.ErrorCode.Should().Be(EngineErrorCode.UnknownGame); + } + + [Fact] + public void CreateMatch_KnownSlugButUnregisteredEngineVersion_ReturnsUnknownVersionNotUnknownGame() + { + var invoker = InvokerWith(new FakeHostedGameDefinition(Metadata(engineVersion: 1))); + + var result = invoker.CreateMatch(Slug, 99, TwoSeatSetup(), 0, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.ErrorCode.Should().Be(EngineErrorCode.UnknownVersion); + } + + [Fact] + public void CreateMatch_Success_ReturnsTheProducedEnvelope() + { + var envelope = StateEnvelope(); + var fake = new FakeHostedGameDefinition(Metadata()) { OnCreateInitialState = (_, _, _) => envelope }; + var invoker = InvokerWith(fake); + + var result = invoker.CreateMatch(Slug, 1, TwoSeatSetup(), 0, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + result.Value.Should().BeSameAs(envelope); + } + + [Fact] + public void CreateMatch_OversizedState_ReturnsStateTooLarge() + { + var tooBig = StateEnvelope(stateBytesLength: EngineLimits.MaxSerializedStateBytes + 1); + var fake = new FakeHostedGameDefinition(Metadata()) { OnCreateInitialState = (_, _, _) => tooBig }; + var invoker = InvokerWith(fake); + + var result = invoker.CreateMatch(Slug, 1, TwoSeatSetup(), 0, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.ErrorCode.Should().Be(EngineErrorCode.StateTooLarge); + } + + // ── ApplyCommand ──────────────────────────────────────────────────────── + + [Fact] + public void ApplyCommand_UnregisteredGameSlugOnTheStateEnvelope_RejectsUnknownGame() + { + var invoker = InvokerWith(new FakeHostedGameDefinition(Metadata())); + var state = StateEnvelope(slug: "no-such-game"); + + var transition = invoker.ApplyCommand(state, CommandEnvelope(), CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.UnknownGame.ToStableCode()); + } + + [Fact] + public void ApplyCommand_OversizedPayload_RejectsPayloadTooLargeWithoutCallingTheDefinition() + { + var called = false; + var fake = new FakeHostedGameDefinition(Metadata()) { OnApplyCommand = (_, _, _) => { called = true; throw new InvalidOperationException(); } }; + var invoker = InvokerWith(fake); + var command = CommandEnvelope(payloadLength: EngineLimits.MaxCommandPayloadBytes + 1); + + var transition = invoker.ApplyCommand(StateEnvelope(), command, CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.PayloadTooLarge.ToStableCode()); + called.Should().BeFalse("an oversized payload must be rejected before the definition is ever invoked"); + } + + [Fact] + public void ApplyCommand_DefinitionThrowsGameHostSerializationException_MapsToItsCarriedCode() + { + var fake = new FakeHostedGameDefinition(Metadata()) + { + OnApplyCommand = (_, _, _) => throw new GameHostSerializationException(EngineErrorCode.CorruptState, "bad state"), + }; + var invoker = InvokerWith(fake); + + var transition = invoker.ApplyCommand(StateEnvelope(), CommandEnvelope(), CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.CorruptState.ToStableCode()); + } + + [Fact] + public void ApplyCommand_DefinitionThrowsAnUnexpectedException_MapsToPluginFailureWithoutLeakingExceptionText() + { + var fake = new FakeHostedGameDefinition(Metadata()) + { + OnApplyCommand = (_, _, _) => throw new InvalidOperationException("some internal engine bug, never seen by a client"), + }; + var invoker = InvokerWith(fake); + + var transition = invoker.ApplyCommand(StateEnvelope(), CommandEnvelope(), CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.PluginFailure.ToStableCode()); + transition.RejectionDetail.Should().NotContain("some internal engine bug"); + } + + [Fact] + public void ApplyCommand_CallerAlreadyCancelled_ReturnsCancelledWithoutInvokingTheDefinition() + { + var called = false; + var fake = new FakeHostedGameDefinition(Metadata()) { OnApplyCommand = (_, _, _) => { called = true; throw new InvalidOperationException(); } }; + var invoker = InvokerWith(fake); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var transition = invoker.ApplyCommand(StateEnvelope(), CommandEnvelope(), cts.Token); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.Cancelled.ToStableCode()); + called.Should().BeFalse(); + } + + [Fact] + public void ApplyCommand_DefinitionExceedsTheCooperativeCancellationBudget_ReturnsExecutionBudgetExceeded() + { + var fake = new FakeHostedGameDefinition(Metadata()) + { + // Ignores the token entirely, standing in for a definition that has leaked/blocked a thread; the + // invoker must stop waiting once CancellationRequestThreshold + CooperativeReturnGrace elapses + // rather than blocking the caller indefinitely. + OnApplyCommand = (state, _, _) => + { + Thread.Sleep(2000); + return EngineTransition.Reject(state.Revision, EngineErrorCode.InvalidCommand); + }, + }; + var invoker = InvokerWith(fake); + + var transition = invoker.ApplyCommand(StateEnvelope(), CommandEnvelope(), CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.ExecutionBudgetExceeded.ToStableCode()); + } + + [Fact] + public void ApplyCommand_AcceptedButOversizedNextState_RejectsStateTooLarge() + { + var tooBigNext = StateEnvelope(stateBytesLength: EngineLimits.MaxSerializedStateBytes + 1, revision: 1); + var fake = new FakeHostedGameDefinition(Metadata()) + { + OnApplyCommand = (state, _, _) => EngineTransition.Accept(state.Revision, tooBigNext, [], null), + }; + var invoker = InvokerWith(fake); + + var transition = invoker.ApplyCommand(StateEnvelope(), CommandEnvelope(), CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.StateTooLarge.ToStableCode()); + } + + [Fact] + public void ApplyCommand_AcceptedButOversizedEventBatch_RejectsPluginFailure() + { + var nextState = StateEnvelope(revision: 1); + var oversizedEvent = GameEvent.Public("some-event", schemaVersion: 1, new byte[EngineLimits.MaxGameEventBatchBytes + 1]); + var fake = new FakeHostedGameDefinition(Metadata()) + { + OnApplyCommand = (state, _, _) => EngineTransition.Accept(state.Revision, nextState, [oversizedEvent], null), + }; + var invoker = InvokerWith(fake); + + var transition = invoker.ApplyCommand(StateEnvelope(), CommandEnvelope(), CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.PluginFailure.ToStableCode()); + } + + [Fact] + public void ApplyCommand_AcceptedWithinBudget_PassesTheTransitionThrough() + { + var nextState = StateEnvelope(revision: 1); + var fake = new FakeHostedGameDefinition(Metadata()) + { + OnApplyCommand = (state, _, _) => EngineTransition.Accept(state.Revision, nextState, [], null), + }; + var invoker = InvokerWith(fake); + + var transition = invoker.ApplyCommand(StateEnvelope(), CommandEnvelope(), CancellationToken.None); + + transition.Accepted.Should().BeTrue(); + transition.NextState.Should().BeSameAs(nextState); + } + + // ── ProjectView / EvaluateResult ──────────────────────────────────────── + + [Fact] + public void ProjectView_UnknownGame_ReturnsUnknownGame() + { + var invoker = InvokerWith(new FakeHostedGameDefinition(Metadata())); + + var result = invoker.ProjectView(StateEnvelope(slug: "no-such-game"), ViewerContext.ForSpectator(), CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.ErrorCode.Should().Be(EngineErrorCode.UnknownGame); + } + + [Fact] + public void ProjectView_OversizedView_ReturnsPluginFailure() + { + var oversizedView = PlayerViewEnvelope.Create( + 0, ViewerContext.ForSpectator(), new byte[EngineLimits.MaxPlayerViewBytes + 1], viewSchemaVersion: 1, EngineState.InProgress); + var fake = new FakeHostedGameDefinition(Metadata()) { OnProjectView = (_, _, _) => oversizedView }; + var invoker = InvokerWith(fake); + + var result = invoker.ProjectView(StateEnvelope(), ViewerContext.ForSpectator(), CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.ErrorCode.Should().Be(EngineErrorCode.PluginFailure); + } + + [Fact] + public void ProjectView_WithinBudget_ReturnsTheProducedView() + { + var view = PlayerViewEnvelope.Create(0, ViewerContext.ForSpectator(), new byte[8], viewSchemaVersion: 1, EngineState.InProgress); + var fake = new FakeHostedGameDefinition(Metadata()) { OnProjectView = (_, _, _) => view }; + var invoker = InvokerWith(fake); + + var result = invoker.ProjectView(StateEnvelope(), ViewerContext.ForSpectator(), CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + result.Value.Should().BeSameAs(view); + } + + [Fact] + public void EvaluateResult_UnknownGame_ReturnsUnknownGame() + { + var invoker = InvokerWith(new FakeHostedGameDefinition(Metadata())); + + var result = invoker.EvaluateResult(StateEnvelope(slug: "no-such-game"), CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.ErrorCode.Should().Be(EngineErrorCode.UnknownGame); + } + + [Fact] + public void EvaluateResult_DelegatesToTheDefinitionAndReturnsItsCandidate() + { + var fake = new FakeHostedGameDefinition(Metadata()) { OnEvaluateResult = (_, _) => null }; + var invoker = InvokerWith(fake); + + var result = invoker.EvaluateResult(StateEnvelope(), CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + result.Value.Should().BeNull(); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/GameHostJsonContextTests.cs b/tests/SimPle.UnitTests/GameHost/GameHostJsonContextTests.cs new file mode 100644 index 0000000..ae58952 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GameHostJsonContextTests.cs @@ -0,0 +1,134 @@ +using System.Text; +using FluentAssertions; +using SimPle.Application.GameHost.Serialization; +using SimPle.Domain.GameHost; +using SimPle.UnitTests.GameHost.Reference; + +namespace SimPle.UnitTests.GameHost; + +/// +/// The pinned codec is the fail-closed boundary between untrusted bytes and typed game state/commands: every +/// malformed-input case here must surface as with the caller's +/// requested , never a raw . +/// +public sealed class GameHostJsonContextTests +{ + [Fact] + public void Serialize_ThenDeserialize_RoundTripsAConcreteType() + { + var state = new HiddenTokenDraftState + { + SeatCount = 3, + CurrentSeat = 1, + ConsecutivePasses = 0, + DeckTokens = [1, 2, 3], + Hands = [[], [], []], + }; + + var bytes = GameHostJsonContext.Serialize(state); + var roundTripped = GameHostJsonContext.Deserialize(bytes, EngineErrorCode.CorruptState); + + roundTripped.SeatCount.Should().Be(3); + roundTripped.CurrentSeat.Should().Be(1); + roundTripped.DeckTokens.Should().Equal(1, 2, 3); + } + + [Fact] + public void Serialize_UsesCamelCasePropertyNames() + { + var state = new HiddenTokenDraftState + { + SeatCount = 2, + CurrentSeat = 0, + ConsecutivePasses = 0, + DeckTokens = [], + Hands = [[], []], + }; + + var json = Encoding.UTF8.GetString(GameHostJsonContext.Serialize(state)); + + json.Should().Contain("\"seatCount\""); + json.Should().NotContain("\"SeatCount\""); + } + + [Fact] + public void Deserialize_MalformedJson_ThrowsWithRequestedErrorCode() + { + var bytes = "{ this is not valid json"u8.ToArray(); + + var act = () => GameHostJsonContext.Deserialize(bytes, EngineErrorCode.CorruptState); + + act.Should().Throw().Which.Code.Should().Be(EngineErrorCode.CorruptState); + } + + [Fact] + public void Deserialize_UnmappedMember_IsRejectedRatherThanIgnored() + { + var json = """{"seatCount":2,"currentSeat":0,"consecutivePasses":0,"deckTokens":[],"hands":[[],[]],"unexpectedField":1}"""; + + var act = () => GameHostJsonContext.Deserialize(Encoding.UTF8.GetBytes(json), EngineErrorCode.CorruptState); + + act.Should().Throw().Which.Code.Should().Be(EngineErrorCode.CorruptState); + } + + [Fact] + public void Deserialize_NullLiteral_ThrowsWithRequestedErrorCode() + { + var bytes = "null"u8.ToArray(); + + var act = () => GameHostJsonContext.Deserialize(bytes, EngineErrorCode.CorruptState); + + act.Should().Throw().Which.Code.Should().Be(EngineErrorCode.CorruptState); + } + + [Fact] + public void Deserialize_NumberAsString_IsRejectedUnderStrictNumberHandling() + { + // NumberHandling.Strict disallows a quoted number for an int property. + var json = """{"seatCount":"2","currentSeat":0,"consecutivePasses":0,"deckTokens":[],"hands":[[],[]]}"""; + + var act = () => GameHostJsonContext.Deserialize(Encoding.UTF8.GetBytes(json), EngineErrorCode.CorruptState); + + act.Should().Throw().Which.Code.Should().Be(EngineErrorCode.CorruptState); + } + + [Fact] + public void Deserialize_UnknownPolymorphicDiscriminator_MapsToRequestedErrorCode() + { + var json = """{"type":"not-a-real-command"}"""; + + var act = () => GameHostJsonContext.Deserialize(Encoding.UTF8.GetBytes(json), EngineErrorCode.InvalidCommandType); + + act.Should().Throw().Which.Code.Should().Be(EngineErrorCode.InvalidCommandType); + } + + [Fact] + public void Serialize_PolymorphicCommand_WritesTheDeclaredTypeDiscriminator() + { + var bytes = GameHostJsonContext.Serialize(new DrawTokenCommand()); + var json = Encoding.UTF8.GetString(bytes); + + json.Should().Contain("\"type\":\"draw\""); + } + + [Fact] + public void Deserialize_PolymorphicCommand_ResolvesToTheConcreteDerivedType() + { + var bytes = GameHostJsonContext.Serialize(new PassTurnCommand()); + + var result = GameHostJsonContext.Deserialize(bytes, EngineErrorCode.InvalidCommandType); + + result.Should().BeOfType(); + result.CommandType.Should().Be("pass"); + } + + [Fact] + public void Deserialize_TrailingGarbageAfterValidJson_IsRejected() + { + var json = """{"seatCount":2,"currentSeat":0,"consecutivePasses":0,"deckTokens":[],"hands":[[],[]]} garbage"""; + + var act = () => GameHostJsonContext.Deserialize(Encoding.UTF8.GetBytes(json), EngineErrorCode.CorruptState); + + act.Should().Throw().Which.Code.Should().Be(EngineErrorCode.CorruptState); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/GameRegistryTests.cs b/tests/SimPle.UnitTests/GameHost/GameRegistryTests.cs new file mode 100644 index 0000000..4a45cc1 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GameRegistryTests.cs @@ -0,0 +1,101 @@ +using FluentAssertions; +using SimPle.Application.GameHost.Services; +using SimPle.Domain.GameHost; +using SimPle.UnitTests.GameHost.Support; + +namespace SimPle.UnitTests.GameHost; + +/// +/// The registry is a fixed, startup-built lookup table keyed by (Slug, EngineVersion): no runtime +/// registration, no "latest version" fallback, and a duplicate key must fail startup rather than silently +/// pick a winner. +/// +public sealed class GameRegistryTests +{ + private static GameDefinitionMetadata Metadata(string slug, int engineVersion) => GameDefinitionMetadata.Create( + slug: slug, + engineVersion: engineVersion, + stateSchemaVersion: 1, + minPlayers: 2, + maxPlayers: 4, + supportedModes: ["multiplayer"]); + + private static FakeHostedGameDefinition Fake(string slug, int engineVersion) => + new(Metadata(slug, engineVersion)); + + [Fact] + public void Create_WithDistinctSlugVersionPairs_RegistersEveryDefinition() + { + var a = Fake("hidden-token-draft", 1); + var b = Fake("hidden-token-draft", 2); + var c = Fake("some-other-game", 1); + + var registry = GameRegistry.Create([a, b, c]); + + registry.RegisteredDefinitions.Should().HaveCount(3); + } + + [Fact] + public void Create_WithDuplicateSlugAndEngineVersion_ThrowsInvalidOperationException() + { + var first = Fake("hidden-token-draft", 1); + var duplicate = Fake("hidden-token-draft", 1); + + var act = () => GameRegistry.Create([first, duplicate]); + + act.Should().Throw(); + } + + [Fact] + public void Create_WithSameSlugButDifferentEngineVersions_DoesNotThrow() + { + var v1 = Fake("hidden-token-draft", 1); + var v2 = Fake("hidden-token-draft", 2); + + var act = () => GameRegistry.Create([v1, v2]); + + act.Should().NotThrow(); + } + + [Fact] + public void TryResolve_ReturnsTheExactRegisteredDefinition() + { + var definition = Fake("hidden-token-draft", 1); + var registry = GameRegistry.Create([definition]); + + var resolved = registry.TryResolve("hidden-token-draft", 1, out var found); + + resolved.Should().BeTrue(); + found.Should().BeSameAs(definition); + } + + [Fact] + public void TryResolve_UnknownSlug_ReturnsFalse() + { + var registry = GameRegistry.Create([Fake("hidden-token-draft", 1)]); + + var resolved = registry.TryResolve("no-such-game", 1, out var found); + + resolved.Should().BeFalse(); + found.Should().BeNull(); + } + + [Fact] + public void TryResolve_KnownSlugButDifferentEngineVersion_DoesNotFallBack() + { + var registry = GameRegistry.Create([Fake("hidden-token-draft", 1)]); + + var resolved = registry.TryResolve("hidden-token-draft", 2, out var found); + + resolved.Should().BeFalse(); + found.Should().BeNull(); + } + + [Fact] + public void RegisteredDefinitions_ExposesMetadataForEveryInstalledDefinition() + { + var registry = GameRegistry.Create([Fake("hidden-token-draft", 1), Fake("some-other-game", 1)]); + + registry.RegisteredDefinitions.Select(m => m.Slug).Should().BeEquivalentTo(["hidden-token-draft", "some-other-game"]); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/GameStateEnvelopeTests.cs b/tests/SimPle.UnitTests/GameHost/GameStateEnvelopeTests.cs new file mode 100644 index 0000000..09f0d5c --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GameStateEnvelopeTests.cs @@ -0,0 +1,106 @@ +using System.Security.Cryptography; +using System.Text; +using FluentAssertions; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost; + +/// +/// The state envelope is the authoritative, server-only record of a match. Two properties matter here: the +/// checksum genuinely covers the exact bytes (so corruption in storage or transport is detectable), and the +/// envelope cannot be mutated out from under its own checksum. +/// +public sealed class GameStateEnvelopeTests +{ + private static readonly Pcg32State AnyRngState = Pcg32.FromSeedParts(42UL, 54UL).Snapshot(); + + private static GameStateEnvelope Create(byte[] stateBytes, int revision = 0) => GameStateEnvelope.Create( + gameSlug: "hidden-token-draft", + engineVersion: 1, + stateSchemaVersion: 1, + revision: revision, + rngState: AnyRngState, + stateBytes: stateBytes); + + [Fact] + public void Create_ComputesSha256OverTheExactStateBytes() + { + var bytes = Encoding.UTF8.GetBytes("""{"turn":0}"""); + var expected = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + var envelope = Create(bytes); + + envelope.Checksum.Should().Be(expected); + envelope.ChecksumMatches().Should().BeTrue(); + } + + [Fact] + public void Checksum_ChangesWhenASingleByteChanges() + { + var a = Create(Encoding.UTF8.GetBytes("""{"turn":0}""")); + var b = Create(Encoding.UTF8.GetBytes("""{"turn":1}""")); + + b.Checksum.Should().NotBe(a.Checksum); + } + + [Fact] + public void Create_CopiesTheCallersBuffer_SoLaterMutationCannotDesyncTheChecksum() + { + // Without the defensive copy, a caller reusing a pooled buffer would silently invalidate the checksum + // of an envelope it had already handed off — and the corruption would surface as a fail-closed error on + // some later replay, far from the cause. + var buffer = Encoding.UTF8.GetBytes("""{"turn":0}"""); + var envelope = Create(buffer); + + buffer[2] = (byte)'X'; + + envelope.ChecksumMatches().Should().BeTrue("the envelope must own its bytes"); + envelope.StateBytes.ToArray().Should().NotEqual(buffer); + } + + [Fact] + public void RngState_IsCarried_ForReplayButIsServerOnly() + { + // The presence of the RNG state here is exactly why this envelope must never be handed to a client: + // it makes every future draw predictable. The redaction boundary is PlayerViewEnvelope, not this type. + var envelope = Create(Encoding.UTF8.GetBytes("{}")); + + envelope.RngState.Should().Be(AnyRngState); + envelope.RngAlgorithm.Should().Be(Pcg32.AlgorithmId); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Create_WithNonPositiveEngineVersion_Throws(int engineVersion) + { + var create = () => GameStateEnvelope.Create( + "slug", engineVersion, 1, 0, AnyRngState, ReadOnlySpan.Empty); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithNonPositiveStateSchemaVersion_Throws() + { + var create = () => GameStateEnvelope.Create("slug", 1, 0, 0, AnyRngState, ReadOnlySpan.Empty); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithNegativeRevision_Throws() + { + var create = () => GameStateEnvelope.Create("slug", 1, 1, -1, AnyRngState, ReadOnlySpan.Empty); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithEmptySlug_Throws() + { + var create = () => GameStateEnvelope.Create(" ", 1, 1, 0, AnyRngState, ReadOnlySpan.Empty); + + create.Should().Throw(); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorDto.cs b/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorDto.cs new file mode 100644 index 0000000..72b9710 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorDto.cs @@ -0,0 +1,66 @@ +namespace SimPle.UnitTests.GameHost.GoldenVectors; + +/// +/// Plain data shapes for the committed golden-vector JSON files. Deliberately a separate, plain +/// System.Text.Json shape from GameHostJsonContext — that codec serializes a definition's own +/// state/command/view payload bytes under the pinned game-host options; these types describe the surrounding +/// envelope metadata for a human/CI reviewer and any future non-C# port, so they carry no dependency on the +/// production codec's fail-closed configuration. +/// +public sealed class GoldenEnvelopeVector +{ + public string GameSlug { get; set; } = ""; + public int EngineVersion { get; set; } + public int StateSchemaVersion { get; set; } + public int Revision { get; set; } + public string RngAlgorithm { get; set; } = ""; + public ulong RngState { get; set; } + public ulong RngInc { get; set; } + public ulong RngCursor { get; set; } + public string StateBytesBase64 { get; set; } = ""; + public string ChecksumSha256Hex { get; set; } = ""; +} + +public sealed class GoldenTransitionVector +{ + public bool Accepted { get; set; } + public int PriorRevision { get; set; } + public int NextRevision { get; set; } + public string? RejectionCode { get; set; } + public string? RejectionDetail { get; set; } + public GoldenEnvelopeVector? NextState { get; set; } + public List PublicEventTypes { get; set; } = new(); + public List PrivateEventTypes { get; set; } = new(); + public string EngineState { get; set; } = ""; +} + +public sealed class GoldenViewVector +{ + public string ViewerRole { get; set; } = ""; + public int? ViewerSeat { get; set; } + public int Revision { get; set; } + public string PublicViewBase64 { get; set; } = ""; + public bool HasPrivateView { get; set; } + public string? PrivateViewBase64 { get; set; } + public string EngineState { get; set; } = ""; +} + +public sealed class GoldenSeatResultVector +{ + public int Seat { get; set; } + public string Outcome { get; set; } = ""; + public int Score { get; set; } +} + +public sealed class GoldenTerminalResultVector +{ + public List SeatResults { get; set; } = new(); +} + +/// A fail-closed scenario: a deliberately invalid envelope paired with the error code it must produce. +public sealed class GoldenFailureVector +{ + public string Scenario { get; set; } = ""; + public GoldenEnvelopeVector Envelope { get; set; } = new(); + public string ExpectedErrorCode { get; set; } = ""; +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorGenerator.cs b/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorGenerator.cs new file mode 100644 index 0000000..debe35a --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorGenerator.cs @@ -0,0 +1,39 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace SimPle.UnitTests.GameHost.GoldenVectors; + +/// +/// One-shot generator, run manually to (re)materialize the committed golden-vector JSON files from +/// . Not a test — is the +/// permanent, read-only comparison that runs every build. Updating a vector this way is itself the "engine/ +/// schema version bump or documented bug-fix ADR" the spec requires before a vector may change. +/// +public static class GoldenVectorGenerator +{ + public static void Regenerate() + { + var directory = SourceDirectory(); + var vectors = HiddenTokenDraftGoldenVectorScenario.Build(); + + Write(directory, "initial-envelope.json", vectors.InitialEnvelope); + Write(directory, "accepted-command.json", vectors.AcceptedCommand); + Write(directory, "rejected-command.json", vectors.RejectedCommand); + Write(directory, "view-seat-0.json", vectors.ViewSeat0); + Write(directory, "view-seat-1.json", vectors.ViewSeat1); + Write(directory, "view-seat-2.json", vectors.ViewSeat2); + Write(directory, "view-spectator.json", vectors.ViewSpectator); + Write(directory, "terminal-result.json", vectors.TerminalResult); + Write(directory, "corrupt-checksum.json", vectors.CorruptChecksum); + Write(directory, "unsupported-version.json", vectors.UnsupportedVersion); + } + + private static void Write(string directory, string fileName, T value) + { + var json = JsonSerializer.Serialize(value, GoldenVectorJson.Options); + File.WriteAllText(Path.Combine(directory, fileName), json + "\n"); + } + + private static string SourceDirectory([CallerFilePath] string sourceFile = "") => + Path.GetDirectoryName(sourceFile)!; +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorJson.cs b/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorJson.cs new file mode 100644 index 0000000..d4468d1 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorJson.cs @@ -0,0 +1,13 @@ +using System.Text.Json; + +namespace SimPle.UnitTests.GameHost.GoldenVectors; + +/// Plain, human-diffable JSON options for the committed golden-vector files. Not the game-host codec. +internal static class GoldenVectorJson +{ + public static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorManifestTests.cs b/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorManifestTests.cs new file mode 100644 index 0000000..5a4c861 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorManifestTests.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using FluentAssertions; + +namespace SimPle.UnitTests.GameHost.GoldenVectors; + +/// +/// Produces the golden-vector SHA-256 manifest the module-05 spec's two-process determinism proof compares. +/// The manifest itself is not the proof — running the filtered GameHost suite in two separate +/// dotnet test processes and diffing between the two runs is (recorded at the +/// verification checkpoint, per deviation D3). This test only guarantees the manifest is written every run and +/// is itself a deterministic function of . +/// +public class GoldenVectorManifestTests +{ + public static string ManifestPath => Path.Combine(AppContext.BaseDirectory, "golden-vector-manifest.generated.json"); + + [Fact] + public void Manifest_IsWrittenAndSha256HashesAreStableAcrossRebuilds() + { + var vectors = HiddenTokenDraftGoldenVectorScenario.Build(); + + var manifest = new SortedDictionary(StringComparer.Ordinal) + { + ["initial-envelope"] = Hash(vectors.InitialEnvelope), + ["accepted-command"] = Hash(vectors.AcceptedCommand), + ["rejected-command"] = Hash(vectors.RejectedCommand), + ["view-seat-0"] = Hash(vectors.ViewSeat0), + ["view-seat-1"] = Hash(vectors.ViewSeat1), + ["view-seat-2"] = Hash(vectors.ViewSeat2), + ["view-spectator"] = Hash(vectors.ViewSpectator), + ["terminal-result"] = Hash(vectors.TerminalResult), + ["corrupt-checksum"] = Hash(vectors.CorruptChecksum), + ["unsupported-version"] = Hash(vectors.UnsupportedVersion), + }; + + var manifestJson = JsonSerializer.Serialize(manifest, GoldenVectorJson.Options); + File.WriteAllText(ManifestPath, manifestJson); + + // Recomputing in the same process must reproduce byte-identical hashes — the in-process half of the + // determinism guarantee. The cross-process half is proven by diffing this file between two separate + // `dotnet test --filter "FullyQualifiedName~GameHost"` runs at the verification checkpoint. + var recomputed = HiddenTokenDraftGoldenVectorScenario.Build(); + Hash(recomputed.InitialEnvelope).Should().Be(manifest["initial-envelope"]); + Hash(recomputed.TerminalResult).Should().Be(manifest["terminal-result"]); + } + + private static string Hash(T value) + { + var json = JsonSerializer.Serialize(value, GoldenVectorJson.Options); + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorTests.cs b/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorTests.cs new file mode 100644 index 0000000..aa8e123 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using FluentAssertions; +using SimPle.Application.GameHost.Serialization; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost.GoldenVectors; + +/// +/// Read-only comparison against the committed golden-vector JSON files. This suite never writes: a vector +/// changing under it means the engine's byte-level output changed, which the spec treats as a compatibility +/// break requiring an engine/schema version bump or a documented bug-fix ADR, not a silent test update. +/// Regenerating the files after such a change is , run manually. +/// +public class GoldenVectorTests +{ + private static readonly HiddenTokenDraftGoldenVectorScenario.Vectors Fresh = HiddenTokenDraftGoldenVectorScenario.Build(); + + [Theory] + [MemberData(nameof(VectorCases))] + public void FreshVector_MatchesCommittedGoldenFile(string fileName, object freshVector) + { + var committedPath = Path.Combine(AppContext.BaseDirectory, "GameHost", "GoldenVectors", fileName); + File.Exists(committedPath).Should().BeTrue($"the committed golden vector '{fileName}' must be checked in and copied to the test output"); + + var committedJson = File.ReadAllText(committedPath).TrimEnd('\n', '\r'); + var freshJson = JsonSerializer.Serialize(freshVector, freshVector.GetType(), GoldenVectorJson.Options); + + freshJson.Should().Be( + committedJson, + $"a silent rewrite of '{fileName}' is a compatibility break — bump the engine/schema version or record a bug-fix ADR"); + } + + public static IEnumerable VectorCases() + { + yield return new object[] { "initial-envelope.json", Fresh.InitialEnvelope }; + yield return new object[] { "accepted-command.json", Fresh.AcceptedCommand }; + yield return new object[] { "rejected-command.json", Fresh.RejectedCommand }; + yield return new object[] { "view-seat-0.json", Fresh.ViewSeat0 }; + yield return new object[] { "view-seat-1.json", Fresh.ViewSeat1 }; + yield return new object[] { "view-seat-2.json", Fresh.ViewSeat2 }; + yield return new object[] { "view-spectator.json", Fresh.ViewSpectator }; + yield return new object[] { "terminal-result.json", Fresh.TerminalResult }; + yield return new object[] { "corrupt-checksum.json", Fresh.CorruptChecksum }; + yield return new object[] { "unsupported-version.json", Fresh.UnsupportedVersion }; + } + + [Fact] + public void RejectedCommand_LeavesRevisionAndRngUnchanged() + { + Fresh.RejectedCommand.Accepted.Should().BeFalse(); + Fresh.RejectedCommand.RejectionCode.Should().Be(EngineErrorCode.IllegalActor.ToStableCode()); + Fresh.RejectedCommand.NextRevision.Should().Be(Fresh.RejectedCommand.PriorRevision); + Fresh.RejectedCommand.NextState.Should().BeNull(); + } + + [Fact] + public void SeatViews_NeverExposeAnotherSeatsHand() + { + // Each seat's own hand differs (a distinct random draw), and no seat's PublicView bytes contain + // another seat's PrivateView-equivalent payload. This adapter carries the whole redacted projection in + // PublicView (see HostedGameDefinition.ProjectView), so the isolation guarantee is: seat N's serialized + // view must equal exactly what ProjectView(state, seat N) produces, and must differ from every other + // seat's view once hands are non-empty. + var views = new[] { Fresh.ViewSeat0, Fresh.ViewSeat1, Fresh.ViewSeat2 }; + views.Select(v => v.PublicViewBase64).Distinct().Should().HaveCount(3, "every seat drew a different token, so every seat's view must be distinct"); + views.Select(v => v.PublicViewBase64).Should().NotContain( + Fresh.ViewSpectator.PublicViewBase64, + "a spectator must never receive a seat's own-hand projection verbatim"); + } + + [Fact] + public void CorruptChecksum_FailsClosedOnProjectView() + { + var hosted = HiddenTokenDraftGoldenVectorScenario.CreateHostedDefinition(); + + var act = () => hosted.ProjectView( + Fresh.CorruptChecksumEnvelope, + ViewerContext.ForSpectator(), + CancellationToken.None); + + act.Should().Throw() + .Which.Code.Should().Be(EngineErrorCode.CorruptState); + } + + [Fact] + public void UnsupportedVersion_FailsClosedOnProjectView() + { + var hosted = HiddenTokenDraftGoldenVectorScenario.CreateHostedDefinition(); + + var act = () => hosted.ProjectView( + Fresh.UnsupportedVersionEnvelope, + ViewerContext.ForSpectator(), + CancellationToken.None); + + act.Should().Throw() + .Which.Code.Should().Be(EngineErrorCode.UnsupportedStateVersion); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/HiddenTokenDraftGoldenVectorScenario.cs b/tests/SimPle.UnitTests/GameHost/GoldenVectors/HiddenTokenDraftGoldenVectorScenario.cs new file mode 100644 index 0000000..ae40066 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/HiddenTokenDraftGoldenVectorScenario.cs @@ -0,0 +1,206 @@ +using SimPle.Application.GameHost.Serialization; +using SimPle.Application.GameHost.Services; +using SimPle.Domain.GameHost; +using SimPle.UnitTests.GameHost.Reference; +using SimPle.UnitTests.GameHost.Support; + +namespace SimPle.UnitTests.GameHost.GoldenVectors; + +/// +/// The complete, fixed set of golden vectors for the module-05 spec's required coverage: initial envelope, +/// accepted command, rejected command, every seat + spectator view, terminal result, corrupt checksum, and +/// unsupported version. Every input (match seed, seats, command ids, command order) is a hardcoded constant, so +/// re-running is byte-identical run to run and process to process — that determinism is +/// exactly what the committed JSON files and the two-process SHA-256 comparison are checking. +/// +public static class HiddenTokenDraftGoldenVectorScenario +{ + public static readonly Guid Seat0User = Guid.Parse("11111111-1111-1111-1111-111111111111"); + public static readonly Guid Seat1User = Guid.Parse("22222222-2222-2222-2222-222222222222"); + public static readonly Guid Seat2User = Guid.Parse("33333333-3333-3333-3333-333333333333"); + + public static readonly UInt128 FixedMatchSeed = ((UInt128)0x0123456789ABCDEFUL << 64) | 0xFEDCBA9876543210UL; + + public sealed class Vectors + { + public required GoldenEnvelopeVector InitialEnvelope { get; init; } + public required GoldenTransitionVector AcceptedCommand { get; init; } + public required GoldenTransitionVector RejectedCommand { get; init; } + public required GoldenViewVector ViewSeat0 { get; init; } + public required GoldenViewVector ViewSeat1 { get; init; } + public required GoldenViewVector ViewSeat2 { get; init; } + public required GoldenViewVector ViewSpectator { get; init; } + public required GoldenTerminalResultVector TerminalResult { get; init; } + public required GoldenFailureVector CorruptChecksum { get; init; } + public required GoldenFailureVector UnsupportedVersion { get; init; } + + /// The raw envelope behind , for fail-closed behavior assertions. + public required GameStateEnvelope CorruptChecksumEnvelope { get; init; } + + /// The raw envelope behind , for fail-closed behavior assertions. + public required GameStateEnvelope UnsupportedVersionEnvelope { get; init; } + } + + public static IHostedGameDefinition CreateHostedDefinition() => + new HostedGameDefinition( + new HiddenTokenDraftDefinition()); + + public static Vectors Build() + { + var hosted = CreateHostedDefinition(); + + var threeSeatSetup = GameSetup.Create( + new[] + { + new SeatAssignment(0, Seat0User, false), + new SeatAssignment(1, Seat1User, false), + new SeatAssignment(2, Seat2User, false), + }, + "multiplayer"); + + var initial = hosted.CreateInitialState(threeSeatSetup, FixedMatchSeed, CancellationToken.None); + + // Step 1: seat 0 draws on the opening turn — accepted. + var afterSeat0 = ApplyDraw(hosted, initial, actorSeat: 0, expectedRevision: 0, commandId: CommandId(1)); + + // Step 2: seat 2 tries to act while it is seat 1's turn — rejected (IllegalActor), revision unchanged. + var outOfTurn = ApplyDraw(hosted, afterSeat0.NextState!, actorSeat: 2, expectedRevision: 1, commandId: CommandId(2)); + + // Step 3: seat 1 draws correctly — accepted. + var afterSeat1 = ApplyDraw(hosted, afterSeat0.NextState!, actorSeat: 1, expectedRevision: 1, commandId: CommandId(3)); + + // Step 4: seat 2 draws correctly — accepted. Every seat now holds exactly one token, so each view below + // has non-empty (and non-identical) hidden hands to prove hidden-view isolation. + var afterSeat2 = ApplyDraw(hosted, afterSeat1.NextState!, actorSeat: 2, expectedRevision: 2, commandId: CommandId(4)); + var midGameState = afterSeat2.NextState!; + + var viewSeat0 = ToViewVector(hosted.ProjectView(midGameState, ViewerContext.ForPlayer(0, Seat0User), CancellationToken.None)); + var viewSeat1 = ToViewVector(hosted.ProjectView(midGameState, ViewerContext.ForPlayer(1, Seat1User), CancellationToken.None)); + var viewSeat2 = ToViewVector(hosted.ProjectView(midGameState, ViewerContext.ForPlayer(2, Seat2User), CancellationToken.None)); + var viewSpectator = ToViewVector(hosted.ProjectView(midGameState, ViewerContext.ForSpectator(), CancellationToken.None)); + + // Terminal scenario: a separate 2-seat match where both seats pass once each — a full round of + // consecutive passes ends the match immediately, without needing to drain the token pool. + var twoSeatSetup = GameSetup.Create( + new[] + { + new SeatAssignment(0, Seat0User, false), + new SeatAssignment(1, Seat1User, false), + }, + "multiplayer"); + var terminalInitial = hosted.CreateInitialState(twoSeatSetup, FixedMatchSeed, CancellationToken.None); + var pass0 = ApplyPass(hosted, terminalInitial, actorSeat: 0, expectedRevision: 0, commandId: CommandId(5)); + var pass1 = ApplyPass(hosted, pass0.NextState!, actorSeat: 1, expectedRevision: 1, commandId: CommandId(6)); + + var corrupted = GameStateEnvelopeTestFactory.WithTamperedChecksum(midGameState); + var unsupportedVersion = GameStateEnvelope.Create( + midGameState.GameSlug, + midGameState.EngineVersion, + stateSchemaVersion: 999, + midGameState.Revision, + midGameState.RngState, + midGameState.StateBytes.Span); + + return new Vectors + { + InitialEnvelope = ToEnvelopeVector(initial), + AcceptedCommand = ToTransitionVector(afterSeat0), + RejectedCommand = ToTransitionVector(outOfTurn), + ViewSeat0 = viewSeat0, + ViewSeat1 = viewSeat1, + ViewSeat2 = viewSeat2, + ViewSpectator = viewSpectator, + TerminalResult = ToTerminalResultVector(pass1.TerminalResult!), + CorruptChecksum = new GoldenFailureVector + { + Scenario = "corrupt-checksum", + Envelope = ToEnvelopeVector(corrupted), + ExpectedErrorCode = EngineErrorCode.CorruptState.ToStableCode(), + }, + UnsupportedVersion = new GoldenFailureVector + { + Scenario = "unsupported-version", + Envelope = ToEnvelopeVector(unsupportedVersion), + ExpectedErrorCode = EngineErrorCode.UnsupportedStateVersion.ToStableCode(), + }, + CorruptChecksumEnvelope = corrupted, + UnsupportedVersionEnvelope = unsupportedVersion, + }; + } + + private static EngineTransition ApplyDraw( + IHostedGameDefinition hosted, GameStateEnvelope state, int actorSeat, int expectedRevision, Guid commandId) => + ApplyCommand(hosted, state, new DrawTokenCommand(), actorSeat, expectedRevision, commandId); + + private static EngineTransition ApplyPass( + IHostedGameDefinition hosted, GameStateEnvelope state, int actorSeat, int expectedRevision, Guid commandId) => + ApplyCommand(hosted, state, new PassTurnCommand(), actorSeat, expectedRevision, commandId); + + private static EngineTransition ApplyCommand( + IHostedGameDefinition hosted, + GameStateEnvelope state, + HiddenTokenDraftCommand command, + int actorSeat, + int expectedRevision, + Guid commandId) + { + var actorUserId = actorSeat switch + { + 0 => Seat0User, + 1 => Seat1User, + 2 => Seat2User, + _ => throw new ArgumentOutOfRangeException(nameof(actorSeat)), + }; + + var payload = GameHostJsonContext.Serialize(command); + var envelope = GameCommandEnvelope.Create(commandId, expectedRevision, actorUserId, actorSeat, command.CommandType, payload); + return hosted.ApplyCommand(state, envelope, CancellationToken.None); + } + + private static Guid CommandId(int index) => new($"00000000-0000-0000-0000-{index:D12}"); + + private static GoldenEnvelopeVector ToEnvelopeVector(GameStateEnvelope envelope) => new() + { + GameSlug = envelope.GameSlug, + EngineVersion = envelope.EngineVersion, + StateSchemaVersion = envelope.StateSchemaVersion, + Revision = envelope.Revision, + RngAlgorithm = envelope.RngAlgorithm, + RngState = envelope.RngState.State, + RngInc = envelope.RngState.Inc, + RngCursor = envelope.RngState.Cursor, + StateBytesBase64 = Convert.ToBase64String(envelope.StateBytes.Span), + ChecksumSha256Hex = envelope.Checksum, + }; + + private static GoldenTransitionVector ToTransitionVector(EngineTransition transition) => new() + { + Accepted = transition.Accepted, + PriorRevision = transition.PriorRevision, + NextRevision = transition.NextRevision, + RejectionCode = transition.RejectionCode, + RejectionDetail = transition.RejectionDetail, + NextState = transition.NextState is null ? null : ToEnvelopeVector(transition.NextState), + PublicEventTypes = transition.PublicEvents.Select(e => e.EventType).ToList(), + PrivateEventTypes = transition.PrivateEvents.Select(e => e.EventType).ToList(), + EngineState = transition.EngineState.ToString(), + }; + + private static GoldenViewVector ToViewVector(PlayerViewEnvelope view) => new() + { + ViewerRole = view.ViewerRole.ToString(), + ViewerSeat = view.ViewerSeat, + Revision = view.Revision, + PublicViewBase64 = Convert.ToBase64String(view.PublicView.Span), + HasPrivateView = view.PrivateView.HasValue, + PrivateViewBase64 = view.PrivateView.HasValue ? Convert.ToBase64String(view.PrivateView.Value.Span) : null, + EngineState = view.EngineState.ToString(), + }; + + private static GoldenTerminalResultVector ToTerminalResultVector(TerminalResultCandidate terminal) => new() + { + SeatResults = terminal.SeatResults + .Select(r => new GoldenSeatResultVector { Seat = r.Seat, Outcome = r.Outcome.ToString(), Score = r.Score }) + .ToList(), + }; +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/accepted-command.json b/tests/SimPle.UnitTests/GameHost/GoldenVectors/accepted-command.json new file mode 100644 index 0000000..0c4f89d --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/accepted-command.json @@ -0,0 +1,24 @@ +{ + "accepted": true, + "priorRevision": 0, + "nextRevision": 1, + "rejectionCode": null, + "rejectionDetail": null, + "nextState": { + "gameSlug": "hidden-token-draft-reference", + "engineVersion": 1, + "stateSchemaVersion": 1, + "revision": 1, + "rngAlgorithm": "PCG32-v1", + "rngState": 6574294275608657278, + "rngInc": 18282773015276577825, + "rngCursor": 1, + "stateBytesBase64": "eyJzZWF0Q291bnQiOjMsImN1cnJlbnRTZWF0IjoxLCJjb25zZWN1dGl2ZVBhc3NlcyI6MCwiZGVja1Rva2VucyI6WzEsMiw0LDUsNiw3LDgsOSwxMCwxMSwxMiwxMywxNCwxNSwxNiwxNywxOF0sImhhbmRzIjpbWzNdLFtdLFtdXX0=", + "checksumSha256Hex": "d9803e2e1bb0bf7680096a66df473f80370eb71c1889d2521686262e7141573a" + }, + "publicEventTypes": [ + "TokenDrawn" + ], + "privateEventTypes": [], + "engineState": "InProgress" +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/corrupt-checksum.json b/tests/SimPle.UnitTests/GameHost/GoldenVectors/corrupt-checksum.json new file mode 100644 index 0000000..77134da --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/corrupt-checksum.json @@ -0,0 +1,16 @@ +{ + "scenario": "corrupt-checksum", + "envelope": { + "gameSlug": "hidden-token-draft-reference", + "engineVersion": 1, + "stateSchemaVersion": 1, + "revision": 3, + "rngAlgorithm": "PCG32-v1", + "rngState": 6002397479510801052, + "rngInc": 18282773015276577825, + "rngCursor": 3, + "stateBytesBase64": "eyJzZWF0Q291bnQiOjMsImN1cnJlbnRTZWF0IjowLCJjb25zZWN1dGl2ZVBhc3NlcyI6MCwiZGVja1Rva2VucyI6WzEsMiw0LDUsNiw3LDksMTEsMTIsMTMsMTQsMTUsMTYsMTcsMThdLCJoYW5kcyI6W1szXSxbOF0sWzEwXV19", + "checksumSha256Hex": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "expectedErrorCode": "Engine.CorruptState" +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/initial-envelope.json b/tests/SimPle.UnitTests/GameHost/GoldenVectors/initial-envelope.json new file mode 100644 index 0000000..28badd8 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/initial-envelope.json @@ -0,0 +1,12 @@ +{ + "gameSlug": "hidden-token-draft-reference", + "engineVersion": 1, + "stateSchemaVersion": 1, + "revision": 0, + "rngAlgorithm": "PCG32-v1", + "rngState": 10126853245685670129, + "rngInc": 18282773015276577825, + "rngCursor": 0, + "stateBytesBase64": "eyJzZWF0Q291bnQiOjMsImN1cnJlbnRTZWF0IjowLCJjb25zZWN1dGl2ZVBhc3NlcyI6MCwiZGVja1Rva2VucyI6WzEsMiwzLDQsNSw2LDcsOCw5LDEwLDExLDEyLDEzLDE0LDE1LDE2LDE3LDE4XSwiaGFuZHMiOltbXSxbXSxbXV19", + "checksumSha256Hex": "ff16b3116796e5b68404f1b4cd2cec1e2b4a10b2da12aa736c7ce9df970a170a" +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/rejected-command.json b/tests/SimPle.UnitTests/GameHost/GoldenVectors/rejected-command.json new file mode 100644 index 0000000..8849887 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/rejected-command.json @@ -0,0 +1,11 @@ +{ + "accepted": false, + "priorRevision": 1, + "nextRevision": 1, + "rejectionCode": "Engine.IllegalActor", + "rejectionDetail": "It is not this seat\u0027s turn.", + "nextState": null, + "publicEventTypes": [], + "privateEventTypes": [], + "engineState": "InProgress" +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/terminal-result.json b/tests/SimPle.UnitTests/GameHost/GoldenVectors/terminal-result.json new file mode 100644 index 0000000..e2d4f74 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/terminal-result.json @@ -0,0 +1,14 @@ +{ + "seatResults": [ + { + "seat": 0, + "outcome": "Draw", + "score": 0 + }, + { + "seat": 1, + "outcome": "Draw", + "score": 0 + } + ] +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/unsupported-version.json b/tests/SimPle.UnitTests/GameHost/GoldenVectors/unsupported-version.json new file mode 100644 index 0000000..a4949e4 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/unsupported-version.json @@ -0,0 +1,16 @@ +{ + "scenario": "unsupported-version", + "envelope": { + "gameSlug": "hidden-token-draft-reference", + "engineVersion": 1, + "stateSchemaVersion": 999, + "revision": 3, + "rngAlgorithm": "PCG32-v1", + "rngState": 6002397479510801052, + "rngInc": 18282773015276577825, + "rngCursor": 3, + "stateBytesBase64": "eyJzZWF0Q291bnQiOjMsImN1cnJlbnRTZWF0IjowLCJjb25zZWN1dGl2ZVBhc3NlcyI6MCwiZGVja1Rva2VucyI6WzEsMiw0LDUsNiw3LDksMTEsMTIsMTMsMTQsMTUsMTYsMTcsMThdLCJoYW5kcyI6W1szXSxbOF0sWzEwXV19", + "checksumSha256Hex": "9e6c2b5bf042e626599c07251f210eaaaaa280b03a96c018f7b1fb41865ee616" + }, + "expectedErrorCode": "Engine.UnsupportedStateVersion" +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-0.json b/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-0.json new file mode 100644 index 0000000..0e95ef7 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-0.json @@ -0,0 +1,9 @@ +{ + "viewerRole": "Player", + "viewerSeat": 0, + "revision": 3, + "publicViewBase64": "eyJzZWF0Q291bnQiOjMsImN1cnJlbnRTZWF0IjowLCJjb25zZWN1dGl2ZVBhc3NlcyI6MCwidG9rZW5zUmVtYWluaW5nIjoxNSwiaGFuZFNpemVzIjpbMSwxLDFdLCJvd25IYW5kIjpbM119", + "hasPrivateView": false, + "privateViewBase64": null, + "engineState": "InProgress" +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-1.json b/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-1.json new file mode 100644 index 0000000..e745bf5 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-1.json @@ -0,0 +1,9 @@ +{ + "viewerRole": "Player", + "viewerSeat": 1, + "revision": 3, + "publicViewBase64": "eyJzZWF0Q291bnQiOjMsImN1cnJlbnRTZWF0IjowLCJjb25zZWN1dGl2ZVBhc3NlcyI6MCwidG9rZW5zUmVtYWluaW5nIjoxNSwiaGFuZFNpemVzIjpbMSwxLDFdLCJvd25IYW5kIjpbOF19", + "hasPrivateView": false, + "privateViewBase64": null, + "engineState": "InProgress" +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-2.json b/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-2.json new file mode 100644 index 0000000..8a4c94f --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-2.json @@ -0,0 +1,9 @@ +{ + "viewerRole": "Player", + "viewerSeat": 2, + "revision": 3, + "publicViewBase64": "eyJzZWF0Q291bnQiOjMsImN1cnJlbnRTZWF0IjowLCJjb25zZWN1dGl2ZVBhc3NlcyI6MCwidG9rZW5zUmVtYWluaW5nIjoxNSwiaGFuZFNpemVzIjpbMSwxLDFdLCJvd25IYW5kIjpbMTBdfQ==", + "hasPrivateView": false, + "privateViewBase64": null, + "engineState": "InProgress" +} diff --git a/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-spectator.json b/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-spectator.json new file mode 100644 index 0000000..c33d569 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/GoldenVectors/view-spectator.json @@ -0,0 +1,9 @@ +{ + "viewerRole": "Spectator", + "viewerSeat": null, + "revision": 3, + "publicViewBase64": "eyJzZWF0Q291bnQiOjMsImN1cnJlbnRTZWF0IjowLCJjb25zZWN1dGl2ZVBhc3NlcyI6MCwidG9rZW5zUmVtYWluaW5nIjoxNSwiaGFuZFNpemVzIjpbMSwxLDFdLCJvd25IYW5kIjpudWxsfQ==", + "hasPrivateView": false, + "privateViewBase64": null, + "engineState": "InProgress" +} diff --git a/tests/SimPle.UnitTests/GameHost/HostedGameDefinitionAdapterTests.cs b/tests/SimPle.UnitTests/GameHost/HostedGameDefinitionAdapterTests.cs new file mode 100644 index 0000000..ae451e7 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/HostedGameDefinitionAdapterTests.cs @@ -0,0 +1,182 @@ +using FluentAssertions; +using SimPle.Application.GameHost.Serialization; +using SimPle.Application.GameHost.Services; +using SimPle.Domain.GameHost; +using SimPle.UnitTests.GameHost.GoldenVectors; +using SimPle.UnitTests.GameHost.Reference; +using SimPle.UnitTests.GameHost.Support; + +namespace SimPle.UnitTests.GameHost; + +/// +/// does all of the envelope/byte/checksum work +/// around a typed . These tests exercise that adapter +/// layer directly against the reference engine — the identity/staleness/ +/// discriminator-mismatch checks the golden vectors don't already cover. +/// +public sealed class HostedGameDefinitionAdapterTests +{ + private static readonly Guid Seat0User = HiddenTokenDraftGoldenVectorScenario.Seat0User; + private static readonly Guid Seat1User = HiddenTokenDraftGoldenVectorScenario.Seat1User; + + private static IHostedGameDefinition CreateHosted() => HiddenTokenDraftGoldenVectorScenario.CreateHostedDefinition(); + + private static GameStateEnvelope CreateTwoSeatInitialState(IHostedGameDefinition hosted) + { + var setup = GameSetup.Create( + [new SeatAssignment(0, Seat0User, false), new SeatAssignment(1, Seat1User, false)], + "multiplayer"); + + return hosted.CreateInitialState(setup, HiddenTokenDraftGoldenVectorScenario.FixedMatchSeed, CancellationToken.None); + } + + private static GameCommandEnvelope DrawEnvelope(int expectedRevision, int actorSeat, Guid actorUserId) => + BuildEnvelope(new DrawTokenCommand(), expectedRevision, actorSeat, actorUserId); + + private static GameCommandEnvelope BuildEnvelope(HiddenTokenDraftCommand command, int expectedRevision, int actorSeat, Guid actorUserId) + { + var payload = GameHostJsonContext.Serialize(command); + return GameCommandEnvelope.Create(Guid.NewGuid(), expectedRevision, actorUserId, actorSeat, command.CommandType, payload); + } + + [Fact] + public void CreateInitialState_ProducesRevisionZeroWithASelfConsistentChecksum() + { + var hosted = CreateHosted(); + + var initial = CreateTwoSeatInitialState(hosted); + + initial.Revision.Should().Be(0); + initial.ChecksumMatches().Should().BeTrue(); + initial.GameSlug.Should().Be(hosted.Metadata.Slug); + initial.EngineVersion.Should().Be(hosted.Metadata.EngineVersion); + initial.StateSchemaVersion.Should().Be(hosted.Metadata.StateSchemaVersion); + } + + [Fact] + public void ApplyCommand_StaleExpectedRevision_RejectsWithStaleRevisionAndLeavesRevisionUnchanged() + { + var hosted = CreateHosted(); + var initial = CreateTwoSeatInitialState(hosted); + + var transition = hosted.ApplyCommand(initial, DrawEnvelope(expectedRevision: 1, actorSeat: 0, Seat0User), CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.StaleRevision.ToStableCode()); + transition.NextRevision.Should().Be(transition.PriorRevision); + transition.NextState.Should().BeNull(); + } + + [Fact] + public void ApplyCommand_MismatchedGameSlug_RejectsWithCorruptState() + { + var hosted = CreateHosted(); + var initial = CreateTwoSeatInitialState(hosted); + var wrongSlugState = GameStateEnvelope.Create( + "not-this-game", initial.EngineVersion, initial.StateSchemaVersion, initial.Revision, initial.RngState, initial.StateBytes.Span); + + var transition = hosted.ApplyCommand(wrongSlugState, DrawEnvelope(0, 0, Seat0User), CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.CorruptState.ToStableCode()); + } + + [Fact] + public void ApplyCommand_MismatchedStateSchemaVersion_RejectsWithUnsupportedStateVersion() + { + var hosted = CreateHosted(); + var initial = CreateTwoSeatInitialState(hosted); + var wrongSchemaState = GameStateEnvelope.Create( + initial.GameSlug, initial.EngineVersion, stateSchemaVersion: 999, initial.Revision, initial.RngState, initial.StateBytes.Span); + + var transition = hosted.ApplyCommand(wrongSchemaState, DrawEnvelope(0, 0, Seat0User), CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.UnsupportedStateVersion.ToStableCode()); + } + + [Fact] + public void ApplyCommand_TamperedChecksum_RejectsWithCorruptState() + { + var hosted = CreateHosted(); + var initial = CreateTwoSeatInitialState(hosted); + var tampered = GameStateEnvelopeTestFactory.WithTamperedChecksum(initial); + + var transition = hosted.ApplyCommand(tampered, DrawEnvelope(0, 0, Seat0User), CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.CorruptState.ToStableCode()); + } + + [Fact] + public void ApplyCommand_EnvelopeCommandTypeDisagreesWithPayloadDiscriminator_RejectsWithInvalidCommandType() + { + var hosted = CreateHosted(); + var initial = CreateTwoSeatInitialState(hosted); + + // The payload's own embedded discriminator says "draw", but the envelope's out-of-band CommandType + // claims "pass" — the adapter's defense-in-depth cross-check must catch this disagreement. + var payload = GameHostJsonContext.Serialize(new DrawTokenCommand()); + var mismatchedEnvelope = GameCommandEnvelope.Create(Guid.NewGuid(), 0, Seat0User, 0, "pass", payload); + + var transition = hosted.ApplyCommand(initial, mismatchedEnvelope, CancellationToken.None); + + transition.Accepted.Should().BeFalse(); + transition.RejectionCode.Should().Be(EngineErrorCode.InvalidCommandType.ToStableCode()); + } + + [Fact] + public void ApplyCommand_Accepted_AdvancesRevisionByExactlyOneAndPersistsANewRngSnapshot() + { + var hosted = CreateHosted(); + var initial = CreateTwoSeatInitialState(hosted); + + var transition = hosted.ApplyCommand(initial, DrawEnvelope(0, 0, Seat0User), CancellationToken.None); + + transition.Accepted.Should().BeTrue(); + transition.NextState!.Revision.Should().Be(1); + transition.NextState.ChecksumMatches().Should().BeTrue(); + transition.NextState.RngState.Should().NotBe(initial.RngState, "a draw consumes RNG output, so the persisted stream must advance"); + } + + [Fact] + public void ProjectView_WhileInProgress_ReportsEngineStateInProgress() + { + var hosted = CreateHosted(); + var initial = CreateTwoSeatInitialState(hosted); + + var view = hosted.ProjectView(initial, ViewerContext.ForSpectator(), CancellationToken.None); + + view.EngineState.Should().Be(EngineState.InProgress); + } + + [Fact] + public void ProjectView_OnceTerminal_ReportsEngineStateTerminal() + { + var hosted = CreateHosted(); + var initial = CreateTwoSeatInitialState(hosted); + + var afterPass0 = hosted.ApplyCommand(initial, BuildEnvelope(new PassTurnCommand(), 0, 0, Seat0User), CancellationToken.None); + var afterPass1 = hosted.ApplyCommand(afterPass0.NextState!, BuildEnvelope(new PassTurnCommand(), 1, 1, Seat1User), CancellationToken.None); + + afterPass1.EngineState.Should().Be(EngineState.Terminal); + + var view = hosted.ProjectView(afterPass1.NextState!, ViewerContext.ForSpectator(), CancellationToken.None); + + view.EngineState.Should().Be(EngineState.Terminal); + } + + [Fact] + public void EvaluateResult_ReturnsNullWhileInProgressAndACandidateOnceTerminal() + { + var hosted = CreateHosted(); + var initial = CreateTwoSeatInitialState(hosted); + + hosted.EvaluateResult(initial, CancellationToken.None).Should().BeNull(); + + var afterPass0 = hosted.ApplyCommand(initial, BuildEnvelope(new PassTurnCommand(), 0, 0, Seat0User), CancellationToken.None); + var afterPass1 = hosted.ApplyCommand(afterPass0.NextState!, BuildEnvelope(new PassTurnCommand(), 1, 1, Seat1User), CancellationToken.None); + + hosted.EvaluateResult(afterPass1.NextState!, CancellationToken.None).Should().NotBeNull(); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/Pcg32Tests.cs b/tests/SimPle.UnitTests/GameHost/Pcg32Tests.cs new file mode 100644 index 0000000..b3a9b98 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/Pcg32Tests.cs @@ -0,0 +1,188 @@ +using FluentAssertions; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost; + +/// +/// PCG32-v1 is the single source of randomness in the game host, so every determinism guarantee in the +/// module — replay, golden vectors, dispute resolution — reduces to this generator producing the same stream +/// on every machine and every run. +/// +/// The reference-vector test is the important one: it pins the implementation to the published PCG32 +/// output, not merely to "whatever it did last time". A refactor that stays self-consistent but drifts from +/// the reference would silently invalidate every stored match, and only this test would catch it. +/// +/// +public sealed class Pcg32Tests +{ + /// + /// The canonical output of pcg32-demo seeded with pcg32_srandom_r(&rng, 42u, 54u). + /// These constants come from the PCG reference implementation and must never be "fixed" to match our code: + /// if this test fails, the implementation is wrong, not the vector. + /// + private static readonly uint[] ReferenceStream4254 = + [ + 0xa15c02b7, 0x7b47f409, 0xba1d3330, 0x83d2f293, 0xbfa4784b, 0xcbed606e, + ]; + + [Fact] + public void NextUInt32_WithReferenceSeed_MatchesPublishedPcg32Vector() + { + var rng = Pcg32.FromSeedParts(initState: 42UL, initSeq: 54UL); + + var actual = Enumerable.Range(0, ReferenceStream4254.Length).Select(_ => rng.NextUInt32()).ToArray(); + + actual.Should().Equal(ReferenceStream4254); + } + + [Fact] + public void FromMatchSeed_SplitsHigh64ToInitStateAndLow64ToInitSeq() + { + // The split is normative: high 64 -> initstate, low 64 -> initseq. If it ever changed, every seed + // would deal a different game, so this test pins it against the reference parameters directly. + var matchSeed = ((UInt128)42UL << 64) | 54UL; + + var fromMatchSeed = Pcg32.FromMatchSeed(matchSeed); + var fromParts = Pcg32.FromSeedParts(42UL, 54UL); + + Draw(fromMatchSeed, 6).Should().Equal(Draw(fromParts, 6)); + Draw(Pcg32.FromMatchSeed(matchSeed), 6).Should().Equal(ReferenceStream4254); + } + + [Fact] + public void NextUInt32_SameSeed_IsReproducibleAcrossOneHundredFreshStreams() + { + // The module claims byte-determinism for identical inputs; 100 repeats is the spec's stated bar. + var expected = Draw(Pcg32.FromSeedParts(7UL, 11UL), 32); + + for (var run = 0; run < 100; run++) + Draw(Pcg32.FromSeedParts(7UL, 11UL), 32).Should().Equal(expected, "run {0} must reproduce the stream", run); + } + + [Fact] + public void NextUInt32_DifferentStreamSelector_ProducesIndependentSequences() + { + var a = Draw(Pcg32.FromSeedParts(42UL, 54UL), 16); + var b = Draw(Pcg32.FromSeedParts(42UL, 55UL), 16); + + a.Should().NotEqual(b); + } + + [Fact] + public void Cursor_CountsDrawsOnly_NotTheTwoSeedingSteps() + { + var rng = Pcg32.FromSeedParts(42UL, 54UL); + rng.Cursor.Should().Be(0, "seeding steps are initialization, not draws the game asked for"); + + rng.NextUInt32(); + rng.NextUInt32(); + + rng.Cursor.Should().Be(2); + } + + [Fact] + public void Restore_FromSnapshot_ResumesTheExactStream() + { + // This is the replay path: Module 8 stores the snapshot in the state envelope and rehydrates it later. + var original = Pcg32.FromSeedParts(42UL, 54UL); + Draw(original, 3); + var snapshot = original.Snapshot(); + + var expectedContinuation = Draw(original, 5); + var restored = Pcg32.Restore(snapshot); + + Draw(restored, 5).Should().Equal(expectedContinuation); + restored.Snapshot().Cursor.Should().Be(8); + } + + [Fact] + public void Restore_WithEvenStreamSelector_IsRejectedAsCorrupt() + { + // PCG requires an odd increment; an even one means the persisted state was corrupted or forged. + var corrupt = new Pcg32State(State: 123UL, Inc: 8UL, Cursor: 0UL); + + var restore = () => Pcg32.Restore(corrupt); + + restore.Should().Throw().WithMessage("*odd*"); + } + + [Fact] + public void NextBounded_StaysInRange_AndIsDeterministic() + { + var rng = Pcg32.FromSeedParts(1UL, 2UL); + var draws = Enumerable.Range(0, 500).Select(_ => rng.NextBounded(6)).ToArray(); + + draws.Should().OnlyContain(d => d < 6); + draws.Should().Contain(0).And.Contain(5, "a bounded draw must be able to reach both ends of its range"); + + var replay = Pcg32.FromSeedParts(1UL, 2UL); + Enumerable.Range(0, 500).Select(_ => replay.NextBounded(6)).Should().Equal(draws); + } + + [Fact] + public void NextBounded_WithZeroBound_Throws() + { + var rng = Pcg32.FromSeedParts(1UL, 2UL); + + var draw = () => rng.NextBounded(0); + + draw.Should().Throw(); + } + + [Fact] + public void NextInt_RespectsInclusiveAndExclusiveBounds() + { + var rng = Pcg32.FromSeedParts(9UL, 9UL); + + var draws = Enumerable.Range(0, 500).Select(_ => rng.NextInt(-3, 4)).ToArray(); + + draws.Should().OnlyContain(d => d >= -3 && d < 4); + } + + [Fact] + public void NextInt_WithInvertedRange_Throws() + { + var rng = Pcg32.FromSeedParts(9UL, 9UL); + + var draw = () => rng.NextInt(5, 5); + + draw.Should().Throw(); + } + + [Fact] + public void Shuffle_WithSameSeed_ProducesTheSamePermutation() + { + // A shuffled deck is the archetypal hidden-information setup; it has to be reproducible from the seed + // alone, or a match cannot be replayed from its golden vector. + var first = Enumerable.Range(0, 20).ToList(); + var second = Enumerable.Range(0, 20).ToList(); + + Pcg32.FromSeedParts(42UL, 54UL).Shuffle(first); + Pcg32.FromSeedParts(42UL, 54UL).Shuffle(second); + + first.Should().Equal(second); + first.Should().NotEqual(Enumerable.Range(0, 20), "a 20-element shuffle must actually permute"); + first.Should().BeEquivalentTo(Enumerable.Range(0, 20), "a shuffle permutes, it never adds or drops"); + } + + [Fact] + public void Shuffle_WithDifferentSeed_ProducesADifferentPermutation() + { + var a = Enumerable.Range(0, 20).ToList(); + var b = Enumerable.Range(0, 20).ToList(); + + Pcg32.FromSeedParts(42UL, 54UL).Shuffle(a); + Pcg32.FromSeedParts(99UL, 54UL).Shuffle(b); + + a.Should().NotEqual(b); + } + + [Fact] + public void AlgorithmId_IsTheVersionedIdentifierRecordedInEveryEnvelope() + { + Pcg32.AlgorithmId.Should().Be("PCG32-v1"); + } + + private static uint[] Draw(Pcg32 rng, int count) => + Enumerable.Range(0, count).Select(_ => rng.NextUInt32()).ToArray(); +} diff --git a/tests/SimPle.UnitTests/GameHost/PlayerViewEnvelopeTests.cs b/tests/SimPle.UnitTests/GameHost/PlayerViewEnvelopeTests.cs new file mode 100644 index 0000000..2f6a734 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/PlayerViewEnvelopeTests.cs @@ -0,0 +1,121 @@ +using System.Text; +using FluentAssertions; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost; + +/// +/// The player view is the redaction boundary — the only game state a client ever receives. The rule this class +/// exists to enforce is that a spectator cannot be handed private data even by a definition that tries to: +/// the envelope refuses to construct, rather than trusting each future game author to remember. +/// +public sealed class PlayerViewEnvelopeTests +{ + private static readonly byte[] PublicBytes = Encoding.UTF8.GetBytes("""{"turn":1,"counts":[3,3]}"""); + private static readonly byte[] PrivateBytes = Encoding.UTF8.GetBytes("""{"hand":["A","K"]}"""); + + [Fact] + public void Create_ForAPlayer_CarriesBothProjections() + { + var envelope = PlayerViewEnvelope.Create( + revision: 4, + viewer: ViewerContext.ForPlayer(seat: 1, userId: Guid.NewGuid()), + publicView: PublicBytes, + viewSchemaVersion: 1, + engineState: EngineState.InProgress, + privateView: PrivateBytes, + hasPrivateView: true); + + envelope.ViewerRole.Should().Be(ViewerRole.Player); + envelope.ViewerSeat.Should().Be(1); + envelope.PublicView.ToArray().Should().Equal(PublicBytes); + envelope.PrivateView!.Value.ToArray().Should().Equal(PrivateBytes); + } + + [Fact] + public void Create_ForASpectatorCarryingPrivateData_Throws() + { + // The whole point of the type. A definition that passes a hand into a spectator projection has made a + // hidden-information mistake, and it fails here rather than on a client screen. + var create = () => PlayerViewEnvelope.Create( + revision: 4, + viewer: ViewerContext.ForSpectator(), + publicView: PublicBytes, + viewSchemaVersion: 1, + engineState: EngineState.InProgress, + privateView: PrivateBytes, + hasPrivateView: true); + + create.Should().Throw().WithMessage("*must not carry private data*"); + } + + [Fact] + public void Create_ForASpectator_YieldsOnlyThePublicProjection() + { + var envelope = PlayerViewEnvelope.Create( + revision: 4, + viewer: ViewerContext.ForSpectator(), + publicView: PublicBytes, + viewSchemaVersion: 1, + engineState: EngineState.InProgress); + + envelope.ViewerRole.Should().Be(ViewerRole.Spectator); + envelope.ViewerSeat.Should().BeNull(); + envelope.PrivateView.Should().BeNull(); + envelope.PublicView.ToArray().Should().Equal(PublicBytes); + } + + [Fact] + public void Create_ForAPlayerWithoutASeat_Throws() + { + var seatless = new ViewerContext(ViewerRole.Player, Seat: null, UserId: Guid.NewGuid()); + + var create = () => PlayerViewEnvelope.Create( + revision: 0, seatless, PublicBytes, 1, EngineState.InProgress); + + create.Should().Throw().WithMessage("*requires a seat*"); + } + + [Fact] + public void Create_WithNonPositiveViewSchemaVersion_Throws() + { + var create = () => PlayerViewEnvelope.Create( + revision: 0, + viewer: ViewerContext.ForSpectator(), + publicView: PublicBytes, + viewSchemaVersion: 0, + engineState: EngineState.InProgress); + + create.Should().Throw(); + } + + [Fact] + public void Create_WithNegativeRevision_Throws() + { + var create = () => PlayerViewEnvelope.Create( + revision: -1, + viewer: ViewerContext.ForSpectator(), + publicView: PublicBytes, + viewSchemaVersion: 1, + engineState: EngineState.InProgress); + + create.Should().Throw(); + } + + [Fact] + public void Create_CopiesTheCallersBuffers() + { + var mutable = Encoding.UTF8.GetBytes("""{"turn":1}"""); + + var envelope = PlayerViewEnvelope.Create( + revision: 1, + viewer: ViewerContext.ForSpectator(), + publicView: mutable, + viewSchemaVersion: 1, + engineState: EngineState.InProgress); + + mutable[2] = (byte)'X'; + + envelope.PublicView.ToArray().Should().NotEqual(mutable, "the envelope must own the bytes it hands out"); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftCommand.cs b/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftCommand.cs new file mode 100644 index 0000000..323cb4c --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftCommand.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost.Reference; + +/// +/// The command union for . Members are allow-listed by the +/// string discriminator, never by CLR type name, matching every other +/// game-host command union. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(DrawTokenCommand), "draw")] +[JsonDerivedType(typeof(PassTurnCommand), "pass")] +public abstract class HiddenTokenDraftCommand : IGameCommand +{ + public abstract string CommandType { get; } +} + +/// Draw one random remaining token from the shared pool into the acting seat's hand. +public sealed class DrawTokenCommand : HiddenTokenDraftCommand +{ + public override string CommandType => "draw"; +} + +/// End the acting seat's turn without drawing. +public sealed class PassTurnCommand : HiddenTokenDraftCommand +{ + public override string CommandType => "pass"; +} diff --git a/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftDefinition.cs b/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftDefinition.cs new file mode 100644 index 0000000..916cbcc --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftDefinition.cs @@ -0,0 +1,187 @@ +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost.Reference; + +/// +/// Test-only reference engine used to exercise the Module 5 host machinery end-to-end: 2-4 seats, a +/// PCG32-v1-driven shared token pool, private hands, public turn/count state, draw/pass +/// commands, a deterministic terminal score, and a spectator projection. +/// +/// Never registered in production DI or inserted into the Module 4 catalog. It lives under +/// tests/SimPle.UnitTests/GameHost/Reference specifically so no production composition root can reach it +/// by accident — only test projects reference this assembly. +/// +/// +public sealed class HiddenTokenDraftDefinition + : IGameDefinition +{ + public const string Slug = "hidden-token-draft-reference"; + + internal const int TokensPerSeat = 6; + + public GameDefinitionMetadata Metadata { get; } = GameDefinitionMetadata.Create( + slug: Slug, + engineVersion: 1, + stateSchemaVersion: 1, + minPlayers: 2, + maxPlayers: 4, + supportedModes: new[] { "multiplayer" }, + hasHiddenInformation: true, + supportsSpectatorView: true, + supportsAi: false, + supportsTimer: false, + supportsRanked: false, + supportsDeterministicReplay: true); + + public HiddenTokenDraftState CreateInitialState(GameSetup setup, Pcg32 rng, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var seatCount = setup.Seats.Count; + var deck = new List(seatCount * TokensPerSeat); + for (var token = 1; token <= seatCount * TokensPerSeat; token++) + deck.Add(token); + + var hands = new List>(seatCount); + for (var seat = 0; seat < seatCount; seat++) + hands.Add(new List()); + + return new HiddenTokenDraftState + { + SeatCount = seatCount, + CurrentSeat = 0, + ConsecutivePasses = 0, + DeckTokens = deck, + Hands = hands, + }; + } + + public EngineDecision ApplyCommand( + HiddenTokenDraftState state, + HiddenTokenDraftCommand command, + CommandContext context, + Pcg32 rng, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (IsTerminal(state)) + return EngineDecision.Reject(EngineErrorCode.InvalidCommand, "Match is already terminal."); + + if (context.ActorSeat < 0 || context.ActorSeat >= state.SeatCount) + return EngineDecision.Reject(EngineErrorCode.IllegalActor, "Seat is out of range."); + + if (context.ActorSeat != state.CurrentSeat) + return EngineDecision.Reject(EngineErrorCode.IllegalActor, "It is not this seat's turn."); + + return command switch + { + DrawTokenCommand => ApplyDraw(state, rng), + PassTurnCommand => ApplyPass(state), + _ => EngineDecision.Reject(EngineErrorCode.InvalidCommand, "Unrecognized command."), + }; + } + + public HiddenTokenDraftPlayerView ProjectView(HiddenTokenDraftState state, ViewerContext viewer, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var handSizes = state.Hands.Select(hand => hand.Count).ToList(); + + List? ownHand = null; + if (!viewer.IsSpectator) + { + var seat = viewer.Seat!.Value; + if (seat < 0 || seat >= state.SeatCount) + throw new ArgumentOutOfRangeException(nameof(viewer), seat, "Seat is out of range for this match."); + + ownHand = new List(state.Hands[seat]); + } + + return new HiddenTokenDraftPlayerView + { + SeatCount = state.SeatCount, + CurrentSeat = state.CurrentSeat, + ConsecutivePasses = state.ConsecutivePasses, + TokensRemaining = state.DeckTokens.Count, + HandSizes = handSizes, + OwnHand = ownHand, + }; + } + + public TerminalResultCandidate? EvaluateResult(HiddenTokenDraftState state, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return IsTerminal(state) ? BuildTerminalResult(state) : null; + } + + private static EngineDecision ApplyDraw(HiddenTokenDraftState state, Pcg32 rng) + { + if (state.DeckTokens.Count == 0) + return EngineDecision.Reject(EngineErrorCode.InvalidCommand, "Deck is empty."); + + var index = (int)rng.NextBounded((uint)state.DeckTokens.Count); + var token = state.DeckTokens[index]; + + var nextDeck = new List(state.DeckTokens); + nextDeck.RemoveAt(index); + + var nextHands = CopyHands(state.Hands); + nextHands[state.CurrentSeat].Add(token); + + var nextState = new HiddenTokenDraftState + { + SeatCount = state.SeatCount, + CurrentSeat = (state.CurrentSeat + 1) % state.SeatCount, + ConsecutivePasses = 0, + DeckTokens = nextDeck, + Hands = nextHands, + }; + + var events = new[] { GameEvent.Public("TokenDrawn", schemaVersion: 1) }; + var terminal = IsTerminal(nextState) ? BuildTerminalResult(nextState) : null; + + return EngineDecision.Accept(nextState, events, terminal); + } + + private static EngineDecision ApplyPass(HiddenTokenDraftState state) + { + var nextState = new HiddenTokenDraftState + { + SeatCount = state.SeatCount, + CurrentSeat = (state.CurrentSeat + 1) % state.SeatCount, + ConsecutivePasses = state.ConsecutivePasses + 1, + DeckTokens = new List(state.DeckTokens), + Hands = CopyHands(state.Hands), + }; + + var events = new[] { GameEvent.Public("TurnPassed", schemaVersion: 1) }; + var terminal = IsTerminal(nextState) ? BuildTerminalResult(nextState) : null; + + return EngineDecision.Accept(nextState, events, terminal); + } + + private static List> CopyHands(List> hands) => + hands.Select(hand => new List(hand)).ToList(); + + private static bool IsTerminal(HiddenTokenDraftState state) => + state.DeckTokens.Count == 0 || state.ConsecutivePasses >= state.SeatCount; + + private static TerminalResultCandidate BuildTerminalResult(HiddenTokenDraftState state) + { + var scores = state.Hands.Select(hand => hand.Sum()).ToList(); + var topScore = scores.Max(); + var winners = scores.Count(score => score == topScore); + + var results = new List(state.SeatCount); + for (var seat = 0; seat < state.SeatCount; seat++) + { + var outcome = scores[seat] != topScore + ? SeatOutcome.Loss + : winners > 1 ? SeatOutcome.Draw : SeatOutcome.Win; + results.Add(new SeatResult(seat, outcome, scores[seat])); + } + + return TerminalResultCandidate.Create(results); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftDefinitionTests.cs b/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftDefinitionTests.cs new file mode 100644 index 0000000..5de549d --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftDefinitionTests.cs @@ -0,0 +1,264 @@ +using FluentAssertions; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost.Reference; + +/// +/// Exercises directly at the typed +/// layer — the properties the whole Module 5 host machinery depends on the reference engine actually having: +/// determinism from a fixed RNG stream, RNG advancing only on accepted draws, hidden-hand isolation in +/// projections, and the two independent terminal conditions (deck exhaustion, a full round of passes). +/// +public sealed class HiddenTokenDraftDefinitionTests +{ + private readonly HiddenTokenDraftDefinition _definition = new(); + + private static readonly Guid Seat0User = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid Seat1User = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + private static GameSetup TwoSeatSetup() => GameSetup.Create( + [new SeatAssignment(0, Seat0User, false), new SeatAssignment(1, Seat1User, false)], "multiplayer"); + + private static Pcg32 FixedRng() => Pcg32.FromSeedParts(42UL, 7UL); + + private static CommandContext ContextFor(int actorSeat, Guid actorUserId, int revision) => + new(Guid.NewGuid(), actorUserId, actorSeat, revision, revision); + + // ── CreateInitialState ───────────────────────────────────────────────── + + [Fact] + public void CreateInitialState_BuildsAFullDeckAndOneEmptyHandPerSeat() + { + var state = _definition.CreateInitialState(TwoSeatSetup(), FixedRng(), CancellationToken.None); + + state.SeatCount.Should().Be(2); + state.CurrentSeat.Should().Be(0); + state.ConsecutivePasses.Should().Be(0); + state.DeckTokens.Should().HaveCount(2 * HiddenTokenDraftDefinition.TokensPerSeat); + state.DeckTokens.Should().OnlyHaveUniqueItems(); + state.Hands.Should().HaveCount(2); + state.Hands.Should().OnlyContain(hand => hand.Count == 0); + } + + [Fact] + public void CreateInitialState_DoesNotConsumeAnyRngDraws() + { + var rng = FixedRng(); + + _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + + rng.Cursor.Should().Be(0UL, "the opening deck is not shuffled — order is fixed, so setup must not touch the RNG stream"); + } + + // ── RNG advancement ───────────────────────────────────────────────────── + + [Fact] + public void ApplyCommand_AcceptedDraw_AdvancesTheRngCursorByExactlyOne() + { + var rng = FixedRng(); + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + + _definition.ApplyCommand(state, new DrawTokenCommand(), ContextFor(0, Seat0User, 0), rng, CancellationToken.None); + + rng.Cursor.Should().Be(1UL); + } + + [Fact] + public void ApplyCommand_AcceptedPass_DoesNotAdvanceTheRngCursor() + { + var rng = FixedRng(); + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + + _definition.ApplyCommand(state, new PassTurnCommand(), ContextFor(0, Seat0User, 0), rng, CancellationToken.None); + + rng.Cursor.Should().Be(0UL); + } + + [Fact] + public void ApplyCommand_RejectedWrongTurn_DoesNotAdvanceTheRngCursor() + { + var rng = FixedRng(); + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + + var decision = _definition.ApplyCommand(state, new DrawTokenCommand(), ContextFor(1, Seat1User, 0), rng, CancellationToken.None); + + decision.Accepted.Should().BeFalse(); + decision.RejectionCode.Should().Be(EngineErrorCode.IllegalActor); + rng.Cursor.Should().Be(0UL); + } + + // ── Turn/state transitions ────────────────────────────────────────────── + + [Fact] + public void ApplyCommand_Draw_MovesOneTokenFromTheDeckIntoTheActingSeatsHand() + { + var rng = FixedRng(); + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + var deckSizeBefore = state.DeckTokens.Count; + + var decision = _definition.ApplyCommand(state, new DrawTokenCommand(), ContextFor(0, Seat0User, 0), rng, CancellationToken.None); + + decision.Accepted.Should().BeTrue(); + decision.NextState!.DeckTokens.Should().HaveCount(deckSizeBefore - 1); + decision.NextState.Hands[0].Should().HaveCount(1); + decision.NextState.Hands[1].Should().BeEmpty(); + } + + [Fact] + public void ApplyCommand_Draw_AdvancesCurrentSeatRoundRobinAndResetsConsecutivePasses() + { + var rng = FixedRng(); + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + var afterPass = _definition.ApplyCommand(state, new PassTurnCommand(), ContextFor(0, Seat0User, 0), rng, CancellationToken.None).NextState!; + + var decision = _definition.ApplyCommand(afterPass, new DrawTokenCommand(), ContextFor(1, Seat1User, 0), rng, CancellationToken.None); + + decision.NextState!.CurrentSeat.Should().Be(0); + decision.NextState.ConsecutivePasses.Should().Be(0); + } + + [Fact] + public void ApplyCommand_OnATerminalState_RejectsInvalidCommand() + { + var rng = FixedRng(); + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + var afterPass0 = _definition.ApplyCommand(state, new PassTurnCommand(), ContextFor(0, Seat0User, 0), rng, CancellationToken.None).NextState!; + var afterPass1 = _definition.ApplyCommand(afterPass0, new PassTurnCommand(), ContextFor(1, Seat1User, 0), rng, CancellationToken.None).NextState!; + + var decision = _definition.ApplyCommand(afterPass1, new PassTurnCommand(), ContextFor(0, Seat0User, 0), rng, CancellationToken.None); + + decision.Accepted.Should().BeFalse(); + decision.RejectionCode.Should().Be(EngineErrorCode.InvalidCommand); + } + + // ── Determinism ────────────────────────────────────────────────────────── + + [Fact] + public void SameSeedAndSameCommandSequence_ProducesIdenticalHandsAcrossTwoIndependentRuns() + { + var commands = new HiddenTokenDraftCommand[] { new DrawTokenCommand(), new DrawTokenCommand(), new DrawTokenCommand(), new DrawTokenCommand() }; + + var handsRunA = PlayFixedSequence(commands); + var handsRunB = PlayFixedSequence(commands); + + handsRunA.Should().BeEquivalentTo(handsRunB, options => options.WithStrictOrdering()); + } + + private List> PlayFixedSequence(IReadOnlyList commands) + { + var rng = FixedRng(); + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + + foreach (var command in commands) + { + var actorUser = state.CurrentSeat == 0 ? Seat0User : Seat1User; + var decision = _definition.ApplyCommand(state, command, ContextFor(state.CurrentSeat, actorUser, 0), rng, CancellationToken.None); + state = decision.NextState!; + } + + return state.Hands; + } + + // ── Projections / hidden-view isolation ──────────────────────────────── + + [Fact] + public void ProjectView_Spectator_HasNoOwnHandButSeesEveryHandSize() + { + var rng = FixedRng(); + var state = MidGameStateWithNonEmptyHands(rng); + + var view = _definition.ProjectView(state, ViewerContext.ForSpectator(), CancellationToken.None); + + view.OwnHand.Should().BeNull(); + view.HandSizes.Should().Equal(state.Hands.Select(h => h.Count)); + } + + [Fact] + public void ProjectView_Player_SeesOnlyItsOwnHandContentsNotAnotherSeats() + { + var rng = FixedRng(); + var state = MidGameStateWithNonEmptyHands(rng); + + var viewSeat0 = _definition.ProjectView(state, ViewerContext.ForPlayer(0, Seat0User), CancellationToken.None); + var viewSeat1 = _definition.ProjectView(state, ViewerContext.ForPlayer(1, Seat1User), CancellationToken.None); + + viewSeat0.OwnHand.Should().Equal(state.Hands[0]); + viewSeat1.OwnHand.Should().Equal(state.Hands[1]); + viewSeat0.OwnHand.Should().NotEqual(viewSeat1.OwnHand); + } + + private HiddenTokenDraftState MidGameStateWithNonEmptyHands(Pcg32 rng) + { + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + var afterSeat0 = _definition.ApplyCommand(state, new DrawTokenCommand(), ContextFor(0, Seat0User, 0), rng, CancellationToken.None).NextState!; + var afterSeat1 = _definition.ApplyCommand(afterSeat0, new DrawTokenCommand(), ContextFor(1, Seat1User, 0), rng, CancellationToken.None).NextState!; + return afterSeat1; + } + + // ── Terminal conditions ────────────────────────────────────────────────── + + [Fact] + public void EvaluateResult_ReturnsNull_WhileDeckIsNonEmptyAndPassesAreBelowSeatCount() + { + var rng = FixedRng(); + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + var afterOnePass = _definition.ApplyCommand(state, new PassTurnCommand(), ContextFor(0, Seat0User, 0), rng, CancellationToken.None).NextState!; + + _definition.EvaluateResult(afterOnePass, CancellationToken.None).Should().BeNull(); + } + + [Fact] + public void EvaluateResult_ATerminal_OnceEverySeatHasPassedConsecutively() + { + var rng = FixedRng(); + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + var afterPass0 = _definition.ApplyCommand(state, new PassTurnCommand(), ContextFor(0, Seat0User, 0), rng, CancellationToken.None).NextState!; + var afterPass1 = _definition.ApplyCommand(afterPass0, new PassTurnCommand(), ContextFor(1, Seat1User, 0), rng, CancellationToken.None).NextState!; + + _definition.EvaluateResult(afterPass1, CancellationToken.None).Should().NotBeNull(); + } + + [Fact] + public void EvaluateResult_ATerminal_OnceTheDeckIsFullyDrained() + { + var rng = FixedRng(); + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + + var totalTokens = state.DeckTokens.Count; + for (var i = 0; i < totalTokens; i++) + { + var actorUser = state.CurrentSeat == 0 ? Seat0User : Seat1User; + var decision = _definition.ApplyCommand(state, new DrawTokenCommand(), ContextFor(state.CurrentSeat, actorUser, 0), rng, CancellationToken.None); + state = decision.NextState!; + } + + state.DeckTokens.Should().BeEmpty(); + _definition.EvaluateResult(state, CancellationToken.None).Should().NotBeNull(); + } + + [Fact] + public void TerminalResult_ScoresAreTheSumOfEachSeatsHandAndTheHighestScoreWins() + { + var rng = FixedRng(); + var state = _definition.CreateInitialState(TwoSeatSetup(), rng, CancellationToken.None); + + var totalTokens = state.DeckTokens.Count; + for (var i = 0; i < totalTokens; i++) + { + var actorUser = state.CurrentSeat == 0 ? Seat0User : Seat1User; + var decision = _definition.ApplyCommand(state, new DrawTokenCommand(), ContextFor(state.CurrentSeat, actorUser, 0), rng, CancellationToken.None); + state = decision.NextState!; + } + + var terminal = _definition.EvaluateResult(state, CancellationToken.None)!; + var expectedScores = state.Hands.Select(hand => hand.Sum()).ToList(); + + terminal.SeatResults.Should().HaveCount(2); + foreach (var seatResult in terminal.SeatResults) + seatResult.Score.Should().Be(expectedScores[seatResult.Seat]); + + var topScore = expectedScores.Max(); + var winnerSeats = terminal.SeatResults.Where(r => r.Outcome == SeatOutcome.Win).Select(r => r.Seat).ToList(); + winnerSeats.Should().OnlyContain(seat => expectedScores[seat] == topScore); + } +} diff --git a/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftPlayerView.cs b/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftPlayerView.cs new file mode 100644 index 0000000..770ab25 --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftPlayerView.cs @@ -0,0 +1,23 @@ +namespace SimPle.UnitTests.GameHost.Reference; + +/// +/// The redacted projection for . Turn/count state is public; only +/// carries hidden information, and it is populated only for the seat it belongs to — a +/// spectator and every other seat always see it as . +/// +public sealed class HiddenTokenDraftPlayerView +{ + public int SeatCount { get; set; } + + public int CurrentSeat { get; set; } + + public int ConsecutivePasses { get; set; } + + public int TokensRemaining { get; set; } + + /// Public hand sizes, index-aligned with seat number. Reveals count, never contents. + public List HandSizes { get; set; } = new(); + + /// The requesting seat's own hand contents, or for a spectator or any other seat. + public List? OwnHand { get; set; } +} diff --git a/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftState.cs b/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftState.cs new file mode 100644 index 0000000..89a4d1b --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftState.cs @@ -0,0 +1,31 @@ +namespace SimPle.UnitTests.GameHost.Reference; + +/// +/// Authoritative state for the test-only reference engine. Plain +/// settable properties (not a record) so the pinned GameHostJsonContext options deserialize it by simple +/// property assignment, with no constructor-parameter-name matching involved. +/// +/// Immutable by convention only: every method that receives a state +/// builds a new instance rather than mutating this one, per IGameDefinition's purity contract. +/// +/// +public sealed class HiddenTokenDraftState +{ + public int SeatCount { get; set; } + + public int CurrentSeat { get; set; } + + /// Consecutive accepted pass commands since the last accepted draw. + public int ConsecutivePasses { get; set; } + + /// + /// Remaining tokens in the shared pool. Draw order is decided lazily: each accepted draw removes one + /// unbiased random element via Pcg32.NextBounded, which is the incremental form of a Fisher-Yates + /// shuffle — equivalent to shuffling the whole deck up front, but the order past the next draw is never + /// materialized (or serialized) before it is actually needed. + /// + public List DeckTokens { get; set; } = new(); + + /// One hand per seat, index-aligned with seat number. Holds every seat's hidden information. + public List> Hands { get; set; } = new(); +} diff --git a/tests/SimPle.UnitTests/GameHost/Support/FakeHostedGameDefinition.cs b/tests/SimPle.UnitTests/GameHost/Support/FakeHostedGameDefinition.cs new file mode 100644 index 0000000..8f9c56d --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/Support/FakeHostedGameDefinition.cs @@ -0,0 +1,42 @@ +using SimPle.Application.GameHost.Services; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost.Support; + +/// +/// A configurable test double for exercising +/// and in isolation from any real typed engine — each method delegates to an +/// injectable callback so a test can simulate a slow, throwing, or oversized-output definition without +/// building a second reference engine. +/// +internal sealed class FakeHostedGameDefinition : IHostedGameDefinition +{ + public GameDefinitionMetadata Metadata { get; } + + public Func? OnCreateInitialState { get; set; } + public Func? OnApplyCommand { get; set; } + public Func? OnProjectView { get; set; } + public Func? OnEvaluateResult { get; set; } + + public FakeHostedGameDefinition(GameDefinitionMetadata metadata) => Metadata = metadata; + + public GameStateEnvelope CreateInitialState(GameSetup setup, UInt128 matchSeed, CancellationToken cancellationToken) => + OnCreateInitialState is not null + ? OnCreateInitialState(setup, matchSeed, cancellationToken) + : throw new NotSupportedException($"{nameof(OnCreateInitialState)} was not configured for this test."); + + public EngineTransition ApplyCommand(GameStateEnvelope state, GameCommandEnvelope command, CancellationToken cancellationToken) => + OnApplyCommand is not null + ? OnApplyCommand(state, command, cancellationToken) + : throw new NotSupportedException($"{nameof(OnApplyCommand)} was not configured for this test."); + + public PlayerViewEnvelope ProjectView(GameStateEnvelope state, ViewerContext viewer, CancellationToken cancellationToken) => + OnProjectView is not null + ? OnProjectView(state, viewer, cancellationToken) + : throw new NotSupportedException($"{nameof(OnProjectView)} was not configured for this test."); + + public TerminalResultCandidate? EvaluateResult(GameStateEnvelope state, CancellationToken cancellationToken) => + OnEvaluateResult is not null + ? OnEvaluateResult(state, cancellationToken) + : throw new NotSupportedException($"{nameof(OnEvaluateResult)} was not configured for this test."); +} diff --git a/tests/SimPle.UnitTests/GameHost/Support/GameStateEnvelopeTestFactory.cs b/tests/SimPle.UnitTests/GameHost/Support/GameStateEnvelopeTestFactory.cs new file mode 100644 index 0000000..a2d54fe --- /dev/null +++ b/tests/SimPle.UnitTests/GameHost/Support/GameStateEnvelopeTestFactory.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using SimPle.Domain.GameHost; + +namespace SimPle.UnitTests.GameHost.Support; + +/// +/// Test-only construction helpers for shapes the public API deliberately +/// cannot produce. always recomputes a self-consistent checksum from the +/// bytes it is given, which is correct for production code but means it cannot express "bytes that were +/// corrupted after the checksum was computed" — exactly the fail-closed path Engine.CorruptState exists +/// to catch. Reflection into the private constructor is the narrowest way to build that scenario without adding +/// a checksum-bypassing factory to the production type. +/// +internal static class GameStateEnvelopeTestFactory +{ + public static GameStateEnvelope WithTamperedChecksum(GameStateEnvelope source) + { + var ctor = typeof(GameStateEnvelope) + .GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(); + + var bogusChecksum = new string('0', source.Checksum.Length) == source.Checksum + ? new string('f', source.Checksum.Length) + : new string('0', source.Checksum.Length); + + return (GameStateEnvelope)ctor.Invoke(new object?[] + { + source.GameSlug, + source.EngineVersion, + source.StateSchemaVersion, + source.Revision, + source.RngAlgorithm, + source.RngState, + source.StateBytes, + bogusChecksum, + }); + } +} diff --git a/tests/SimPle.UnitTests/SimPle.UnitTests.csproj b/tests/SimPle.UnitTests/SimPle.UnitTests.csproj index e52de1a..f47d6b6 100644 --- a/tests/SimPle.UnitTests/SimPle.UnitTests.csproj +++ b/tests/SimPle.UnitTests/SimPle.UnitTests.csproj @@ -30,4 +30,10 @@ + + + + +