Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion coverage.unit.runsettings
Original file line number Diff line number Diff line change
Expand Up @@ -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.*,
Expand Down
43 changes: 43 additions & 0 deletions src/SimPle.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<IGameRegistry>(_ => GameRegistry.Create(Array.Empty<IHostedGameDefinition>()));

builder.Services.AddOptions<JwtSettings>()
.Bind(builder.Configuration.GetSection(JwtSettings.SectionName))
.Validate(settings =>
Expand Down Expand Up @@ -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<IGameRegistry>();
if (gameRegistry.RegisteredDefinitions.Count > 0)
{
var startupDb = startupScope.ServiceProvider.GetRequiredService<AppDbContext>();
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<string>)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<string>()));

var catalogValidator = startupScope.ServiceProvider.GetRequiredService<ICatalogEngineCompatibilityValidator>();
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<ExceptionHandlingMiddleware>();
Expand Down
7 changes: 7 additions & 0 deletions src/SimPle.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,6 +18,12 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped<IPeopleService, PeopleService>();
services.AddScoped<IGamesService, GamesService>();

// 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<IGameHostInvoker, GameHostInvoker>();
services.AddScoped<ICatalogEngineCompatibilityValidator, CatalogEngineCompatibilityValidator>();

return services;
}
}
112 changes: 112 additions & 0 deletions src/SimPle.Application/GameHost/Serialization/GameHostJsonContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using SimPle.Domain.GameHost;

namespace SimPle.Application.GameHost.Serialization;

/// <summary>
/// Thrown when a payload fails the codec's fail-closed checks. Never carries the offending bytes or the raw
/// exception text — only a stable <see cref="EngineErrorCode"/> the caller maps to a client-safe rejection.
/// </summary>
public sealed class GameHostSerializationException(EngineErrorCode code, string message) : Exception(message)
{
public EngineErrorCode Code { get; } = code;
}

/// <summary>
/// The one pinned <see cref="JsonSerializerOptions"/> 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).
/// <para>
/// <b>No CLR type-name polymorphism is ever used.</b> Every call site deserializes into a concrete,
/// caller-selected .NET type (<c>Deserialize&lt;T&gt;</c> with <typeparamref name="object"/> never used as
/// <c>T</c>). A payload's own <c>TCommand</c> union, if a game declares one, is resolved only through
/// compile-time <see cref="System.Text.Json.Serialization.JsonDerivedTypeAttribute"/> string discriminators
/// the game author writes on their own sealed hierarchy — never through a CLR-qualified <c>$type</c> or a
/// reflection-based arbitrary activation. Combined with <see cref="JsonSerializerOptions.UnmappedMemberHandling"/>
/// set to <see cref="System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow"/>, a payload that
/// smuggles a CLR-qualified <c>$type</c>/<c>$id</c> gadget at a non-polymorphic type is rejected as an unmapped
/// member rather than ever reaching a deserializer that would honor it.
/// </para>
/// <para>
/// <b><c>AllowOutOfOrderMetadataProperties</c> is not set anywhere in this codec</b> because the property does
/// not exist on the .NET 8 <see cref="JsonSerializerOptions"/> 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.
/// </para>
/// </summary>
public static class GameHostJsonContext
{
/// <summary>
/// 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 <c>object</c>, not from the
/// resolver strategy.
/// </summary>
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;
}

/// <summary>
/// Serializes <paramref name="value"/> 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 <see cref="Services.GameHostInvoker"/> to map to
/// <see cref="EngineErrorCode.PluginFailure"/>.
/// </summary>
public static byte[] Serialize<T>(T value) => JsonSerializer.SerializeToUtf8Bytes(value, Options);

/// <summary>
/// Deserializes untrusted bytes into the caller-selected concrete type <typeparamref name="T"/>. Every
/// failure mode — malformed JSON, an unknown/renamed/cross-definition discriminator, an unmapped member, a
/// trailing second value — surfaces as <see cref="GameHostSerializationException"/> with a stable
/// <see cref="EngineErrorCode"/> rather than an unmapped <see cref="JsonException"/>, so a caller never
/// needs to catch <see cref="JsonException"/> directly and risk missing a new failure shape.
/// </summary>
public static T Deserialize<T>(ReadOnlySpan<byte> utf8Json, EngineErrorCode onFailure)
{
try
{
var result = JsonSerializer.Deserialize<T>(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.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using SimPle.Domain.GameHost;

namespace SimPle.Application.GameHost.Services;

/// <summary>The one <see cref="ICatalogEngineCompatibilityValidator"/> implementation.</summary>
public sealed class CatalogEngineCompatibilityValidator : ICatalogEngineCompatibilityValidator
{
public IReadOnlyList<CatalogCompatibilityViolation> Validate(
IEnumerable<GameDefinitionMetadata> registeredDefinitions,
IEnumerable<CatalogGameSnapshot> catalogSnapshots)
{
ArgumentNullException.ThrowIfNull(registeredDefinitions);
ArgumentNullException.ThrowIfNull(catalogSnapshots);

var catalogBySlug = catalogSnapshots.ToDictionary(snapshot => snapshot.Slug, StringComparer.Ordinal);
var violations = new List<CatalogCompatibilityViolation>();

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;
}
}
56 changes: 56 additions & 0 deletions src/SimPle.Application/GameHost/Services/CatalogGameSnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
namespace SimPle.Application.GameHost.Services;

/// <summary>
/// A minimal, immutable read of one Module 4 catalog row — <c>Slug</c>, player bounds, and mode capabilities —
/// used only by <see cref="ICatalogEngineCompatibilityValidator"/>.
/// <para>
/// This is deliberately not M4's <c>Game</c> 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
/// <c>Game.Slug</c>/<c>MinPlayers</c>/<c>MaxPlayers</c>/<c>Capabilities[].Mode</c> into this shape.
/// </para>
/// </summary>
public sealed class CatalogGameSnapshot
{
public string Slug { get; }
public int MinPlayers { get; }
public int MaxPlayers { get; }
public IReadOnlySet<string> Modes { get; }

private CatalogGameSnapshot(string slug, int minPlayers, int maxPlayers, IReadOnlySet<string> modes)
{
Slug = slug;
MinPlayers = minPlayers;
MaxPlayers = maxPlayers;
Modes = modes;
}

public static CatalogGameSnapshot Create(string slug, int minPlayers, int maxPlayers, IEnumerable<string> 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<string>(modes, StringComparer.Ordinal));
}
}

/// <summary>One drift signal between a registered engine's metadata and its catalog row's advertised shape.</summary>
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}";
}
Loading
Loading