From 14a4270d1c33e1d0cdae6e801b8ee84ece359d7b Mon Sep 17 00:00:00 2001
From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com>
Date: Sat, 11 Jul 2026 15:08:29 +0300
Subject: [PATCH 1/3] feat(module-05-game-hosting): add GameHost domain
contracts and remove legacy stubs
---
src/SimPle.Domain/GameHost/EngineDecision.cs | 89 ++++++++++
src/SimPle.Domain/GameHost/EngineErrorCode.cs | 48 +++++
src/SimPle.Domain/GameHost/EngineLimits.cs | 34 ++++
src/SimPle.Domain/GameHost/EngineState.cs | 12 ++
.../GameHost/EngineTransition.cs | 107 ++++++++++++
.../GameHost/GameCommandEnvelope.cs | 76 ++++++++
.../GameHost/GameDefinitionMetadata.cs | 122 +++++++++++++
src/SimPle.Domain/GameHost/GameEvent.cs | 55 ++++++
.../GameHost/GameHostContexts.cs | 97 +++++++++++
src/SimPle.Domain/GameHost/GameSession.cs | 79 ---------
.../GameHost/GameStateEnvelope.cs | 103 +++++++++++
src/SimPle.Domain/GameHost/IGameCommand.cs | 20 +++
src/SimPle.Domain/GameHost/IGameDefinition.cs | 81 +++++++++
src/SimPle.Domain/GameHost/IGameEngine.cs | 41 -----
src/SimPle.Domain/GameHost/Pcg32.cs | 164 ++++++++++++++++++
.../GameHost/PlayerViewEnvelope.cs | 93 ++++++++++
.../GameHost/TerminalResultCandidate.cs | 42 +++++
17 files changed, 1143 insertions(+), 120 deletions(-)
create mode 100644 src/SimPle.Domain/GameHost/EngineDecision.cs
create mode 100644 src/SimPle.Domain/GameHost/EngineErrorCode.cs
create mode 100644 src/SimPle.Domain/GameHost/EngineLimits.cs
create mode 100644 src/SimPle.Domain/GameHost/EngineState.cs
create mode 100644 src/SimPle.Domain/GameHost/EngineTransition.cs
create mode 100644 src/SimPle.Domain/GameHost/GameCommandEnvelope.cs
create mode 100644 src/SimPle.Domain/GameHost/GameDefinitionMetadata.cs
create mode 100644 src/SimPle.Domain/GameHost/GameEvent.cs
create mode 100644 src/SimPle.Domain/GameHost/GameHostContexts.cs
delete mode 100644 src/SimPle.Domain/GameHost/GameSession.cs
create mode 100644 src/SimPle.Domain/GameHost/GameStateEnvelope.cs
create mode 100644 src/SimPle.Domain/GameHost/IGameCommand.cs
create mode 100644 src/SimPle.Domain/GameHost/IGameDefinition.cs
delete mode 100644 src/SimPle.Domain/GameHost/IGameEngine.cs
create mode 100644 src/SimPle.Domain/GameHost/Pcg32.cs
create mode 100644 src/SimPle.Domain/GameHost/PlayerViewEnvelope.cs
create mode 100644 src/SimPle.Domain/GameHost/TerminalResultCandidate.cs
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);
+}
From b4faba3d486f696c15501e7c73c00c56b3fbef37 Mon Sep 17 00:00:00 2001
From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com>
Date: Sat, 11 Jul 2026 15:08:40 +0300
Subject: [PATCH 2/3] feat(module-05-game-hosting): add GameHost application
services and wire composition root
---
src/SimPle.Api/Program.cs | 43 ++++
src/SimPle.Application/DependencyInjection.cs | 7 +
.../Serialization/GameHostJsonContext.cs | 112 ++++++++++
.../CatalogEngineCompatibilityValidator.cs | 42 ++++
.../GameHost/Services/CatalogGameSnapshot.cs | 56 +++++
.../GameHost/Services/GameHostInvoker.cs | 209 ++++++++++++++++++
.../GameHost/Services/GameHostResult.cs | 29 +++
.../GameHost/Services/GameRegistry.cs | 55 +++++
.../GameHost/Services/HostedGameDefinition.cs | 154 +++++++++++++
.../ICatalogEngineCompatibilityValidator.cs | 22 ++
.../GameHost/Services/IGameHostInvoker.cs | 29 +++
.../GameHost/Services/IGameRegistry.cs | 16 +-
.../Services/IHostedGameDefinition.cs | 50 +++++
13 files changed, 820 insertions(+), 4 deletions(-)
create mode 100644 src/SimPle.Application/GameHost/Serialization/GameHostJsonContext.cs
create mode 100644 src/SimPle.Application/GameHost/Services/CatalogEngineCompatibilityValidator.cs
create mode 100644 src/SimPle.Application/GameHost/Services/CatalogGameSnapshot.cs
create mode 100644 src/SimPle.Application/GameHost/Services/GameHostInvoker.cs
create mode 100644 src/SimPle.Application/GameHost/Services/GameHostResult.cs
create mode 100644 src/SimPle.Application/GameHost/Services/GameRegistry.cs
create mode 100644 src/SimPle.Application/GameHost/Services/HostedGameDefinition.cs
create mode 100644 src/SimPle.Application/GameHost/Services/ICatalogEngineCompatibilityValidator.cs
create mode 100644 src/SimPle.Application/GameHost/Services/IGameHostInvoker.cs
create mode 100644 src/SimPle.Application/GameHost/Services/IHostedGameDefinition.cs
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);
+}
From 764bee343f1c9485e0f5e477f72e3305cd38738e Mon Sep 17 00:00:00 2001
From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com>
Date: Sat, 11 Jul 2026 15:08:48 +0300
Subject: [PATCH 3/3] test(module-05-game-hosting): add GameHost unit and
integration tests
---
coverage.unit.runsettings | 1 -
.../GameHostCatalogValidationStartupTests.cs | 83 +++++
.../GameHost/GameHostCompositionRootTests.cs | 107 +++++++
.../GameHost/GameHostNoEfDeltaTests.cs | 61 ++++
.../GameHostSerializerHardeningTests.cs | 75 +++++
.../GameHostTestWebApplicationFactory.cs | 134 ++++++++
.../SimPle.IntegrationTests.csproj | 5 +
.../GameHost/Benchmark/GameHostBenchmark.cs | 134 ++++++++
.../Benchmark/GameHostBenchmarkTests.cs | 30 ++
...atalogEngineCompatibilityValidatorTests.cs | 106 +++++++
.../GameHost/EngineErrorCodeTests.cs | 53 ++++
.../GameHost/EngineLimitsTests.cs | 47 +++
.../GameHost/EngineTransitionTests.cs | 199 ++++++++++++
.../GameHost/GameCommandEnvelopeTests.cs | 105 ++++++
.../GameHost/GameDefinitionMetadataTests.cs | 129 ++++++++
.../GameHost/GameHostContextsTests.cs | 112 +++++++
.../GameHost/GameHostInvokerTests.cs | 298 ++++++++++++++++++
.../GameHost/GameHostJsonContextTests.cs | 134 ++++++++
.../GameHost/GameRegistryTests.cs | 101 ++++++
.../GameHost/GameStateEnvelopeTests.cs | 106 +++++++
.../GameHost/GoldenVectors/GoldenVectorDto.cs | 66 ++++
.../GoldenVectors/GoldenVectorGenerator.cs | 39 +++
.../GoldenVectors/GoldenVectorJson.cs | 13 +
.../GoldenVectorManifestTests.cs | 55 ++++
.../GoldenVectors/GoldenVectorTests.cs | 98 ++++++
.../HiddenTokenDraftGoldenVectorScenario.cs | 206 ++++++++++++
.../GoldenVectors/accepted-command.json | 24 ++
.../GoldenVectors/corrupt-checksum.json | 16 +
.../GoldenVectors/initial-envelope.json | 12 +
.../GoldenVectors/rejected-command.json | 11 +
.../GoldenVectors/terminal-result.json | 14 +
.../GoldenVectors/unsupported-version.json | 16 +
.../GameHost/GoldenVectors/view-seat-0.json | 9 +
.../GameHost/GoldenVectors/view-seat-1.json | 9 +
.../GameHost/GoldenVectors/view-seat-2.json | 9 +
.../GoldenVectors/view-spectator.json | 9 +
.../HostedGameDefinitionAdapterTests.cs | 182 +++++++++++
tests/SimPle.UnitTests/GameHost/Pcg32Tests.cs | 188 +++++++++++
.../GameHost/PlayerViewEnvelopeTests.cs | 121 +++++++
.../Reference/HiddenTokenDraftCommand.cs | 29 ++
.../Reference/HiddenTokenDraftDefinition.cs | 187 +++++++++++
.../HiddenTokenDraftDefinitionTests.cs | 264 ++++++++++++++++
.../Reference/HiddenTokenDraftPlayerView.cs | 23 ++
.../Reference/HiddenTokenDraftState.cs | 31 ++
.../Support/FakeHostedGameDefinition.cs | 42 +++
.../Support/GameStateEnvelopeTestFactory.cs | 38 +++
.../SimPle.UnitTests/SimPle.UnitTests.csproj | 6 +
47 files changed, 3736 insertions(+), 1 deletion(-)
create mode 100644 tests/SimPle.IntegrationTests/GameHost/GameHostCatalogValidationStartupTests.cs
create mode 100644 tests/SimPle.IntegrationTests/GameHost/GameHostCompositionRootTests.cs
create mode 100644 tests/SimPle.IntegrationTests/GameHost/GameHostNoEfDeltaTests.cs
create mode 100644 tests/SimPle.IntegrationTests/GameHost/GameHostSerializerHardeningTests.cs
create mode 100644 tests/SimPle.IntegrationTests/GameHost/GameHostTestWebApplicationFactory.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/Benchmark/GameHostBenchmark.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/Benchmark/GameHostBenchmarkTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/CatalogEngineCompatibilityValidatorTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/EngineErrorCodeTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/EngineLimitsTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/EngineTransitionTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GameCommandEnvelopeTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GameDefinitionMetadataTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GameHostContextsTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GameHostInvokerTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GameHostJsonContextTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GameRegistryTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GameStateEnvelopeTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorDto.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorGenerator.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorJson.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorManifestTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/GoldenVectorTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/HiddenTokenDraftGoldenVectorScenario.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/accepted-command.json
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/corrupt-checksum.json
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/initial-envelope.json
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/rejected-command.json
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/terminal-result.json
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/unsupported-version.json
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-0.json
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-1.json
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/view-seat-2.json
create mode 100644 tests/SimPle.UnitTests/GameHost/GoldenVectors/view-spectator.json
create mode 100644 tests/SimPle.UnitTests/GameHost/HostedGameDefinitionAdapterTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/Pcg32Tests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/PlayerViewEnvelopeTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftCommand.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftDefinition.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftDefinitionTests.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftPlayerView.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/Reference/HiddenTokenDraftState.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/Support/FakeHostedGameDefinition.cs
create mode 100644 tests/SimPle.UnitTests/GameHost/Support/GameStateEnvelopeTestFactory.cs
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/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