diff --git a/src/SimPle.Api/Controllers/GamesController.cs b/src/SimPle.Api/Controllers/GamesController.cs
new file mode 100644
index 0000000..d9868eb
--- /dev/null
+++ b/src/SimPle.Api/Controllers/GamesController.cs
@@ -0,0 +1,210 @@
+using System.IdentityModel.Tokens.Jwt;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+using SimPle.Api.Models;
+using SimPle.Application.Games.DTOs;
+using SimPle.Application.Games.Services;
+using SimPle.Shared.Common;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace SimPle.Api.Controllers;
+
+///
+/// Public catalog reads (list/detail/featured) are anonymous, auth-independent, and cache-headered per spec
+/// deviation D1 (Vary: Cookie, not Vary: Authorization — this app authenticates via cookie only,
+/// see ProfileController.cs:112-115). Favorites are authenticated, ownership-scoped from the JWT
+/// sub claim only (never a request body id), and never cached.
+///
+[ApiController]
+[Route("api/games")]
+[Produces("application/json")]
+[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status500InternalServerError)]
+public sealed class GamesController : ControllerBase
+{
+ private readonly IGamesService _games;
+
+ public GamesController(IGamesService games)
+ {
+ _games = games;
+ }
+
+ // ── Public catalog reads ────────────────────────────────────────────────
+
+ [HttpGet]
+ [EnableRateLimiting("catalog-read")]
+ [SwaggerOperation(Summary = "List/search the game catalog (keyset cursor paged)",
+ OperationId = "Games_List", Tags = new[] { "Games" })]
+ [ProducesResponseType(typeof(CursorPage), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status304NotModified)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task List(
+ [FromQuery] string? query,
+ [FromQuery] string[]? category,
+ [FromQuery] string[]? tag,
+ [FromQuery] string[]? mode,
+ [FromQuery] string[]? lifecycle,
+ [FromQuery] string? sort,
+ [FromQuery] int limit = 24,
+ [FromQuery] string? after = null,
+ CancellationToken ct = default)
+ {
+ var result = await _games.ListAsync(query, category, tag, mode, lifecycle, sort, limit, after, ct);
+ if (!result.IsSuccess) return MapError(result.Error!);
+
+ var (page, etag) = result.Value!;
+ return IfNoneMatchMatches(etag) ? NotModified(etag) : CachedOk(page, etag);
+ }
+
+ [HttpGet("featured")]
+ [EnableRateLimiting("catalog-read")]
+ [SwaggerOperation(Summary = "Get the single featured game, or none",
+ OperationId = "Games_GetFeatured", Tags = new[] { "Games" })]
+ [ProducesResponseType(typeof(GameCatalogDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status304NotModified)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task GetFeatured(CancellationToken ct)
+ {
+ var result = await _games.GetFeaturedAsync(ct);
+ var featured = result.Value!;
+ if (featured.Game is null) return NoContent();
+
+ return IfNoneMatchMatches(featured.ETag!) ? NotModified(featured.ETag!) : CachedOk(featured.Game, featured.ETag!);
+ }
+
+ [HttpGet("{slug}")]
+ [EnableRateLimiting("catalog-read")]
+ [SwaggerOperation(Summary = "Get a single game by slug",
+ OperationId = "Games_GetBySlug", Tags = new[] { "Games" })]
+ [ProducesResponseType(typeof(GameCatalogDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status304NotModified)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(GameTombstoneDto), StatusCodes.Status410Gone)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task GetBySlug([FromRoute] string slug, CancellationToken ct)
+ {
+ var result = await _games.GetDetailAsync(slug, ct);
+ if (!result.IsSuccess) return MapError(result.Error!);
+
+ var detail = result.Value!;
+ // 410 carries the minimal tombstone DTO directly (not the ApiErrorResponse envelope) per the spec's
+ // DTOs section; cache headers apply only to 200/304, never to 410.
+ if (detail.Tombstone is not null) return StatusCode(StatusCodes.Status410Gone, detail.Tombstone);
+
+ return IfNoneMatchMatches(detail.ETag!) ? NotModified(detail.ETag!) : CachedOk(detail.Game, detail.ETag!);
+ }
+
+ // ── Authenticated favorites ─────────────────────────────────────────────
+
+ [HttpGet("me/favorites")]
+ [Authorize]
+ [EnableRateLimiting("game-favorites")]
+ [SwaggerOperation(Summary = "List the authenticated user's active favorites (keyset cursor paged)",
+ OperationId = "Games_GetFavorites", Tags = new[] { "Games" })]
+ [ProducesResponseType(typeof(CursorPage), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task GetFavorites(
+ [FromQuery] int limit = 24, [FromQuery] string? after = null, CancellationToken ct = default)
+ {
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _games.GetFavoritesAsync(userId, limit, after, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ [HttpPut("me/favorites/{slug}")]
+ [Authorize]
+ [EnableRateLimiting("game-favorites")]
+ [SwaggerOperation(Summary = "Favorite a game (idempotent — identical DTO on repeat calls)",
+ OperationId = "Games_PutFavorite", Tags = new[] { "Games" })]
+ [ProducesResponseType(typeof(GameFavoriteDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task PutFavorite([FromRoute] string slug, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _games.PutFavoriteAsync(userId, slug, ct);
+ return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
+ }
+
+ [HttpDelete("me/favorites/{slug}")]
+ [Authorize]
+ [EnableRateLimiting("game-favorites")]
+ [SwaggerOperation(Summary = "Unfavorite a game (idempotent — 204 whether present, absent, or already inactive)",
+ OperationId = "Games_DeleteFavorite", Tags = new[] { "Games" })]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
+ public async Task DeleteFavorite([FromRoute] string slug, CancellationToken ct)
+ {
+ if (!HasCsrfHeader()) return MissingCsrfHeader();
+ if (!TryGetUserId(out var userId)) return Unauthorized();
+
+ Response.Headers.CacheControl = "private, no-store";
+ var result = await _games.DeleteFavoriteAsync(userId, slug, ct);
+ return result.IsSuccess ? NoContent() : MapError(result.Error!);
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private const string CsrfHeader = "X-Requested-With";
+ private const string CsrfHeaderValue = "XMLHttpRequest";
+
+ private bool HasCsrfHeader() =>
+ string.Equals(Request.Headers[CsrfHeader], CsrfHeaderValue, StringComparison.Ordinal);
+
+ private IActionResult MissingCsrfHeader() => BadRequest(Error(
+ "Auth.CsrfHeaderRequired",
+ $"The {CsrfHeader} header is required for this request."));
+
+ private bool TryGetUserId(out Guid userId) =>
+ Guid.TryParse(User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value, out userId);
+
+ private bool IfNoneMatchMatches(string etag)
+ {
+ var header = Request.Headers.IfNoneMatch.ToString();
+ if (string.IsNullOrEmpty(header)) return false;
+ return header.Split(',').Select(v => v.Trim()).Any(v => v == etag || v == "*");
+ }
+
+ private IActionResult NotModified(string etag)
+ {
+ Response.Headers.ETag = etag;
+ Response.Headers.CacheControl = "public, max-age=60";
+ Response.Headers.Vary = "Cookie";
+ return StatusCode(StatusCodes.Status304NotModified);
+ }
+
+ private IActionResult CachedOk(T value, string etag)
+ {
+ Response.Headers.ETag = etag;
+ Response.Headers.CacheControl = "public, max-age=60";
+ Response.Headers.Vary = "Cookie";
+ return Ok(value);
+ }
+
+ /// Games.NotFound -> 404, Games.Retired -> 409 (new-favorite rejection only; the detail-read
+ /// 410 tombstone path never reaches this switch), everything else (Validation.Failed,
+ /// Pagination.InvalidCursor) -> 400.
+ private IActionResult MapError(Error error) =>
+ error.Code switch
+ {
+ "Games.NotFound" => NotFound(Error(error.Code, error.Message)),
+ "Games.Retired" => Conflict(Error(error.Code, error.Message)),
+ _ => BadRequest(Error(error.Code, error.Message)),
+ };
+
+ private static ApiErrorResponse Error(string code, string message) =>
+ new(new ApiErrorDetail(code, message));
+}
diff --git a/src/SimPle.Api/Program.cs b/src/SimPle.Api/Program.cs
index 1206aa6..eb2aaf1 100644
--- a/src/SimPle.Api/Program.cs
+++ b/src/SimPle.Api/Program.cs
@@ -20,6 +20,8 @@
using SimPle.Application.Common.Options;
using SimPle.Infrastructure;
using SimPle.Infrastructure.Auth;
+using SimPle.Infrastructure.Games;
+using SimPle.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
@@ -271,22 +273,44 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse(
options.AddPolicy("profile-friends", context => FriendWindow(context, "pfrd", 120, TimeSpan.FromMinutes(1)));
options.AddPolicy("profile-mutual-friends", context => FriendWindow(context, "pmut", 120, TimeSpan.FromMinutes(1)));
- // Chained per-IP ceiling for discovery/people-search (spec: 30/min/account + 120/hour/IP). Scoped by
- // request path so it only "bites" on these two routes; every other route gets a permanent no-op lease.
+ // Game catalog policies (per IP for anonymous public reads; per account, IP-fallback for favorites).
+ options.AddPolicy("catalog-read", context => AuthWindow(context, 120, TimeSpan.FromMinutes(1)));
+ options.AddPolicy("game-favorites", context => FriendWindow(context, "gfav", 60, TimeSpan.FromMinutes(1)));
+
+ // Chained per-IP ceiling for discovery/people-search (spec: 30/min/account + 120/hour/IP), and for
+ // catalog search (spec: 120/min/IP catalog-read + 30/min/IP catalog-search when `query` is present).
+ // Scoped by request path/query so it only "bites" on these routes; every other route gets a permanent
+ // no-op lease.
options.GlobalLimiter = PartitionedRateLimiter.Create(context =>
{
var path = context.Request.Path;
- if (!path.StartsWithSegments("/api/people/search") && !path.StartsWithSegments("/api/friends/discovery"))
- return RateLimitPartition.GetNoLimiter("no-limit");
-
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
- return RateLimitPartition.GetFixedWindowLimiter($"people-ip:{ip}", _ => new FixedWindowRateLimiterOptions
+
+ if (path.StartsWithSegments("/api/people/search") || path.StartsWithSegments("/api/friends/discovery"))
{
- PermitLimit = 120,
- Window = TimeSpan.FromHours(1),
- QueueLimit = 0,
- QueueProcessingOrder = QueueProcessingOrder.OldestFirst
- });
+ return RateLimitPartition.GetFixedWindowLimiter($"people-ip:{ip}", _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 120,
+ Window = TimeSpan.FromHours(1),
+ QueueLimit = 0,
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst
+ });
+ }
+
+ if (HttpMethods.IsGet(context.Request.Method) &&
+ path.StartsWithSegments("/api/games", out var remaining) && remaining == PathString.Empty &&
+ !string.IsNullOrEmpty(context.Request.Query["query"]))
+ {
+ return RateLimitPartition.GetFixedWindowLimiter($"catalog-search-ip:{ip}", _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 30,
+ Window = TimeSpan.FromMinutes(1),
+ QueueLimit = 0,
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst
+ });
+ }
+
+ return RateLimitPartition.GetNoLimiter("no-limit");
});
});
@@ -348,6 +372,17 @@ static RateLimitPartition FriendWindow(HttpContext context, string prefi
});
}
+if (args.Contains("--seed-game-catalog"))
+{
+ using var scope = app.Services.CreateScope();
+ var seedDb = scope.ServiceProvider.GetRequiredService();
+ var seedLogger = scope.ServiceProvider.GetRequiredService().CreateLogger();
+ var seeder = new GameCatalogSeeder(seedDb, seedLogger);
+ var seedResult = seeder.SeedAsync().GetAwaiter().GetResult();
+ Console.WriteLine(seedResult.Message);
+ Environment.Exit(seedResult.Success ? 0 : 1);
+}
+
app.Run();
public partial class Program { }
diff --git a/src/SimPle.Application/Common/Interfaces/IGameRepository.cs b/src/SimPle.Application/Common/Interfaces/IGameRepository.cs
new file mode 100644
index 0000000..d66ae79
--- /dev/null
+++ b/src/SimPle.Application/Common/Interfaces/IGameRepository.cs
@@ -0,0 +1,61 @@
+using SimPle.Application.Games;
+using SimPle.Domain.Games;
+using SimPle.Domain.Outbox;
+
+namespace SimPle.Application.Common.Interfaces;
+
+///
+/// Fully normalized/validated catalog query, built by
+/// and consumed by GameRepository. / are the decoded
+/// keyset position (null on the first page); their string shape is defined by .
+///
+public sealed record GameCatalogFilter(
+ string? NormalizedSearch,
+ IReadOnlyList Categories,
+ IReadOnlyList Tags,
+ IReadOnlyList Modes,
+ IReadOnlyList Lifecycles,
+ string Sort,
+ int Limit,
+ string? AfterSortKey,
+ string? AfterSlug);
+
+///
+/// Data access for the game catalog and favorites. List/detail/featured reads are budgeted at exactly two SQL
+/// round trips: //
+/// fetch the game row(s) only, and fetches tags and mode
+/// capabilities for those ids in a single hand-written UNION ALL query (EF's AsSplitQuery would cost a
+/// third round trip, which the spec's performance budget forbids).
+///
+public interface IGameRepository
+{
+ Task> GetCatalogPageAsync(GameCatalogFilter filter, CancellationToken ct = default);
+
+ /// Flat (GameId, Kind, Value) rows for the given ids, Kind is "tag" or "mode".
+ Task> GetTagsAndCapabilitiesAsync(
+ IReadOnlyList gameIds, CancellationToken ct = default);
+
+ /// Any lifecycle, including Draft/Retired — the service decides visibility (404 vs 410).
+ Task GetBySlugAsync(string slug, CancellationToken ct = default);
+
+ /// The single FeaturedRank = 1 game, or null when none exists (204 case).
+ Task GetFeaturedAsync(CancellationToken ct = default);
+
+ // ── Favorites ────────────────────────────────────────────────────────────
+
+ /// Active favorites ordered by (UpdatedAt DESC, Id DESC).
+ Task> GetFavoritesPageAsync(
+ Guid userId, int limit, DateTime? afterFavoritedAt, Guid? afterId, CancellationToken ct = default);
+
+ Task GetFavoriteAsync(Guid userId, Guid gameId, CancellationToken ct = default);
+
+ /// Brand-new (UserId, GameId) row. Conflict = a concurrent first favorite already won the race.
+ Task AddFavoriteAsync(UserFavoriteGame favorite, OutboxMessage evt, CancellationToken ct = default);
+
+ ///
+ /// Toggles an existing row (Refavorite/Unfavorite already called by the caller). ConcurrencyConflict means
+ /// the outbox's unique (AggregateId, EventType, AggregateDomainVersion) index rejected a retried transition
+ /// or a racing writer already applied it — the caller re-reads and converges.
+ ///
+ Task UpdateFavoriteAsync(UserFavoriteGame favorite, OutboxMessage evt, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Application/Common/Pagination/Cursor.cs b/src/SimPle.Application/Common/Pagination/Cursor.cs
index 22aa860..3b06494 100644
--- a/src/SimPle.Application/Common/Pagination/Cursor.cs
+++ b/src/SimPle.Application/Common/Pagination/Cursor.cs
@@ -107,6 +107,29 @@ public static bool TryDecodeProfileList(
return true;
}
+ // ── Game catalog cursor: (sortKey, Slug) position, bound to a hash of the normalized query shape
+ // (search text, category/tag/mode/lifecycle filters, sort) so a cursor cannot be replayed after the
+ // filter/sort shape changes — it must fail with Pagination.InvalidCursor rather than blend result sets.
+ // sortKey is a caller-formatted, order-preserving string representation of the active sort's key(s)
+ // (e.g. the default order's zero-padded FeaturedRank/SortOrder composite, or the uppercased Name).
+
+ public static string EncodeCatalog(string sortKey, string slug, string queryShapeHash) =>
+ ToBase64Url($"{ToBase64Url(sortKey)}{Sep}{ToBase64Url(slug)}{Sep}{ToBase64Url(queryShapeHash)}");
+
+ public static bool TryDecodeCatalog(string? cursor, out string sortKey, out string slug, out string queryShapeHash)
+ {
+ sortKey = string.Empty;
+ slug = string.Empty;
+ queryShapeHash = string.Empty;
+ if (!TryFromBase64Url(cursor, out var raw)) return false;
+ var parts = raw.Split(Sep);
+ if (parts.Length != 3) return false;
+ if (!TryFromBase64Url(parts[0], out sortKey)) return false;
+ if (!TryFromBase64Url(parts[1], out slug)) return false;
+ if (!TryFromBase64Url(parts[2], out queryShapeHash)) return false;
+ return true;
+ }
+
// ── base64url (RFC 4648 §5) without external dependencies ──
private static string ToBase64Url(string value)
diff --git a/src/SimPle.Application/DependencyInjection.cs b/src/SimPle.Application/DependencyInjection.cs
index 9f29739..4545f94 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.Games.Services;
using SimPle.Application.People.Services;
using SimPle.Application.Profiles.Services;
@@ -14,6 +15,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/src/SimPle.Application/Games/DTOs/GameCatalogDto.cs b/src/SimPle.Application/Games/DTOs/GameCatalogDto.cs
new file mode 100644
index 0000000..bbe177e
--- /dev/null
+++ b/src/SimPle.Application/Games/DTOs/GameCatalogDto.cs
@@ -0,0 +1,28 @@
+namespace SimPle.Application.Games.DTOs;
+
+///
+/// Public catalog/detail/featured DTO. Auth-independent — byte-identical for anonymous and authenticated
+/// callers, which is what makes Cache-Control: public, max-age=60 safe (see spec D1). Deliberately
+/// excludes isFavorited, online/presence counts, lastPlayed, stats, and ELO — favorite state
+/// comes only from the private favorites endpoint and is merged client-side.
+///
+public sealed record GameCatalogDto(
+ string Slug,
+ string Name,
+ string Summary,
+ string RulesSummary,
+ string Category,
+ IReadOnlyList Tags,
+ string Difficulty,
+ int EstimatedDurationMinMinutes,
+ int EstimatedDurationMaxMinutes,
+ int MinPlayers,
+ int MaxPlayers,
+ string Lifecycle,
+ IReadOnlyList Capabilities,
+ int? FeaturedRank,
+ string ArtToken,
+ string ArtColorA,
+ string ArtColorB,
+ string ArtAltText,
+ IReadOnlyList EntryActions);
diff --git a/src/SimPle.Application/Games/DTOs/GameEntryActionDto.cs b/src/SimPle.Application/Games/DTOs/GameEntryActionDto.cs
new file mode 100644
index 0000000..15638bf
--- /dev/null
+++ b/src/SimPle.Application/Games/DTOs/GameEntryActionDto.cs
@@ -0,0 +1,8 @@
+namespace SimPle.Application.Games.DTOs;
+
+///
+/// One entry-point action on a catalog/detail DTO. In M4 every action is deferred — no engine exists
+/// yet — and names the module that will flip it to enabled after its own
+/// backend and E2E gate pass. Never mutated per-request; the same fixed set is projected for every game.
+///
+public sealed record GameEntryActionDto(string Action, string Status, string ReasonCode, int OwnerModule);
diff --git a/src/SimPle.Application/Games/DTOs/GameFavoriteDto.cs b/src/SimPle.Application/Games/DTOs/GameFavoriteDto.cs
new file mode 100644
index 0000000..52f13a7
--- /dev/null
+++ b/src/SimPle.Application/Games/DTOs/GameFavoriteDto.cs
@@ -0,0 +1,14 @@
+namespace SimPle.Application.Games.DTOs;
+
+///
+/// Private favorites-list / favorite-mutation DTO. Never shared-cached (private, no-store).
+///
+public sealed record GameFavoriteDto(
+ string Slug,
+ string Name,
+ string Lifecycle,
+ string ArtToken,
+ string ArtColorA,
+ string ArtColorB,
+ string ArtAltText,
+ DateTime FavoritedAt);
diff --git a/src/SimPle.Application/Games/DTOs/GameTombstoneDto.cs b/src/SimPle.Application/Games/DTOs/GameTombstoneDto.cs
new file mode 100644
index 0000000..1229fd4
--- /dev/null
+++ b/src/SimPle.Application/Games/DTOs/GameTombstoneDto.cs
@@ -0,0 +1,7 @@
+namespace SimPle.Application.Games.DTOs;
+
+///
+/// Minimal shape returned for a Retired game detail read (410). Carries only enough for the client to render
+/// an honest "this game is retired" state — no summary, no tags, no capabilities, no art.
+///
+public sealed record GameTombstoneDto(string Slug, string Name, string Lifecycle, string ReasonCode);
diff --git a/src/SimPle.Application/Games/FavoriteOutcomes.cs b/src/SimPle.Application/Games/FavoriteOutcomes.cs
new file mode 100644
index 0000000..7b5e297
--- /dev/null
+++ b/src/SimPle.Application/Games/FavoriteOutcomes.cs
@@ -0,0 +1,5 @@
+namespace SimPle.Application.Games;
+
+public enum AddFavoriteOutcome { Added, Conflict }
+
+public enum UpdateFavoriteOutcome { Updated, ConcurrencyConflict }
diff --git a/src/SimPle.Application/Games/GameCatalogSortKey.cs b/src/SimPle.Application/Games/GameCatalogSortKey.cs
new file mode 100644
index 0000000..001827f
--- /dev/null
+++ b/src/SimPle.Application/Games/GameCatalogSortKey.cs
@@ -0,0 +1,61 @@
+using SimPle.Domain.Games;
+
+namespace SimPle.Application.Games;
+
+///
+/// Shared encode/decode contract for the four allow-listed catalog sorts' opaque cursor sort-key (paired with
+/// /
+/// ).
+/// encodes the last-emitted row's key into the outgoing cursor; GameRepository decodes an incoming
+/// cursor's key back into the typed keyset predicate for whichever sort is active.
+///
+public static class GameCatalogSortKey
+{
+ public const string Default = "default";
+ public const string Name = "name";
+ public const string Difficulty = "difficulty";
+ public const string Duration = "duration";
+
+ public static readonly IReadOnlyList AllowedSorts = new[] { Default, Name, Difficulty, Duration };
+
+ ///
+ /// Stands in for a null so the default sort's key is a single, totally
+ /// ordered, fixed-width string (Postgres ASC already sorts NULLS LAST, which this sentinel mirrors: it is
+ /// larger than any real rank, so encoded null rows sort after every real rank when compared as text).
+ ///
+ public const int NoFeaturedRank = int.MaxValue;
+
+ public static string Encode(string sort, Game game) => sort switch
+ {
+ Default => $"{(game.FeaturedRank ?? NoFeaturedRank):D10}{game.SortOrder:D10}",
+ Name => game.Name.ToUpperInvariant(),
+ Difficulty => $"{DifficultyRank(game.Difficulty):D2}",
+ Duration => $"{game.EstimatedDurationMinMinutes:D10}",
+ _ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown sort."),
+ };
+
+ /// Easy/Medium/Hard progression, independent of the column's string storage (see GameConfiguration).
+ public static int DifficultyRank(GameDifficulty difficulty) => difficulty switch
+ {
+ GameDifficulty.Easy => 0,
+ GameDifficulty.Medium => 1,
+ GameDifficulty.Hard => 2,
+ _ => throw new ArgumentOutOfRangeException(nameof(difficulty), difficulty, "Unknown difficulty."),
+ };
+
+ public static bool TryDecodeDefault(string sortKey, out int featuredRank, out int sortOrder)
+ {
+ featuredRank = default;
+ sortOrder = default;
+ if (sortKey.Length != 20) return false;
+ if (!int.TryParse(sortKey.AsSpan(0, 10), out featuredRank)) return false;
+ if (!int.TryParse(sortKey.AsSpan(10, 10), out sortOrder)) return false;
+ return true;
+ }
+
+ public static bool TryDecodeDifficulty(string sortKey, out int rank) =>
+ int.TryParse(sortKey, out rank) && rank is >= 0 and <= 2;
+
+ public static bool TryDecodeDuration(string sortKey, out int minutes) =>
+ int.TryParse(sortKey, out minutes) && minutes >= 0;
+}
diff --git a/src/SimPle.Application/Games/GameEntryActions.cs b/src/SimPle.Application/Games/GameEntryActions.cs
new file mode 100644
index 0000000..32e6994
--- /dev/null
+++ b/src/SimPle.Application/Games/GameEntryActions.cs
@@ -0,0 +1,20 @@
+using SimPle.Application.Games.DTOs;
+
+namespace SimPle.Application.Games;
+
+///
+/// The fixed set of entry-point actions projected onto every catalog/detail DTO. In M4 every action is
+/// deferred — no game engine exists yet — per the spec's entry-actions table. A later module flips its
+/// own action to enabled only after its backend and E2E gate pass; this list is never mutated per-request.
+///
+public static class GameEntryActions
+{
+ public static readonly IReadOnlyList All = new[]
+ {
+ new GameEntryActionDto("play-vs-ai", "deferred", "Games.EntryDeferred.AI", 9),
+ new GameEntryActionDto("quick-match", "deferred", "Games.EntryDeferred.QuickMatch", 6),
+ new GameEntryActionDto("create-lobby", "deferred", "Games.EntryDeferred.Lobby", 6),
+ new GameEntryActionDto("invite-friend", "deferred", "Games.EntryDeferred.Invite", 6),
+ new GameEntryActionDto("enter-match-room", "deferred", "Games.EntryDeferred.MatchRoom", 8),
+ };
+}
diff --git a/src/SimPle.Application/Games/Outbox/GameOutbox.cs b/src/SimPle.Application/Games/Outbox/GameOutbox.cs
new file mode 100644
index 0000000..5547d26
--- /dev/null
+++ b/src/SimPle.Application/Games/Outbox/GameOutbox.cs
@@ -0,0 +1,52 @@
+using System.Text.Json;
+using SimPle.Domain.Games;
+using SimPle.Domain.Outbox;
+
+namespace SimPle.Application.Games.Outbox;
+
+///
+/// Builds the three Module 4 integration events as immutable rows, mirroring
+/// . Every payload carries minimum ids only — no
+/// search text, no favorite contents. Each message is staged inside the same transaction as the aggregate
+/// mutation; the unique (AggregateId, EventType, AggregateDomainVersion) index makes a retried transition
+/// idempotent. No consumer exists yet (arrives with M7/M10/M11) — that is expected, not a gap.
+///
+public static class GameOutbox
+{
+ public const int EventVersion = 1;
+ private const string FavoriteAggregate = "UserFavoriteGame";
+ private const string GameAggregate = "Game";
+
+ public const string GameFavorited = "GameFavoritedV1";
+ public const string GameUnfavorited = "GameUnfavoritedV1";
+ public const string GameLifecycleChanged = "GameLifecycleChangedV1";
+
+ public static OutboxMessage GameFavoritedEvent(UserFavoriteGame favorite) =>
+ Favorite(favorite, GameFavorited);
+
+ public static OutboxMessage GameUnfavoritedEvent(UserFavoriteGame favorite) =>
+ Favorite(favorite, GameUnfavorited);
+
+ public static OutboxMessage GameLifecycleChangedEvent(Game game) =>
+ OutboxMessage.Create(
+ GameAggregate, game.Id, GameLifecycleChanged, EventVersion,
+ aggregateDomainVersion: game.LifecycleVersion, requestCycleId: game.LifecycleVersion,
+ Serialize(new
+ {
+ gameId = game.Id,
+ lifecycle = game.Lifecycle.ToString(),
+ }));
+
+ private static OutboxMessage Favorite(UserFavoriteGame favorite, string eventType) =>
+ OutboxMessage.Create(
+ FavoriteAggregate, favorite.Id, eventType, EventVersion,
+ aggregateDomainVersion: favorite.CycleId, requestCycleId: favorite.CycleId,
+ Serialize(new
+ {
+ favoriteId = favorite.Id,
+ userId = favorite.UserId,
+ gameId = favorite.GameId,
+ }));
+
+ private static string Serialize(object payload) => JsonSerializer.Serialize(payload);
+}
diff --git a/src/SimPle.Application/Games/Services/GamesService.cs b/src/SimPle.Application/Games/Services/GamesService.cs
new file mode 100644
index 0000000..543101f
--- /dev/null
+++ b/src/SimPle.Application/Games/Services/GamesService.cs
@@ -0,0 +1,317 @@
+using System.Security.Cryptography;
+using System.Text;
+using SimPle.Application.Common.Interfaces;
+using SimPle.Application.Common.Pagination;
+using SimPle.Application.Games.DTOs;
+using SimPle.Application.Games.Outbox;
+using SimPle.Domain.Games;
+using SimPle.Shared.Common;
+
+namespace SimPle.Application.Games.Services;
+
+public sealed class GamesService : IGamesService
+{
+ private readonly IGameRepository _games;
+
+ private const int MaxLimit = 50;
+ private const int DefaultLimit = 24;
+
+ ///
+ /// Fixed cap on how many values a single multi-value filter (category/tag/mode/lifecycle) may carry.
+ /// Not specified numerically by the spec ("filter cardinality exceeded" is named as a 400 case without a
+ /// number) — 5 is a documented design decision: every allow-list itself has at most 8 members, and a
+ /// filter wider than that stops narrowing the catalog at all, so 5 comfortably covers real use without
+ /// letting a request carry unbounded repeated query keys.
+ ///
+ private const int MaxFilterValues = 5;
+
+ private const string ValidationFailed = "Validation.Failed";
+ private const string InvalidCursor = "Pagination.InvalidCursor";
+ private const string NotFound = "Games.NotFound";
+ private const string Retired = "Games.Retired";
+
+ private static readonly IReadOnlyDictionary PublicLifecycleNames =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["ComingSoon"] = GameLifecycle.ComingSoon,
+ ["Available"] = GameLifecycle.Available,
+ ["Maintenance"] = GameLifecycle.Maintenance,
+ };
+
+ private static readonly IReadOnlyList PublicLifecycles =
+ new[] { GameLifecycle.ComingSoon, GameLifecycle.Available, GameLifecycle.Maintenance };
+
+ public GamesService(IGameRepository games) => _games = games;
+
+ public async Task> ListAsync(
+ string? query,
+ IReadOnlyList? category,
+ IReadOnlyList? tag,
+ IReadOnlyList? mode,
+ IReadOnlyList? lifecycle,
+ string? sort,
+ int limit,
+ string? cursor,
+ CancellationToken ct = default)
+ {
+ string? normalizedSearch = null;
+ if (!string.IsNullOrWhiteSpace(query))
+ {
+ var collapsed = string.Join(' ', query.Split(' ', StringSplitOptions.RemoveEmptyEntries)).Trim();
+ if (collapsed.Length < 2 || collapsed.Length > 100)
+ return Fail("Search query must be between 2 and 100 characters.");
+ normalizedSearch = collapsed.ToUpperInvariant();
+ }
+
+ var categories = category ?? Array.Empty();
+ var tags = tag ?? Array.Empty();
+ var modes = mode ?? Array.Empty();
+ var lifecycleValues = lifecycle ?? Array.Empty();
+
+ if (categories.Count > MaxFilterValues || tags.Count > MaxFilterValues ||
+ modes.Count > MaxFilterValues || lifecycleValues.Count > MaxFilterValues)
+ return Fail("Too many values for a single filter.");
+
+ if (categories.Any(c => !GameCatalogAllowLists.Tags.Contains(c)))
+ return Fail("Unknown category filter.");
+ if (tags.Any(t => !GameCatalogAllowLists.Tags.Contains(t)))
+ return Fail("Unknown tag filter.");
+ if (modes.Any(m => !GameCatalogAllowLists.Modes.Contains(m)))
+ return Fail("Unknown mode filter.");
+
+ IReadOnlyList lifecycles;
+ if (lifecycleValues.Count == 0)
+ {
+ lifecycles = PublicLifecycles;
+ }
+ else
+ {
+ var parsed = new List(lifecycleValues.Count);
+ foreach (var lv in lifecycleValues)
+ {
+ if (!PublicLifecycleNames.TryGetValue(lv, out var parsedLifecycle))
+ return Fail("Unknown or non-public lifecycle filter.");
+ parsed.Add(parsedLifecycle);
+ }
+ lifecycles = parsed;
+ }
+
+ var activeSort = string.IsNullOrEmpty(sort) ? GameCatalogSortKey.Default : sort;
+ if (!GameCatalogSortKey.AllowedSorts.Contains(activeSort))
+ return Fail("Unknown sort.");
+
+ if (limit < 1 || limit > MaxLimit)
+ return Fail("Page size must be between 1 and 50.");
+
+ var shapeHash = HashQueryShape(normalizedSearch, categories, tags, modes, lifecycles, activeSort);
+
+ string? afterSortKey = null;
+ string? afterSlug = null;
+ if (cursor is not null)
+ {
+ if (!Cursor.TryDecodeCatalog(cursor, out var sortKey, out var slug, out var cursorShapeHash) ||
+ cursorShapeHash != shapeHash || slug.Length == 0)
+ return Result.Fail(InvalidCursor, "The pagination cursor is invalid.");
+
+ // Reject a cursor whose sort key cannot decode under the active sort now, rather than letting the
+ // repository silently drop the keyset predicate and quietly restart at page 1.
+ var sortKeyValid = activeSort switch
+ {
+ GameCatalogSortKey.Difficulty => GameCatalogSortKey.TryDecodeDifficulty(sortKey, out _),
+ GameCatalogSortKey.Duration => GameCatalogSortKey.TryDecodeDuration(sortKey, out _),
+ GameCatalogSortKey.Default => GameCatalogSortKey.TryDecodeDefault(sortKey, out _, out _),
+ _ => sortKey.Length > 0, // Name: any non-empty uppercased string is a valid key
+ };
+ if (!sortKeyValid)
+ return Result.Fail(InvalidCursor, "The pagination cursor is invalid.");
+
+ afterSortKey = sortKey;
+ afterSlug = slug;
+ }
+
+ var filter = new GameCatalogFilter(
+ normalizedSearch, categories, tags, modes, lifecycles, activeSort, limit, afterSortKey, afterSlug);
+ var rows = await _games.GetCatalogPageAsync(filter, ct);
+
+ var items = await ProjectAsync(rows, ct);
+
+ string? next = rows.Count == limit
+ ? Cursor.EncodeCatalog(GameCatalogSortKey.Encode(activeSort, rows[^1]), rows[^1].Slug, shapeHash)
+ : null;
+
+ var etag = ComputeListETag(rows);
+ return Result.Ok(new CatalogPageResult(new CursorPage(items, next), etag));
+ }
+
+ public async Task> GetDetailAsync(string slug, CancellationToken ct = default)
+ {
+ var game = await _games.GetBySlugAsync(slug, ct);
+ if (game is null || game.Lifecycle == GameLifecycle.Draft)
+ return Result.Fail(NotFound, "Game not found.");
+
+ if (game.Lifecycle == GameLifecycle.Retired)
+ {
+ var tombstone = new GameTombstoneDto(game.Slug, game.Name, game.Lifecycle.ToString(), "Games.Retired");
+ return Result.Ok(new GameDetailResult(null, tombstone, null));
+ }
+
+ var items = await ProjectAsync(new[] { game }, ct);
+ return Result.Ok(new GameDetailResult(items[0], null, ComputeSingleETag(game)));
+ }
+
+ public async Task> GetFeaturedAsync(CancellationToken ct = default)
+ {
+ var game = await _games.GetFeaturedAsync(ct);
+ if (game is null)
+ return Result.Ok(new FeaturedResult(null, null));
+
+ var items = await ProjectAsync(new[] { game }, ct);
+ return Result.Ok(new FeaturedResult(items[0], ComputeSingleETag(game)));
+ }
+
+ public async Task>> GetFavoritesAsync(
+ Guid userId, int limit, string? cursor, CancellationToken ct = default)
+ {
+ if (limit < 1 || limit > MaxLimit)
+ return Result>.Fail(ValidationFailed, "Page size must be between 1 and 50.");
+
+ DateTime? afterUpdatedAt = null;
+ Guid? afterId = null;
+ if (cursor is not null)
+ {
+ if (!Cursor.TryDecodeTimeId(cursor, out var updatedAt, out var id))
+ return Result>.Fail(InvalidCursor, "The pagination cursor is invalid.");
+ afterUpdatedAt = updatedAt;
+ afterId = id;
+ }
+
+ var rows = await _games.GetFavoritesPageAsync(userId, limit, afterUpdatedAt, afterId, ct);
+ var items = rows.Select(x => ToFavoriteDto(x.Favorite, x.Game)).ToList();
+
+ string? next = rows.Count == limit
+ ? Cursor.EncodeTimeId(rows[^1].Favorite.UpdatedAt, rows[^1].Favorite.Id)
+ : null;
+
+ return Result>.Ok(new CursorPage(items, next));
+ }
+
+ public async Task> PutFavoriteAsync(Guid userId, string slug, CancellationToken ct = default)
+ {
+ var game = await _games.GetBySlugAsync(slug, ct);
+ if (game is null || game.Lifecycle == GameLifecycle.Draft)
+ return Result.Fail(NotFound, "Game not found.");
+
+ var existing = await _games.GetFavoriteAsync(userId, game.Id, ct);
+ if (existing is not null && existing.IsActive)
+ return Result.Ok(ToFavoriteDto(existing, game));
+
+ if (game.Lifecycle == GameLifecycle.Retired)
+ return Result.Fail(Retired, "This game is retired and cannot be favorited.");
+
+ if (existing is null)
+ {
+ var favorite = UserFavoriteGame.Favorite(userId, game.Id);
+ var outcome = await _games.AddFavoriteAsync(favorite, GameOutbox.GameFavoritedEvent(favorite), ct);
+ if (outcome == AddFavoriteOutcome.Conflict)
+ {
+ // A concurrent first favorite already won the (UserId, GameId) race; re-read and converge.
+ var reread = await _games.GetFavoriteAsync(userId, game.Id, ct);
+ if (reread is not null)
+ return Result.Ok(ToFavoriteDto(reread, game));
+ return Result.Fail(NotFound, "Game not found.");
+ }
+ return Result.Ok(ToFavoriteDto(favorite, game));
+ }
+
+ existing.Refavorite();
+ var updateOutcome = await _games.UpdateFavoriteAsync(existing, GameOutbox.GameFavoritedEvent(existing), ct);
+ if (updateOutcome == UpdateFavoriteOutcome.ConcurrencyConflict)
+ {
+ var reread = await _games.GetFavoriteAsync(userId, game.Id, ct);
+ if (reread is not null)
+ return Result.Ok(ToFavoriteDto(reread, game));
+ }
+ return Result.Ok(ToFavoriteDto(existing, game));
+ }
+
+ public async Task DeleteFavoriteAsync(Guid userId, string slug, CancellationToken ct = default)
+ {
+ var game = await _games.GetBySlugAsync(slug, ct);
+ if (game is null || game.Lifecycle == GameLifecycle.Draft)
+ return Result.Fail(NotFound, "Game not found.");
+
+ var existing = await _games.GetFavoriteAsync(userId, game.Id, ct);
+ if (existing is null || !existing.IsActive)
+ return Result.Ok(); // Idempotent: already unfavorited (or never favorited) is still a success.
+
+ existing.Unfavorite();
+ // A ConcurrencyConflict here just means a racing caller already unfavorited it — also fine for a 204.
+ await _games.UpdateFavoriteAsync(existing, GameOutbox.GameUnfavoritedEvent(existing), ct);
+ return Result.Ok();
+ }
+
+ // ── Projection (query 2 of 2: tags + capabilities in one UNION ALL round trip) ──
+
+ private async Task> ProjectAsync(IReadOnlyList games, CancellationToken ct)
+ {
+ var ids = games.Select(g => g.Id).ToList();
+ var extra = await _games.GetTagsAndCapabilitiesAsync(ids, ct);
+
+ var tagsByGame = extra.Where(r => r.Kind == "tag")
+ .GroupBy(r => r.GameId)
+ .ToDictionary(g => g.Key, g => (IReadOnlyList)g.Select(r => r.Value).ToList());
+ var modesByGame = extra.Where(r => r.Kind == "mode")
+ .GroupBy(r => r.GameId)
+ .ToDictionary(g => g.Key, g => (IReadOnlyList)g.Select(r => r.Value).ToList());
+
+ return games.Select(g => new GameCatalogDto(
+ g.Slug, g.Name, g.Summary, g.RulesSummary, g.Category,
+ tagsByGame.TryGetValue(g.Id, out var t) ? t : Array.Empty(),
+ g.Difficulty.ToString(),
+ g.EstimatedDurationMinMinutes, g.EstimatedDurationMaxMinutes, g.MinPlayers, g.MaxPlayers,
+ g.Lifecycle.ToString(),
+ modesByGame.TryGetValue(g.Id, out var m) ? m : Array.Empty(),
+ g.FeaturedRank, g.ArtToken, g.ArtColorA, g.ArtColorB, g.ArtAltText,
+ GameEntryActions.All)).ToList();
+ }
+
+ private static GameFavoriteDto ToFavoriteDto(UserFavoriteGame favorite, Game game) => new(
+ game.Slug, game.Name, game.Lifecycle.ToString(),
+ game.ArtToken, game.ArtColorA, game.ArtColorB, game.ArtAltText, favorite.UpdatedAt);
+
+ // ── ETag derivation (documented deviation from the spec's literal single global catalogVersion counter:
+ // a hash of the already-fetched row data avoids both a third SQL round trip and an unsafe in-process
+ // counter cache; two requests get the same ETag iff they'd return byte-identical bodies) ──
+
+ private static string ComputeListETag(IReadOnlyList rows)
+ {
+ var sb = new StringBuilder();
+ foreach (var g in rows)
+ sb.Append(g.Slug).Append('|').Append(g.UpdatedAt.Ticks).Append('|').Append(g.LifecycleVersion).Append(';');
+ return Quote(Hash(sb.ToString()));
+ }
+
+ private static string ComputeSingleETag(Game g) =>
+ Quote(Hash($"{g.Slug}|{g.UpdatedAt.Ticks}|{g.LifecycleVersion}"));
+
+ private static string HashQueryShape(
+ string? normalizedSearch, IReadOnlyList categories, IReadOnlyList tags,
+ IReadOnlyList modes, IReadOnlyList lifecycles, string sort)
+ {
+ var shape = string.Join('&',
+ $"q={normalizedSearch}",
+ $"cat={string.Join(',', categories.OrderBy(x => x, StringComparer.Ordinal))}",
+ $"tag={string.Join(',', tags.OrderBy(x => x, StringComparer.Ordinal))}",
+ $"mode={string.Join(',', modes.OrderBy(x => x, StringComparer.Ordinal))}",
+ $"life={string.Join(',', lifecycles.Select(l => l.ToString()).OrderBy(x => x, StringComparer.Ordinal))}",
+ $"sort={sort}");
+ return Hash(shape);
+ }
+
+ private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
+
+ private static string Quote(string value) => $"\"{value}\"";
+
+ private static Result Fail(string message) =>
+ Result.Fail(ValidationFailed, message);
+}
diff --git a/src/SimPle.Application/Games/Services/IGamesService.cs b/src/SimPle.Application/Games/Services/IGamesService.cs
new file mode 100644
index 0000000..01e9c3e
--- /dev/null
+++ b/src/SimPle.Application/Games/Services/IGamesService.cs
@@ -0,0 +1,41 @@
+using SimPle.Application.Games.DTOs;
+using SimPle.Shared.Common;
+
+namespace SimPle.Application.Games.Services;
+
+///
+/// A successful detail read is exactly one of (200) or (410 — the
+/// slug exists but is Retired). is populated only alongside : the spec's
+/// cache headers apply to 200/304 responses only, never to 410.
+///
+public sealed record GameDetailResult(GameCatalogDto? Game, GameTombstoneDto? Tombstone, string? ETag);
+
+/// A successful featured read: is null when no game currently holds FeaturedRank 1 (204).
+public sealed record FeaturedResult(GameCatalogDto? Game, string? ETag);
+
+public sealed record CatalogPageResult(CursorPage Page, string ETag);
+
+public interface IGamesService
+{
+ Task> ListAsync(
+ string? query,
+ IReadOnlyList? category,
+ IReadOnlyList? tag,
+ IReadOnlyList? mode,
+ IReadOnlyList? lifecycle,
+ string? sort,
+ int limit,
+ string? cursor,
+ CancellationToken ct = default);
+
+ Task> GetDetailAsync(string slug, CancellationToken ct = default);
+
+ Task> GetFeaturedAsync(CancellationToken ct = default);
+
+ Task>> GetFavoritesAsync(
+ Guid userId, int limit, string? cursor, CancellationToken ct = default);
+
+ Task> PutFavoriteAsync(Guid userId, string slug, CancellationToken ct = default);
+
+ Task DeleteFavoriteAsync(Guid userId, string slug, CancellationToken ct = default);
+}
diff --git a/src/SimPle.Domain/Games/CatalogSeedHistory.cs b/src/SimPle.Domain/Games/CatalogSeedHistory.cs
new file mode 100644
index 0000000..7df8b90
--- /dev/null
+++ b/src/SimPle.Domain/Games/CatalogSeedHistory.cs
@@ -0,0 +1,20 @@
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Games;
+
+/// Records that a given catalog manifest version was applied, with its content checksum.
+public class CatalogSeedHistory : Entity
+{
+ public string ManifestVersion { get; private set; } = default!;
+ public string Checksum { get; private set; } = default!;
+ public DateTime AppliedAtUtc { get; private set; }
+
+ private CatalogSeedHistory() { }
+
+ public static CatalogSeedHistory Record(string manifestVersion, string checksum) => new()
+ {
+ ManifestVersion = manifestVersion,
+ Checksum = checksum,
+ AppliedAtUtc = DateTime.UtcNow,
+ };
+}
diff --git a/src/SimPle.Domain/Games/Game.cs b/src/SimPle.Domain/Games/Game.cs
index 09586be..2bdbcc6 100644
--- a/src/SimPle.Domain/Games/Game.cs
+++ b/src/SimPle.Domain/Games/Game.cs
@@ -3,42 +3,303 @@
namespace SimPle.Domain.Games;
///
-/// Catalog entry for a game. The actual game logic lives in the GameHost module.
+/// Catalog entry for a game. The actual game logic lives in the GameHost module. This slice (4A) owns only
+/// the domain/schema shape; the read API, search/cursor, favorites endpoints and outbox emission are 4B.
///
public class Game : Entity
{
+ private readonly List _tags = new();
+ private readonly List _capabilities = new();
+
public string Slug { get; private set; } = default!;
public string Name { get; private set; } = default!;
- public string Description { get; private set; } = default!;
- public string Rules { get; private set; } = default!;
+ public string Summary { get; private set; } = default!;
+ public string RulesSummary { get; private set; } = default!;
public string Category { get; private set; } = default!;
- public string Difficulty { get; private set; } = "Medium";
- public string EstimatedDuration { get; private set; } = "5-10 min";
- public int MinPlayers { get; private set; } = 1;
- public int MaxPlayers { get; private set; } = 2;
- public bool SupportsSolo { get; private set; } = true;
- public bool SupportsAi { get; private set; } = true;
- public bool SupportsMultiplayer { get; private set; } = true;
- public bool SupportsRanked { get; private set; } = true;
- public bool IsActive { get; private set; } = true;
- public bool IsFeatured { get; private set; }
+ public GameDifficulty Difficulty { get; private set; }
+ public int EstimatedDurationMinMinutes { get; private set; }
+ public int EstimatedDurationMaxMinutes { get; private set; }
+ public int MinPlayers { get; private set; }
+ public int MaxPlayers { get; private set; }
+ public GameLifecycle Lifecycle { get; private set; }
+
+ ///
+ /// Monotonic per-game counter bumped on every lifecycle transition. Starts at 1 (the as-created state);
+ /// used as the outbox AggregateDomainVersion for GameLifecycleChangedV1 so a retried
+ /// transition is idempotent while two distinct transitions never collide, even across an
+ /// Available <-> Maintenance cycle. Mirrors .
+ ///
+ public int LifecycleVersion { get; private set; } = 1;
+
+ public int? FeaturedRank { get; private set; }
public int SortOrder { get; private set; }
+ public string ArtToken { get; private set; } = default!;
+ public string ArtColorA { get; private set; } = default!;
+ public string ArtColorB { get; private set; } = default!;
+ public string ArtAltText { get; private set; } = default!;
+
+ /// Last manifest version that wrote this row (seeder bookkeeping only).
+ public string ManifestVersion { get; private set; } = default!;
- public IReadOnlyList Tags { get; private set; } = [];
- public IReadOnlyList AiDifficultyLevels { get; private set; } = ["Easy", "Medium", "Hard"];
+ public uint Version { get; private set; } // mapped to xmin via IsRowVersion() in EF config
+
+ public IReadOnlyList Tags => _tags;
+ public IReadOnlyList Capabilities => _capabilities;
private Game() { }
public static Game Create(
- string slug, string name, string description, string rules,
- string category, int minPlayers, int maxPlayers) => new()
- {
- Slug = slug,
- Name = name,
- Description = description,
- Rules = rules,
- Category = category,
- MinPlayers = minPlayers,
- MaxPlayers = maxPlayers,
+ string slug,
+ string name,
+ string summary,
+ string rulesSummary,
+ GameDifficulty difficulty,
+ int estimatedDurationMinMinutes,
+ int estimatedDurationMaxMinutes,
+ int minPlayers,
+ int maxPlayers,
+ GameLifecycle initialLifecycle,
+ int? featuredRank,
+ int sortOrder,
+ string artToken,
+ string artColorA,
+ string artColorB,
+ string artAltText,
+ string manifestVersion,
+ string category,
+ IEnumerable tags,
+ IEnumerable modes)
+ {
+ var game = new Game
+ {
+ Slug = RequireNonEmpty(slug, nameof(slug)),
+ Name = RequireNonEmpty(name, nameof(name)),
+ Summary = RequireNonEmpty(summary, nameof(summary)),
+ RulesSummary = RequireNonEmpty(rulesSummary, nameof(rulesSummary)),
+ Category = RequireValidCategory(category),
+ Difficulty = difficulty,
+ EstimatedDurationMinMinutes = estimatedDurationMinMinutes,
+ EstimatedDurationMaxMinutes = estimatedDurationMaxMinutes,
+ MinPlayers = minPlayers,
+ MaxPlayers = maxPlayers,
+ Lifecycle = initialLifecycle,
+ FeaturedRank = featuredRank,
+ SortOrder = sortOrder,
+ ArtToken = RequireNonEmpty(artToken, nameof(artToken)),
+ ArtColorA = RequireNonEmpty(artColorA, nameof(artColorA)),
+ ArtColorB = RequireNonEmpty(artColorB, nameof(artColorB)),
+ ArtAltText = RequireNonEmpty(artAltText, nameof(artAltText)),
+ ManifestVersion = RequireNonEmpty(manifestVersion, nameof(manifestVersion)),
+ };
+
+ game.Validate();
+ game.ReplaceTags(tags);
+ game.ReplaceModes(modes);
+ game.ValidateModesImplyMultiplayer();
+
+ return game;
+ }
+
+ ///
+ /// Manifest upgrade path used by the seeder: Lifecycle and Slug are never changed here. Re-validates all
+ /// invariants and replaces the tag/mode child collections wholesale.
+ ///
+ public void ApplyManifestUpdate(
+ string name,
+ string summary,
+ string rulesSummary,
+ GameDifficulty difficulty,
+ int estimatedDurationMinMinutes,
+ int estimatedDurationMaxMinutes,
+ int minPlayers,
+ int maxPlayers,
+ int? featuredRank,
+ int sortOrder,
+ string artToken,
+ string artColorA,
+ string artColorB,
+ string artAltText,
+ string manifestVersion,
+ string category,
+ IEnumerable tags,
+ IEnumerable modes)
+ {
+ Name = RequireNonEmpty(name, nameof(name));
+ Summary = RequireNonEmpty(summary, nameof(summary));
+ RulesSummary = RequireNonEmpty(rulesSummary, nameof(rulesSummary));
+ Category = RequireValidCategory(category);
+ Difficulty = difficulty;
+ EstimatedDurationMinMinutes = estimatedDurationMinMinutes;
+ EstimatedDurationMaxMinutes = estimatedDurationMaxMinutes;
+ MinPlayers = minPlayers;
+ MaxPlayers = maxPlayers;
+ FeaturedRank = featuredRank;
+ SortOrder = sortOrder;
+ ArtToken = RequireNonEmpty(artToken, nameof(artToken));
+ ArtColorA = RequireNonEmpty(artColorA, nameof(artColorA));
+ ArtColorB = RequireNonEmpty(artColorB, nameof(artColorB));
+ ArtAltText = RequireNonEmpty(artAltText, nameof(artAltText));
+ ManifestVersion = RequireNonEmpty(manifestVersion, nameof(manifestVersion));
+
+ Validate();
+ ReplaceTags(tags);
+ ReplaceModes(modes);
+ ValidateModesImplyMultiplayer();
+
+ Touch();
+ }
+
+ // ── Lifecycle transitions ───────────────────────────────────────────────
+
+ public void Publish()
+ {
+ if (Lifecycle != GameLifecycle.Draft)
+ throw new InvalidOperationException($"Cannot publish (Draft -> ComingSoon) from {Lifecycle}.");
+ Lifecycle = GameLifecycle.ComingSoon;
+ LifecycleVersion += 1;
+ Touch();
+ }
+
+ public void MakeAvailable()
+ {
+ if (Lifecycle != GameLifecycle.ComingSoon && Lifecycle != GameLifecycle.Maintenance)
+ throw new InvalidOperationException($"Cannot transition to Available from {Lifecycle}.");
+ Lifecycle = GameLifecycle.Available;
+ LifecycleVersion += 1;
+ Touch();
+ }
+
+ public void EnterMaintenance()
+ {
+ if (Lifecycle != GameLifecycle.Available)
+ throw new InvalidOperationException($"Cannot transition to Maintenance from {Lifecycle}.");
+ Lifecycle = GameLifecycle.Maintenance;
+ LifecycleVersion += 1;
+ Touch();
+ }
+
+ public void Retire()
+ {
+ if (Lifecycle == GameLifecycle.Retired)
+ throw new InvalidOperationException("Retired is terminal and cannot be re-retired.");
+ Lifecycle = GameLifecycle.Retired;
+ FeaturedRank = null;
+ LifecycleVersion += 1;
+ Touch();
+ }
+
+ /// Generic entry point enforcing the exact allowed-edges table; delegates to the named methods.
+ public void TransitionTo(GameLifecycle target)
+ {
+ switch (target)
+ {
+ case GameLifecycle.ComingSoon:
+ Publish();
+ break;
+ case GameLifecycle.Available:
+ MakeAvailable();
+ break;
+ case GameLifecycle.Maintenance:
+ EnterMaintenance();
+ break;
+ case GameLifecycle.Retired:
+ Retire();
+ break;
+ default:
+ throw new InvalidOperationException($"Cannot transition to {target}.");
+ }
+ }
+
+ // ── Validation ───────────────────────────────────────────────────────────
+
+ private void Validate()
+ {
+ if (MinPlayers < 1)
+ throw new ArgumentException("MinPlayers must be at least 1.", nameof(MinPlayers));
+ if (MinPlayers > MaxPlayers)
+ throw new ArgumentException("MinPlayers must be <= MaxPlayers.", nameof(MinPlayers));
+ if (EstimatedDurationMinMinutes > EstimatedDurationMaxMinutes)
+ throw new ArgumentException(
+ "EstimatedDurationMinMinutes must be <= EstimatedDurationMaxMinutes.",
+ nameof(EstimatedDurationMinMinutes));
+ if (FeaturedRank is not null && (Lifecycle == GameLifecycle.Draft || Lifecycle == GameLifecycle.Retired))
+ throw new ArgumentException("FeaturedRank may only be set when Lifecycle is not Draft or Retired.");
+ }
+
+ private void ReplaceTags(IEnumerable tags)
+ {
+ var values = tags.ToList();
+ if (values.Distinct(StringComparer.Ordinal).Count() != values.Count)
+ throw new ArgumentException("Tag values must not contain duplicates.", nameof(tags));
+ foreach (var value in values)
+ {
+ if (!GameCatalogAllowLists.Tags.Contains(value))
+ throw new ArgumentException($"Tag '{value}' is not in the allow-list.", nameof(tags));
+ if (value == Category)
+ throw new ArgumentException($"Tag '{value}' duplicates the category and must not be repeated.", nameof(tags));
+ }
+
+ _tags.Clear();
+ foreach (var value in values)
+ _tags.Add(GameTag.Create(Id, value));
+ }
+
+ private static string RequireValidCategory(string category)
+ {
+ RequireNonEmpty(category, nameof(category));
+ if (!GameCatalogAllowLists.Tags.Contains(category))
+ throw new ArgumentException($"Category '{category}' is not in the allow-list.", nameof(category));
+ return category;
+ }
+
+ private void ReplaceModes(IEnumerable modes)
+ {
+ var values = modes.ToList();
+ if (values.Distinct(StringComparer.Ordinal).Count() != values.Count)
+ throw new ArgumentException("Mode values must not contain duplicates.", nameof(modes));
+ foreach (var value in values)
+ {
+ if (!GameCatalogAllowLists.Modes.Contains(value))
+ throw new ArgumentException($"Mode '{value}' is not in the allow-list.", nameof(modes));
+ }
+
+ _capabilities.Clear();
+ foreach (var value in values)
+ _capabilities.Add(GameModeCapability.Create(Id, value));
+ }
+
+ private void ValidateModesImplyMultiplayer()
+ {
+ var modeValues = _capabilities.Select(c => c.Mode).ToHashSet(StringComparer.Ordinal);
+ if (modeValues.Contains("ranked") && !modeValues.Contains("multiplayer"))
+ throw new ArgumentException("Mode 'ranked' requires 'multiplayer' to also be present.");
+ }
+
+ private static string RequireNonEmpty(string value, string paramName)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ throw new ArgumentException($"{paramName} must not be empty.", paramName);
+ return value;
+ }
+}
+
+public enum GameDifficulty { Easy, Medium, Hard }
+
+public enum GameLifecycle { Draft, ComingSoon, Available, Maintenance, Retired }
+
+///
+/// Phase-1 allow-lists for game tags and mode capabilities. Used by domain validation on
+/// creation/update and by the seeder/manifest validator before any database write.
+///
+public static class GameCatalogAllowLists
+{
+ public static readonly IReadOnlySet Tags = new HashSet(StringComparer.Ordinal)
+ {
+ "puzzle", "logic", "arcade", "reaction", "strategy", "classic", "vocabulary", "memory",
+ };
+
+ public static readonly IReadOnlySet Modes = new HashSet(StringComparer.Ordinal)
+ {
+ "solo", "cooperative", "multiplayer", "ai", "ranked", "quick-match",
};
}
diff --git a/src/SimPle.Domain/Games/GameModeCapability.cs b/src/SimPle.Domain/Games/GameModeCapability.cs
new file mode 100644
index 0000000..094f038
--- /dev/null
+++ b/src/SimPle.Domain/Games/GameModeCapability.cs
@@ -0,0 +1,18 @@
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Games;
+
+/// Child row of ; constructible only via the owning .
+public class GameModeCapability : Entity
+{
+ public Guid GameId { get; private set; }
+ public string Mode { get; private set; } = default!;
+
+ private GameModeCapability() { }
+
+ internal static GameModeCapability Create(Guid gameId, string mode) => new()
+ {
+ GameId = gameId,
+ Mode = mode,
+ };
+}
diff --git a/src/SimPle.Domain/Games/GameTag.cs b/src/SimPle.Domain/Games/GameTag.cs
new file mode 100644
index 0000000..1b1cbdc
--- /dev/null
+++ b/src/SimPle.Domain/Games/GameTag.cs
@@ -0,0 +1,18 @@
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Games;
+
+/// Child row of ; constructible only via the owning .
+public class GameTag : Entity
+{
+ public Guid GameId { get; private set; }
+ public string Value { get; private set; } = default!;
+
+ private GameTag() { }
+
+ internal static GameTag Create(Guid gameId, string value) => new()
+ {
+ GameId = gameId,
+ Value = value,
+ };
+}
diff --git a/src/SimPle.Domain/Games/UserFavoriteGame.cs b/src/SimPle.Domain/Games/UserFavoriteGame.cs
new file mode 100644
index 0000000..9568c1e
--- /dev/null
+++ b/src/SimPle.Domain/Games/UserFavoriteGame.cs
@@ -0,0 +1,43 @@
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Games;
+
+///
+/// A user's favorite marker for a game. increments on each fresh
+/// favorite -> unfavorite -> favorite cycle, giving the future (4B) outbox event a fresh
+/// AggregateDomainVersion per cycle. No 4A code creates these rows via an API — schema/domain only.
+///
+public class UserFavoriteGame : Entity
+{
+ public Guid UserId { get; private set; }
+ public Guid GameId { get; private set; }
+ public bool IsActive { get; private set; }
+ public int CycleId { get; private set; }
+
+ private UserFavoriteGame() { }
+
+ public static UserFavoriteGame Favorite(Guid userId, Guid gameId) => new()
+ {
+ UserId = userId,
+ GameId = gameId,
+ IsActive = true,
+ CycleId = 1,
+ };
+
+ /// Idempotent: a no-op if already inactive. Does not increment CycleId.
+ public void Unfavorite()
+ {
+ if (!IsActive) return;
+ IsActive = false;
+ Touch();
+ }
+
+ /// Idempotent: a no-op if already active. Increments CycleId for a fresh cycle.
+ public void Refavorite()
+ {
+ if (IsActive) return;
+ IsActive = true;
+ CycleId += 1;
+ Touch();
+ }
+}
diff --git a/src/SimPle.Infrastructure/DependencyInjection.cs b/src/SimPle.Infrastructure/DependencyInjection.cs
index 04b217a..a9e297f 100644
--- a/src/SimPle.Infrastructure/DependencyInjection.cs
+++ b/src/SimPle.Infrastructure/DependencyInjection.cs
@@ -41,6 +41,7 @@ public static IServiceCollection AddInfrastructureServices(
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.Configure(configuration.GetSection(StorageOptions.SectionName));
services.PostConfigure(options =>
{
diff --git a/src/SimPle.Infrastructure/Games/CatalogSeedManifest.cs b/src/SimPle.Infrastructure/Games/CatalogSeedManifest.cs
new file mode 100644
index 0000000..57200df
--- /dev/null
+++ b/src/SimPle.Infrastructure/Games/CatalogSeedManifest.cs
@@ -0,0 +1,73 @@
+using System.Text.Json.Serialization;
+
+namespace SimPle.Infrastructure.Games;
+
+/// Deserialization shape of the embedded catalog.seed.v1.json manifest.
+public sealed class CatalogSeedManifest
+{
+ [JsonPropertyName("manifestVersion")]
+ public string ManifestVersion { get; set; } = default!;
+
+ [JsonPropertyName("games")]
+ public List Games { get; set; } = new();
+}
+
+public sealed class CatalogSeedGameEntry
+{
+ [JsonPropertyName("legacyMockId")]
+ public string LegacyMockId { get; set; } = default!;
+
+ [JsonPropertyName("slug")]
+ public string Slug { get; set; } = default!;
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = default!;
+
+ [JsonPropertyName("summary")]
+ public string Summary { get; set; } = default!;
+
+ [JsonPropertyName("rulesSummary")]
+ public string RulesSummary { get; set; } = default!;
+
+ [JsonPropertyName("category")]
+ public string Category { get; set; } = default!;
+
+ [JsonPropertyName("tags")]
+ public List Tags { get; set; } = new();
+
+ [JsonPropertyName("difficulty")]
+ public string Difficulty { get; set; } = default!;
+
+ [JsonPropertyName("estimatedDurationMinMinutes")]
+ public int EstimatedDurationMinMinutes { get; set; }
+
+ [JsonPropertyName("estimatedDurationMaxMinutes")]
+ public int EstimatedDurationMaxMinutes { get; set; }
+
+ [JsonPropertyName("minPlayers")]
+ public int MinPlayers { get; set; }
+
+ [JsonPropertyName("maxPlayers")]
+ public int MaxPlayers { get; set; }
+
+ [JsonPropertyName("modes")]
+ public List Modes { get; set; } = new();
+
+ [JsonPropertyName("featuredRank")]
+ public int? FeaturedRank { get; set; }
+
+ [JsonPropertyName("sortOrder")]
+ public int SortOrder { get; set; }
+
+ [JsonPropertyName("artToken")]
+ public string ArtToken { get; set; } = default!;
+
+ [JsonPropertyName("artColorA")]
+ public string ArtColorA { get; set; } = default!;
+
+ [JsonPropertyName("artColorB")]
+ public string ArtColorB { get; set; } = default!;
+
+ [JsonPropertyName("artAltText")]
+ public string ArtAltText { get; set; } = default!;
+}
diff --git a/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs b/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs
new file mode 100644
index 0000000..ca83325
--- /dev/null
+++ b/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs
@@ -0,0 +1,267 @@
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using SimPle.Domain.Games;
+using SimPle.Infrastructure.Persistence;
+
+namespace SimPle.Infrastructure.Games;
+
+public sealed record GameCatalogSeedResult(bool Success, string Message, int GamesCreated, int GamesUpdated);
+
+///
+/// Loads the embedded catalog seed manifest, validates it against domain invariants before touching the
+/// database, then upserts the catalog inside a transaction guarded by a Postgres advisory lock so concurrent
+/// seeder runs converge safely. See docs/specs/module-04-game-library-discovery-spec.md "Seeder" section.
+///
+public sealed class GameCatalogSeeder
+{
+ // Module-4-specific advisory lock key. Must never collide with another module's advisory lock —
+ // no other module uses advisory locks today.
+ private const long CatalogSeedAdvisoryLockKey = 44004001;
+
+ private static readonly string ResourceName =
+ typeof(GameCatalogSeeder).Assembly.GetManifestResourceNames()
+ .First(n => n.EndsWith("catalog.seed.v1.json", StringComparison.Ordinal));
+
+ private readonly AppDbContext _db;
+ private readonly ILogger _logger;
+
+ public GameCatalogSeeder(AppDbContext db, ILogger logger)
+ {
+ _db = db;
+ _logger = logger;
+ }
+
+ public async Task SeedAsync(CancellationToken ct = default)
+ {
+ byte[] manifestBytes;
+ await using (var stream = typeof(GameCatalogSeeder).Assembly.GetManifestResourceStream(ResourceName))
+ {
+ if (stream is null)
+ return Fail("Embedded catalog seed manifest resource not found.");
+
+ using var buffer = new MemoryStream();
+ await stream.CopyToAsync(buffer, ct);
+ manifestBytes = buffer.ToArray();
+ }
+
+ CatalogSeedManifest manifest;
+ try
+ {
+ manifest = JsonSerializer.Deserialize(manifestBytes,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
+ ?? throw new JsonException("Manifest deserialized to null.");
+ }
+ catch (JsonException ex)
+ {
+ return Fail($"Manifest failed to parse: {ex.Message}");
+ }
+
+ var validationError = Validate(manifest);
+ if (validationError is not null)
+ return Fail(validationError);
+
+ var checksum = Convert.ToHexString(SHA256.HashData(manifestBytes)).ToLowerInvariant();
+
+ await using var transaction = await _db.Database.BeginTransactionAsync(ct);
+ await _db.Database.ExecuteSqlRawAsync(
+ $"SELECT pg_advisory_xact_lock({CatalogSeedAdvisoryLockKey});", ct);
+
+ var existingHistory = await _db.Set()
+ .FirstOrDefaultAsync(h => h.ManifestVersion == manifest.ManifestVersion, ct);
+
+ if (existingHistory is not null)
+ {
+ if (existingHistory.Checksum != checksum)
+ {
+ await transaction.RollbackAsync(ct);
+ var message = $"Checksum mismatch for manifest version '{manifest.ManifestVersion}', refusing to overwrite.";
+ _logger.LogError("Game catalog seed failed: {Message}", message);
+ return new GameCatalogSeedResult(false, message, 0, 0);
+ }
+
+ await transaction.CommitAsync(ct);
+ var noopMessage = $"Manifest version '{manifest.ManifestVersion}' already applied; no-op.";
+ _logger.LogInformation("Game catalog seed no-op: {Message}", noopMessage);
+ return new GameCatalogSeedResult(true, noopMessage, 0, 0);
+ }
+
+ var created = 0;
+ var updated = 0;
+
+ try
+ {
+ foreach (var entry in manifest.Games)
+ {
+ var difficulty = Enum.Parse(entry.Difficulty);
+
+ var existingGame = await _db.Games
+ .Include(g => g.Tags)
+ .Include(g => g.Capabilities)
+ .FirstOrDefaultAsync(g => g.Slug == entry.Slug, ct);
+
+ if (existingGame is null)
+ {
+ var game = Game.Create(
+ entry.Slug,
+ entry.Name,
+ entry.Summary,
+ entry.RulesSummary,
+ difficulty,
+ entry.EstimatedDurationMinMinutes,
+ entry.EstimatedDurationMaxMinutes,
+ entry.MinPlayers,
+ entry.MaxPlayers,
+ GameLifecycle.ComingSoon,
+ entry.FeaturedRank,
+ entry.SortOrder,
+ entry.ArtToken,
+ entry.ArtColorA,
+ entry.ArtColorB,
+ entry.ArtAltText,
+ manifest.ManifestVersion,
+ entry.Category,
+ entry.Tags,
+ entry.Modes);
+ _db.Games.Add(game);
+ created++;
+ }
+ else
+ {
+ existingGame.ApplyManifestUpdate(
+ entry.Name,
+ entry.Summary,
+ entry.RulesSummary,
+ difficulty,
+ entry.EstimatedDurationMinMinutes,
+ entry.EstimatedDurationMaxMinutes,
+ entry.MinPlayers,
+ entry.MaxPlayers,
+ entry.FeaturedRank,
+ entry.SortOrder,
+ entry.ArtToken,
+ entry.ArtColorA,
+ entry.ArtColorB,
+ entry.ArtAltText,
+ manifest.ManifestVersion,
+ entry.Category,
+ entry.Tags,
+ entry.Modes);
+ updated++;
+ }
+ }
+
+ _db.Set().Add(CatalogSeedHistory.Record(manifest.ManifestVersion, checksum));
+
+ await _db.SaveChangesAsync(ct);
+ await transaction.CommitAsync(ct);
+ }
+ catch (DbUpdateException ex)
+ {
+ await transaction.RollbackAsync(ct);
+ var message = $"Failed to apply manifest version '{manifest.ManifestVersion}': {ex.Message}";
+ _logger.LogError(ex, "Game catalog seed failed: {Message}", message);
+ return new GameCatalogSeedResult(false, message, 0, 0);
+ }
+
+ var successMessage =
+ $"Applied manifest version '{manifest.ManifestVersion}': {created} created, {updated} updated.";
+ _logger.LogInformation("Game catalog seed succeeded: {Message}", successMessage);
+ return new GameCatalogSeedResult(true, successMessage, created, updated);
+ }
+
+ private GameCatalogSeedResult Fail(string message)
+ {
+ _logger.LogError("Game catalog seed failed: {Message}", message);
+ return new GameCatalogSeedResult(false, message, 0, 0);
+ }
+
+ ///
+ /// Structural + domain-invariant validation of the whole manifest before any database access.
+ /// Returns null when valid, or a message identifying the offending slug/field otherwise.
+ ///
+ private static string? Validate(CatalogSeedManifest manifest)
+ {
+ if (string.IsNullOrWhiteSpace(manifest.ManifestVersion))
+ return "manifestVersion must not be empty.";
+
+ if (manifest.Games is null || manifest.Games.Count == 0)
+ return "games must contain at least one entry.";
+
+ var seenSlugs = new HashSet(StringComparer.Ordinal);
+ var featuredCount = 0;
+
+ foreach (var entry in manifest.Games)
+ {
+ if (string.IsNullOrWhiteSpace(entry.Slug))
+ return "A game entry is missing a slug.";
+ if (!seenSlugs.Add(entry.Slug))
+ return $"Duplicate slug '{entry.Slug}' in manifest.";
+
+ if (string.IsNullOrWhiteSpace(entry.Name))
+ return $"[{entry.Slug}] name must not be empty.";
+ if (string.IsNullOrWhiteSpace(entry.Summary))
+ return $"[{entry.Slug}] summary must not be empty.";
+ if (string.IsNullOrWhiteSpace(entry.RulesSummary))
+ return $"[{entry.Slug}] rulesSummary must not be empty.";
+ if (string.IsNullOrWhiteSpace(entry.ArtToken))
+ return $"[{entry.Slug}] artToken must not be empty.";
+ if (string.IsNullOrWhiteSpace(entry.ArtColorA))
+ return $"[{entry.Slug}] artColorA must not be empty.";
+ if (string.IsNullOrWhiteSpace(entry.ArtColorB))
+ return $"[{entry.Slug}] artColorB must not be empty.";
+ if (string.IsNullOrWhiteSpace(entry.ArtAltText))
+ return $"[{entry.Slug}] artAltText must not be empty.";
+
+ if (!Enum.TryParse(entry.Difficulty, out _))
+ return $"[{entry.Slug}] difficulty '{entry.Difficulty}' is not a valid GameDifficulty.";
+
+ if (entry.MinPlayers < 1)
+ return $"[{entry.Slug}] minPlayers must be at least 1.";
+ if (entry.MinPlayers > entry.MaxPlayers)
+ return $"[{entry.Slug}] minPlayers must be <= maxPlayers.";
+ if (entry.EstimatedDurationMinMinutes > entry.EstimatedDurationMaxMinutes)
+ return $"[{entry.Slug}] estimatedDurationMinMinutes must be <= estimatedDurationMaxMinutes.";
+
+ if (string.IsNullOrWhiteSpace(entry.Category))
+ return $"[{entry.Slug}] category must not be empty.";
+ if (!GameCatalogAllowLists.Tags.Contains(entry.Category))
+ return $"[{entry.Slug}] category '{entry.Category}' is not in the tag allow-list.";
+ foreach (var tag in entry.Tags)
+ {
+ if (!GameCatalogAllowLists.Tags.Contains(tag))
+ return $"[{entry.Slug}] tag '{tag}' is not in the allow-list.";
+ }
+ if (entry.Tags.Contains(entry.Category))
+ return $"[{entry.Slug}] tag '{entry.Category}' duplicates the category and must not be repeated.";
+ if (entry.Tags.Distinct(StringComparer.Ordinal).Count() != entry.Tags.Count)
+ return $"[{entry.Slug}] tags contain a duplicate value.";
+
+ if (entry.Modes is null || entry.Modes.Count == 0)
+ return $"[{entry.Slug}] modes must contain at least one entry.";
+ foreach (var mode in entry.Modes)
+ {
+ if (!GameCatalogAllowLists.Modes.Contains(mode))
+ return $"[{entry.Slug}] mode '{mode}' is not in the allow-list.";
+ }
+ if (entry.Modes.Distinct(StringComparer.Ordinal).Count() != entry.Modes.Count)
+ return $"[{entry.Slug}] modes contain a duplicate value.";
+ if (entry.Modes.Contains("ranked") && !entry.Modes.Contains("multiplayer"))
+ return $"[{entry.Slug}] mode 'ranked' requires 'multiplayer' to also be present.";
+
+ if (entry.FeaturedRank is not null)
+ {
+ if (entry.FeaturedRank != 1)
+ return $"[{entry.Slug}] featuredRank must be 1 when present.";
+ featuredCount++;
+ }
+ }
+
+ if (featuredCount > 1)
+ return "At most one game may have a non-null featuredRank.";
+
+ return null;
+ }
+}
diff --git a/src/SimPle.Infrastructure/Games/catalog.seed.schema.json b/src/SimPle.Infrastructure/Games/catalog.seed.schema.json
new file mode 100644
index 0000000..bf2f99b
--- /dev/null
+++ b/src/SimPle.Infrastructure/Games/catalog.seed.schema.json
@@ -0,0 +1,74 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://simple.internal/schemas/catalog.seed.schema.json",
+ "title": "SimPle Game Catalog Seed Manifest",
+ "description": "Documentation-only JSON Schema for catalog.seed.v1.json. Authoritative structural and domain-invariant validation is performed in code by GameCatalogSeeder before any database write.",
+ "type": "object",
+ "required": ["manifestVersion", "games"],
+ "additionalProperties": false,
+ "properties": {
+ "manifestVersion": {
+ "type": "string",
+ "minLength": 1
+ },
+ "games": {
+ "type": "array",
+ "minItems": 1,
+ "items": { "$ref": "#/$defs/gameEntry" }
+ }
+ },
+ "$defs": {
+ "gameEntry": {
+ "type": "object",
+ "required": [
+ "legacyMockId",
+ "slug",
+ "name",
+ "summary",
+ "rulesSummary",
+ "category",
+ "tags",
+ "difficulty",
+ "estimatedDurationMinMinutes",
+ "estimatedDurationMaxMinutes",
+ "minPlayers",
+ "maxPlayers",
+ "modes",
+ "featuredRank",
+ "sortOrder",
+ "artToken",
+ "artColorA",
+ "artColorB",
+ "artAltText"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "legacyMockId": { "type": "string", "minLength": 1 },
+ "slug": { "type": "string", "minLength": 1 },
+ "name": { "type": "string", "minLength": 1 },
+ "summary": { "type": "string", "minLength": 1 },
+ "rulesSummary": { "type": "string", "minLength": 1 },
+ "category": { "type": "string", "minLength": 1 },
+ "tags": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1 }
+ },
+ "difficulty": { "type": "string", "enum": ["Easy", "Medium", "Hard"] },
+ "estimatedDurationMinMinutes": { "type": "integer", "minimum": 0 },
+ "estimatedDurationMaxMinutes": { "type": "integer", "minimum": 0 },
+ "minPlayers": { "type": "integer", "minimum": 1 },
+ "maxPlayers": { "type": "integer", "minimum": 1 },
+ "modes": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1 }
+ },
+ "featuredRank": { "type": ["integer", "null"] },
+ "sortOrder": { "type": "integer", "minimum": 0 },
+ "artToken": { "type": "string", "minLength": 1 },
+ "artColorA": { "type": "string", "minLength": 1 },
+ "artColorB": { "type": "string", "minLength": 1 },
+ "artAltText": { "type": "string", "minLength": 1 }
+ }
+ }
+ }
+}
diff --git a/src/SimPle.Infrastructure/Games/catalog.seed.v1.json b/src/SimPle.Infrastructure/Games/catalog.seed.v1.json
new file mode 100644
index 0000000..01b75bf
--- /dev/null
+++ b/src/SimPle.Infrastructure/Games/catalog.seed.v1.json
@@ -0,0 +1,173 @@
+{
+ "manifestVersion": "2026.1",
+ "games": [
+ {
+ "legacyMockId": "sudoku",
+ "slug": "online-sudoku",
+ "name": "Online Sudoku",
+ "summary": "A relaxing numbers puzzle for solo play or a shared cooperative grid.",
+ "rulesSummary": "Sudoku fills every row, column, and 3×3 box with 1–9.",
+ "category": "puzzle",
+ "tags": ["logic"],
+ "difficulty": "Medium",
+ "estimatedDurationMinMinutes": 5,
+ "estimatedDurationMaxMinutes": 20,
+ "minPlayers": 1,
+ "maxPlayers": 2,
+ "modes": ["solo", "cooperative"],
+ "featuredRank": null,
+ "sortOrder": 0,
+ "artToken": "online-sudoku",
+ "artColorA": "#2D9CDB",
+ "artColorB": "#56CCF2",
+ "artAltText": "Online Sudoku abstract game artwork"
+ },
+ {
+ "legacyMockId": "tetris",
+ "slug": "falling-blocks-arena",
+ "name": "Falling Blocks Arena",
+ "summary": "A fast-paced falling-block stacking challenge built for quick sessions.",
+ "rulesSummary": "Falling Blocks clears completed lines and, in future multiplayer, sends garbage.",
+ "category": "arcade",
+ "tags": ["reaction"],
+ "difficulty": "Hard",
+ "estimatedDurationMinMinutes": 3,
+ "estimatedDurationMaxMinutes": 8,
+ "minPlayers": 1,
+ "maxPlayers": 4,
+ "modes": ["solo", "multiplayer", "quick-match"],
+ "featuredRank": null,
+ "sortOrder": 1,
+ "artToken": "falling-blocks",
+ "artColorA": "#EB5757",
+ "artColorB": "#F2994A",
+ "artAltText": "Falling Blocks Arena abstract game artwork"
+ },
+ {
+ "legacyMockId": "connect4",
+ "slug": "four-in-a-row",
+ "name": "Four in a Row",
+ "summary": "A classic connection game against the AI or another player.",
+ "rulesSummary": "Four in a Row wins with four connected tokens.",
+ "category": "strategy",
+ "tags": [],
+ "difficulty": "Easy",
+ "estimatedDurationMinMinutes": 2,
+ "estimatedDurationMaxMinutes": 6,
+ "minPlayers": 2,
+ "maxPlayers": 2,
+ "modes": ["ai", "multiplayer", "ranked"],
+ "featuredRank": null,
+ "sortOrder": 2,
+ "artToken": "four-in-a-row",
+ "artColorA": "#27AE60",
+ "artColorB": "#F2C94C",
+ "artAltText": "Four in a Row abstract game artwork"
+ },
+ {
+ "legacyMockId": "chess",
+ "slug": "chess-lite",
+ "name": "Chess Lite",
+ "summary": "A streamlined chess experience for quick matches against the AI or a rival.",
+ "rulesSummary": "Chess Lite wins by checkmate or a future configured clock.",
+ "category": "strategy",
+ "tags": [],
+ "difficulty": "Hard",
+ "estimatedDurationMinMinutes": 5,
+ "estimatedDurationMaxMinutes": 25,
+ "minPlayers": 2,
+ "maxPlayers": 2,
+ "modes": ["ai", "multiplayer", "ranked"],
+ "featuredRank": 1,
+ "sortOrder": 3,
+ "artToken": "chess-lite",
+ "artColorA": "#9B51E0",
+ "artColorB": "#2D9CDB",
+ "artAltText": "Chess Lite abstract game artwork"
+ },
+ {
+ "legacyMockId": "checkers",
+ "slug": "checkers",
+ "name": "Checkers",
+ "summary": "A timeless board game of diagonal captures and crowned kings.",
+ "rulesSummary": "Checkers uses diagonal movement, mandatory captures, and kings.",
+ "category": "strategy",
+ "tags": ["classic"],
+ "difficulty": "Easy",
+ "estimatedDurationMinMinutes": 5,
+ "estimatedDurationMaxMinutes": 15,
+ "minPlayers": 2,
+ "maxPlayers": 2,
+ "modes": ["ai", "multiplayer", "ranked"],
+ "featuredRank": null,
+ "sortOrder": 4,
+ "artToken": "checkers",
+ "artColorA": "#F2C94C",
+ "artColorB": "#27AE60",
+ "artAltText": "Checkers abstract game artwork"
+ },
+ {
+ "legacyMockId": "word",
+ "slug": "five-letter-duel",
+ "name": "Five-Letter Duel",
+ "summary": "A head-to-head word-guessing duel with a five-letter target.",
+ "rulesSummary": "Five-Letter Duel compares valid five-letter guesses.",
+ "category": "puzzle",
+ "tags": ["vocabulary"],
+ "difficulty": "Medium",
+ "estimatedDurationMinMinutes": 4,
+ "estimatedDurationMaxMinutes": 8,
+ "minPlayers": 2,
+ "maxPlayers": 2,
+ "modes": ["multiplayer"],
+ "featuredRank": null,
+ "sortOrder": 5,
+ "artToken": "five-letter",
+ "artColorA": "#56CCF2",
+ "artColorB": "#9B51E0",
+ "artAltText": "Five-Letter Duel abstract game artwork"
+ },
+ {
+ "legacyMockId": "memory",
+ "slug": "memory-grid",
+ "name": "Memory Grid",
+ "summary": "A pair-matching memory challenge playable solo or with friends.",
+ "rulesSummary": "Memory Grid matches hidden pairs.",
+ "category": "puzzle",
+ "tags": ["memory"],
+ "difficulty": "Easy",
+ "estimatedDurationMinMinutes": 3,
+ "estimatedDurationMaxMinutes": 6,
+ "minPlayers": 1,
+ "maxPlayers": 4,
+ "modes": ["solo", "cooperative", "multiplayer"],
+ "featuredRank": null,
+ "sortOrder": 6,
+ "artToken": "memory-grid",
+ "artColorA": "#F2994A",
+ "artColorB": "#EB5757",
+ "artAltText": "Memory Grid abstract game artwork"
+ },
+ {
+ "legacyMockId": "snake",
+ "slug": "snake-rush",
+ "name": "Snake Rush",
+ "summary": "A quick-reflex snake game for solo runs or competitive lobbies.",
+ "rulesSummary": "Snake Rush grows by collecting items and loses on a validated collision.",
+ "category": "arcade",
+ "tags": ["reaction"],
+ "difficulty": "Medium",
+ "estimatedDurationMinMinutes": 2,
+ "estimatedDurationMaxMinutes": 5,
+ "minPlayers": 1,
+ "maxPlayers": 8,
+ "modes": ["solo", "multiplayer", "quick-match"],
+ "featuredRank": null,
+ "sortOrder": 7,
+ "artToken": "snake-rush",
+ "artColorA": "#27AE60",
+ "artColorB": "#56CCF2",
+ "artAltText": "Snake Rush abstract game artwork"
+ }
+ ]
+}
diff --git a/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.Designer.cs b/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.Designer.cs
new file mode 100644
index 0000000..8491060
--- /dev/null
+++ b/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.Designer.cs
@@ -0,0 +1,1184 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using SimPle.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace SimPle.Infrastructure.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260710072312_AddGameCatalog")]
+ partial class AddGameCatalog
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("SimPle.Domain.Friends.Block", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BlockedId")
+ .HasColumnType("uuid");
+
+ b.Property("BlockerId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BlockedId");
+
+ b.HasIndex("BlockerId");
+
+ b.HasIndex("BlockerId", "BlockedId")
+ .IsUnique();
+
+ b.ToTable("blocks", null, t =>
+ {
+ t.HasCheckConstraint("ck_no_self_block", "\"BlockerId\" != \"BlockedId\"");
+ });
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Friends.DismissedFriendSuggestion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DismissedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SuggestedUserId")
+ .HasColumnType("uuid");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpiresAt")
+ .HasDatabaseName("ix_dismissed_suggestions_expiresat");
+
+ b.HasIndex("SuggestedUserId");
+
+ b.HasIndex("UserId", "SuggestedUserId")
+ .IsUnique()
+ .HasDatabaseName("ix_dismissed_suggestions_user_suggested");
+
+ b.ToTable("dismissed_friend_suggestions", null, t =>
+ {
+ t.HasCheckConstraint("ck_no_self_dismissal", "\"UserId\" != \"SuggestedUserId\"");
+ });
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Friends.Friendship", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AcceptedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AddresseeId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DomainVersion")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasDefaultValue(1L);
+
+ b.Property("EndReason")
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("EndedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSenderId")
+ .HasColumnType("uuid");
+
+ b.Property("NextRequestAllowedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RequestCycleId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.Property("RequesterId")
+ .HasColumnType("uuid");
+
+ b.Property("SendCountInWindow")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("SendWindowStartUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SentAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("TransitionActorId")
+ .HasColumnType("uuid");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AddresseeId", "Status", "SentAt", "Id")
+ .IsDescending(false, false, true, true)
+ .HasDatabaseName("ix_friendships_addressee_status_sentat_id");
+
+ b.HasIndex("RequesterId", "Status", "SentAt", "Id")
+ .IsDescending(false, false, true, true)
+ .HasDatabaseName("ix_friendships_requester_status_sentat_id");
+
+ b.ToTable("friendships", null, t =>
+ {
+ t.HasCheckConstraint("ck_no_self_friendship", "\"RequesterId\" != \"AddresseeId\"");
+ });
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Friends.UserFriendSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FriendRequestPrivacy")
+ .IsRequired()
+ .HasMaxLength(24)
+ .HasColumnType("character varying(24)");
+
+ b.Property("FriendsListVisibility")
+ .IsRequired()
+ .HasMaxLength(24)
+ .HasColumnType("character varying(24)");
+
+ b.Property("PrivacyPolicyVersion")
+ .HasColumnType("bigint");
+
+ b.Property("SearchVisibility")
+ .IsRequired()
+ .HasMaxLength(24)
+ .HasColumnType("character varying(24)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("user_friend_settings", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Games.CatalogSeedHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AppliedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Checksum")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character(64)")
+ .IsFixedLength();
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ManifestVersion")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ManifestVersion")
+ .IsUnique();
+
+ b.ToTable("catalog_seed_history", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Games.Game", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ArtAltText")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ArtColorA")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ArtColorB")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ArtToken")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Difficulty")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("EstimatedDurationMaxMinutes")
+ .HasColumnType("integer");
+
+ b.Property("EstimatedDurationMinMinutes")
+ .HasColumnType("integer");
+
+ b.Property("FeaturedRank")
+ .HasColumnType("integer");
+
+ b.Property("Lifecycle")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("ManifestVersion")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MaxPlayers")
+ .HasColumnType("integer");
+
+ b.Property("MinPlayers")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RulesSummary")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("Summary")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.HasIndex("Difficulty", "Slug")
+ .HasDatabaseName("ix_games_difficulty_slug");
+
+ b.HasIndex("EstimatedDurationMinMinutes", "Slug")
+ .HasDatabaseName("ix_games_duration_slug");
+
+ b.HasIndex("Name", "Slug")
+ .HasDatabaseName("ix_games_name_slug");
+
+ b.HasIndex("FeaturedRank", "SortOrder", "Slug")
+ .HasDatabaseName("ix_games_default_order");
+
+ b.ToTable("games", null, t =>
+ {
+ t.HasCheckConstraint("ck_games_draft_retired_not_featured", "(\"Lifecycle\" <> 'Draft' AND \"Lifecycle\" <> 'Retired') OR \"FeaturedRank\" IS NULL");
+
+ t.HasCheckConstraint("ck_games_duration_bounds", "\"EstimatedDurationMinMinutes\" <= \"EstimatedDurationMaxMinutes\"");
+
+ t.HasCheckConstraint("ck_games_min_players", "\"MinPlayers\" >= 1 AND \"MinPlayers\" <= \"MaxPlayers\"");
+ });
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GameId")
+ .HasColumnType("uuid");
+
+ b.Property("Mode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameId", "Mode")
+ .IsUnique();
+
+ b.ToTable("game_mode_capabilities", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Games.GameTag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GameId")
+ .HasColumnType("uuid");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameId", "Value")
+ .IsUnique();
+
+ b.ToTable("game_tags", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CycleId")
+ .HasColumnType("integer");
+
+ b.Property("GameId")
+ .HasColumnType("uuid");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameId");
+
+ b.HasIndex("UserId", "GameId")
+ .IsUnique();
+
+ b.ToTable("user_favorite_games", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AttemptCount")
+ .HasColumnType("integer");
+
+ b.Property("DeadLettered")
+ .HasColumnType("boolean");
+
+ b.Property("EventId")
+ .HasColumnType("uuid");
+
+ b.Property("HandlerName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("LastError")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("Lease")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Processed")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EventId", "HandlerName")
+ .IsUnique()
+ .HasDatabaseName("ix_outbox_deliveries_event_handler");
+
+ b.HasIndex("HandlerName", "Processed", "DeadLettered")
+ .HasDatabaseName("ix_outbox_deliveries_handler_processed_dead");
+
+ b.ToTable("outbox_deliveries", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Outbox.OutboxMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AggregateDomainVersion")
+ .HasColumnType("bigint");
+
+ b.Property("AggregateId")
+ .HasColumnType("uuid");
+
+ b.Property("AggregateType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("EventVersion")
+ .HasColumnType("integer");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("RequestCycleId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OccurredAtUtc")
+ .HasDatabaseName("ix_outbox_messages_occurredat");
+
+ b.HasIndex("AggregateId", "EventType", "AggregateDomainVersion")
+ .IsUnique()
+ .HasDatabaseName("ix_outbox_messages_aggregate_event_version");
+
+ b.ToTable("outbox_messages", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Profiles.ProfileExternalLink", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DisplayLabel")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Platform")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("profile_external_links", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "NormalizedName")
+ .IsUnique();
+
+ b.ToTable("profile_interest_tags", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Profiles.RetiredUsername", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedUsername")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("PriorOwnerUserId")
+ .HasColumnType("uuid");
+
+ b.Property("RetiredAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedUsername")
+ .IsUnique();
+
+ b.HasIndex("PriorOwnerUserId");
+
+ b.ToTable("retired_usernames", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b =>
+ {
+ b.Property