From 04745cdd0f9b8b5f6d09463e0cf0bcebfdffcdd9 Mon Sep 17 00:00:00 2001
From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:21:01 +0300
Subject: [PATCH 1/3] feat(module-04-games): add game catalog domain, schema,
and seeder
Adds Game/GameTag/GameModeCapability/UserFavoriteGame/CatalogSeedHistory
entities, EF configurations, two additive migrations, and the
checksum-verified seed manifest + advisory-locked seeder CLI for the
canonical 8-game catalog.
---
src/SimPle.Domain/Games/CatalogSeedHistory.cs | 20 +
src/SimPle.Domain/Games/Game.cs | 311 ++++-
src/SimPle.Domain/Games/GameModeCapability.cs | 18 +
src/SimPle.Domain/Games/GameTag.cs | 18 +
src/SimPle.Domain/Games/UserFavoriteGame.cs | 43 +
.../DependencyInjection.cs | 1 +
.../Games/CatalogSeedManifest.cs | 73 +
.../Games/GameCatalogSeeder.cs | 267 ++++
.../Games/catalog.seed.schema.json | 74 +
.../Games/catalog.seed.v1.json | 173 +++
.../20260710072312_AddGameCatalog.Designer.cs | 1184 ++++++++++++++++
.../20260710072312_AddGameCatalog.cs | 218 +++
...073819_AddGameLifecycleVersion.Designer.cs | 1187 +++++++++++++++++
.../20260710073819_AddGameLifecycleVersion.cs | 29 +
.../Migrations/AppDbContextModelSnapshot.cs | 277 ++++
.../Persistence/AppDbContext.cs | 8 +
.../CatalogSeedHistoryConfiguration.cs | 20 +
.../Configurations/GameConfiguration.cs | 64 +
.../GameModeCapabilityConfiguration.cs | 18 +
.../Configurations/GameTagConfiguration.cs | 18 +
.../UserFavoriteGameConfiguration.cs | 23 +
.../SimPle.Infrastructure.csproj | 5 +
22 files changed, 4024 insertions(+), 25 deletions(-)
create mode 100644 src/SimPle.Domain/Games/CatalogSeedHistory.cs
create mode 100644 src/SimPle.Domain/Games/GameModeCapability.cs
create mode 100644 src/SimPle.Domain/Games/GameTag.cs
create mode 100644 src/SimPle.Domain/Games/UserFavoriteGame.cs
create mode 100644 src/SimPle.Infrastructure/Games/CatalogSeedManifest.cs
create mode 100644 src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs
create mode 100644 src/SimPle.Infrastructure/Games/catalog.seed.schema.json
create mode 100644 src/SimPle.Infrastructure/Games/catalog.seed.v1.json
create mode 100644 src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.Designer.cs
create mode 100644 src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.cs
create mode 100644 src/SimPle.Infrastructure/Migrations/20260710073819_AddGameLifecycleVersion.Designer.cs
create mode 100644 src/SimPle.Infrastructure/Migrations/20260710073819_AddGameLifecycleVersion.cs
create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/CatalogSeedHistoryConfiguration.cs
create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/GameConfiguration.cs
create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/GameModeCapabilityConfiguration.cs
create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/GameTagConfiguration.cs
create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/UserFavoriteGameConfiguration.cs
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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CancelledAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedRequestedUsername")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("RejectionReason")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("RequestMonth")
+ .HasColumnType("integer");
+
+ b.Property("RequestYear")
+ .HasColumnType("integer");
+
+ b.Property("RequestedUsername")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("ReviewedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReviewedBy")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("UserId", "Status");
+
+ b.HasIndex("UserId", "RequestYear", "RequestMonth");
+
+ b.ToTable("username_change_requests", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PendingEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("email_verification_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("password_reset_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedByIp")
+ .IsRequired()
+ .HasMaxLength(45)
+ .HasColumnType("character varying(45)");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FamilyId")
+ .HasColumnType("uuid");
+
+ b.Property("ReplacedByTokenHash")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RevokedReason")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserAgent")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FamilyId");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("refresh_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Users.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AvatarObjectKey")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("AvatarUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("BannerFallbackColor")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("BannerObjectKey")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("BannerUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Bio")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Elo")
+ .HasColumnType("integer");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(254)
+ .HasColumnType("character varying(254)");
+
+ b.Property("FailedLoginCount")
+ .HasColumnType("integer");
+
+ b.Property("GoogleId")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Initials")
+ .IsRequired()
+ .HasMaxLength(4)
+ .HasColumnType("character varying(4)");
+
+ b.Property("IsEmailVerified")
+ .HasColumnType("boolean");
+
+ b.Property("IsSuspended")
+ .HasColumnType("boolean");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastUsernameAdminRequestMonth")
+ .HasColumnType("integer");
+
+ b.Property("LastUsernameAdminRequestYear")
+ .HasColumnType("integer");
+
+ b.Property("LastUsernameImmediateChangeMonth")
+ .HasColumnType("integer");
+
+ b.Property("LastUsernameImmediateChangeYear")
+ .HasColumnType("integer");
+
+ b.Property("Level")
+ .HasColumnType("integer");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedEmail")
+ .IsRequired()
+ .HasMaxLength(254)
+ .HasColumnType("character varying(254)");
+
+ b.Property("NormalizedUsername")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProfileType")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("Player");
+
+ b.Property("Region")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("StatusMessage")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("SubscriptionTier")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SuspendedUntil")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("Visibility")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("Public");
+
+ b.Property("Xp")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GoogleId")
+ .IsUnique()
+ .HasFilter("\"GoogleId\" IS NOT NULL");
+
+ b.HasIndex("NormalizedEmail")
+ .IsUnique();
+
+ b.HasIndex("NormalizedUsername")
+ .IsUnique();
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Friends.Block", b =>
+ {
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("BlockedId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("BlockerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Friends.DismissedFriendSuggestion", b =>
+ {
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("SuggestedUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Friends.Friendship", b =>
+ {
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("AddresseeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("RequesterId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Friends.UserFriendSettings", b =>
+ {
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b =>
+ {
+ b.HasOne("SimPle.Domain.Games.Game", null)
+ .WithMany("Capabilities")
+ .HasForeignKey("GameId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Games.GameTag", b =>
+ {
+ b.HasOne("SimPle.Domain.Games.Game", null)
+ .WithMany("Tags")
+ .HasForeignKey("GameId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b =>
+ {
+ b.HasOne("SimPle.Domain.Games.Game", null)
+ .WithMany()
+ .HasForeignKey("GameId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b =>
+ {
+ b.HasOne("SimPle.Domain.Outbox.OutboxMessage", null)
+ .WithMany()
+ .HasForeignKey("EventId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Profiles.ProfileExternalLink", b =>
+ {
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b =>
+ {
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b =>
+ {
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b =>
+ {
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b =>
+ {
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b =>
+ {
+ b.HasOne("SimPle.Domain.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Games.Game", b =>
+ {
+ b.Navigation("Capabilities");
+
+ b.Navigation("Tags");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.cs b/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.cs
new file mode 100644
index 0000000..39ed6d0
--- /dev/null
+++ b/src/SimPle.Infrastructure/Migrations/20260710072312_AddGameCatalog.cs
@@ -0,0 +1,218 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace SimPle.Infrastructure.Migrations
+{
+ ///
+ public partial class AddGameCatalog : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "catalog_seed_history",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ ManifestVersion = table.Column(type: "text", nullable: false),
+ Checksum = table.Column(type: "character(64)", fixedLength: true, maxLength: 64, nullable: false),
+ AppliedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_catalog_seed_history", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "games",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Slug = table.Column(type: "text", nullable: false),
+ Name = table.Column(type: "text", nullable: false),
+ Summary = table.Column(type: "text", nullable: false),
+ RulesSummary = table.Column(type: "text", nullable: false),
+ Category = table.Column(type: "text", nullable: false),
+ Difficulty = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ EstimatedDurationMinMinutes = table.Column(type: "integer", nullable: false),
+ EstimatedDurationMaxMinutes = table.Column(type: "integer", nullable: false),
+ MinPlayers = table.Column(type: "integer", nullable: false),
+ MaxPlayers = table.Column(type: "integer", nullable: false),
+ Lifecycle = table.Column(type: "character varying(16)", maxLength: 16, nullable: false),
+ FeaturedRank = table.Column(type: "integer", nullable: true),
+ SortOrder = table.Column(type: "integer", nullable: false),
+ ArtToken = table.Column