From e09a9ab5b93da4858b08af9f85a151a5a287d670 Mon Sep 17 00:00:00 2001
From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com>
Date: Sun, 12 Jul 2026 20:41:34 +0300
Subject: [PATCH 1/6] feat(module-06-lobby-matchmaking): add lobby,
matchmaking, and capability domain models
Adds the Lobby aggregate (members, invites, join credentials, settings,
outcomes), matchmaking tickets/bands/assignments, and game capability
profiles, plus the additive EF Core migration and entity configurations
backing them.
---
.../Capabilities/CapabilitySeedHistory.cs | 26 +
.../Capabilities/GameCapabilityProfile.cs | 294 +++
src/SimPle.Domain/Lobbies/Lobby.cs | 406 +++-
src/SimPle.Domain/Lobbies/LobbyAllowLists.cs | 74 +
src/SimPle.Domain/Lobbies/LobbyEnums.cs | 70 +
src/SimPle.Domain/Lobbies/LobbyInvite.cs | 82 +
.../Lobbies/LobbyJoinCredential.cs | 164 ++
src/SimPle.Domain/Lobbies/LobbyMember.cs | 64 +
src/SimPle.Domain/Lobbies/LobbyOutcomes.cs | 53 +
src/SimPle.Domain/Lobbies/LobbySettings.cs | 41 +
.../Lobbies/LobbyStartRequest.cs | 84 +
.../Matchmaking/MatchmakingAssignment.cs | 82 +
.../Matchmaking/MatchmakingBands.cs | 40 +
.../Matchmaking/MatchmakingEnums.cs | 55 +
.../Matchmaking/MatchmakingTicket.cs | 242 +++
...obbyMatchmakingAndCapabilities.Designer.cs | 1912 +++++++++++++++++
...5731_AddLobbyMatchmakingAndCapabilities.cs | 497 +++++
.../Migrations/AppDbContextModelSnapshot.cs | 899 +++++++-
.../Persistence/AppDbContext.cs | 17 +
.../GameCapabilityProfileConfiguration.cs | 79 +
.../Configurations/LobbyConfiguration.cs | 131 ++
.../LobbyInviteConfiguration.cs | 144 ++
.../MatchmakingConfiguration.cs | 119 +
23 files changed, 5449 insertions(+), 126 deletions(-)
create mode 100644 src/SimPle.Domain/Capabilities/CapabilitySeedHistory.cs
create mode 100644 src/SimPle.Domain/Capabilities/GameCapabilityProfile.cs
create mode 100644 src/SimPle.Domain/Lobbies/LobbyAllowLists.cs
create mode 100644 src/SimPle.Domain/Lobbies/LobbyEnums.cs
create mode 100644 src/SimPle.Domain/Lobbies/LobbyInvite.cs
create mode 100644 src/SimPle.Domain/Lobbies/LobbyJoinCredential.cs
create mode 100644 src/SimPle.Domain/Lobbies/LobbyMember.cs
create mode 100644 src/SimPle.Domain/Lobbies/LobbyOutcomes.cs
create mode 100644 src/SimPle.Domain/Lobbies/LobbySettings.cs
create mode 100644 src/SimPle.Domain/Lobbies/LobbyStartRequest.cs
create mode 100644 src/SimPle.Domain/Matchmaking/MatchmakingAssignment.cs
create mode 100644 src/SimPle.Domain/Matchmaking/MatchmakingBands.cs
create mode 100644 src/SimPle.Domain/Matchmaking/MatchmakingEnums.cs
create mode 100644 src/SimPle.Domain/Matchmaking/MatchmakingTicket.cs
create mode 100644 src/SimPle.Infrastructure/Migrations/20260711195731_AddLobbyMatchmakingAndCapabilities.Designer.cs
create mode 100644 src/SimPle.Infrastructure/Migrations/20260711195731_AddLobbyMatchmakingAndCapabilities.cs
create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/GameCapabilityProfileConfiguration.cs
create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/LobbyConfiguration.cs
create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/LobbyInviteConfiguration.cs
create mode 100644 src/SimPle.Infrastructure/Persistence/Configurations/MatchmakingConfiguration.cs
diff --git a/src/SimPle.Domain/Capabilities/CapabilitySeedHistory.cs b/src/SimPle.Domain/Capabilities/CapabilitySeedHistory.cs
new file mode 100644
index 0000000..7fefd9e
--- /dev/null
+++ b/src/SimPle.Domain/Capabilities/CapabilitySeedHistory.cs
@@ -0,0 +1,26 @@
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Capabilities;
+
+///
+/// Records that a given capability manifest version was applied, with its content checksum.
+///
+/// Deliberately a separate table from Module 4's catalog_seed_history rather than a shared one: the two
+/// manifests version independently, so a shared ManifestVersion key would collide the moment both seeders
+/// happened to ship a "2026.1", and one seeder would read the other's checksum and refuse to run.
+///
+public class CapabilitySeedHistory : Entity
+{
+ public string ManifestVersion { get; private set; } = default!;
+ public string Checksum { get; private set; } = default!;
+ public DateTime AppliedAtUtc { get; private set; }
+
+ private CapabilitySeedHistory() { }
+
+ public static CapabilitySeedHistory Record(string manifestVersion, string checksum, DateTime appliedAtUtc) => new()
+ {
+ ManifestVersion = manifestVersion,
+ Checksum = checksum,
+ AppliedAtUtc = appliedAtUtc,
+ };
+}
diff --git a/src/SimPle.Domain/Capabilities/GameCapabilityProfile.cs b/src/SimPle.Domain/Capabilities/GameCapabilityProfile.cs
new file mode 100644
index 0000000..99f0e25
--- /dev/null
+++ b/src/SimPle.Domain/Capabilities/GameCapabilityProfile.cs
@@ -0,0 +1,294 @@
+using SimPle.Domain.Common;
+using SimPle.Domain.Games;
+using SimPle.Domain.Lobbies;
+
+namespace SimPle.Domain.Capabilities;
+
+///
+/// What a lobby or ticket may configure for a given game, at a pinned capability version (D2).
+///
+/// The brief assumes lobby settings come from "the pinned M4/M5 capability version", but Module 4's catalog has no
+/// such thing — it models only slug, player bounds, a mode list, and a lifecycle counter, with nothing for time
+/// controls, tie-break rules, spectator policy, or rated eligibility. Rather than mutate M4-owned schema and
+/// re-seed a catalog it does not own, Module 6 owns this additive table keyed by
+/// (GameSlug, CapabilityVersion).
+///
+/// Ownership stays clean: M4 owns what a game is; M6 owns what a lobby may configure. If M9 adds
+/// AI difficulty tiers or M10 adds real ratings, they extend this profile rather than M4's catalog.
+///
+/// A lobby/ticket pins (GameSlug, CapabilityVersion) at creation. Because the pin is immutable but the
+/// profile can be deactivated underneath it, "capability disabled after create" becomes a real, testable path
+/// rather than a theoretical one — see and .
+///
+public class GameCapabilityProfile : Entity
+{
+ public string GameSlug { get; private set; } = default!;
+
+ /// Immutable half of the pin. Bumped by publishing a new profile row, never by editing this one.
+ public int CapabilityVersion { get; private set; }
+
+ public int MinPlayers { get; private set; }
+ public int MaxPlayers { get; private set; }
+
+ // Npgsql maps List to text[] natively, so these need no junction tables.
+ public List AllowedModes { get; private set; } = new();
+ public List TimeControls { get; private set; } = new();
+ public List TieBreakRules { get; private set; } = new();
+ public List SpectatorPolicies { get; private set; } = new();
+
+ public bool RatedEligible { get; private set; }
+ public bool AiFillEligible { get; private set; }
+
+ ///
+ /// A deactivated profile still exists (lobbies pinned to it must remain readable) but rejects every new
+ /// command that depends on it.
+ ///
+ public bool IsActive { get; private set; } = true;
+
+ /// Last manifest version that wrote this row (seeder bookkeeping only).
+ public string ManifestVersion { get; private set; } = default!;
+
+ private GameCapabilityProfile() { }
+
+ public static GameCapabilityProfile Create(
+ string gameSlug,
+ int capabilityVersion,
+ int minPlayers,
+ int maxPlayers,
+ IEnumerable allowedModes,
+ IEnumerable timeControls,
+ IEnumerable tieBreakRules,
+ IEnumerable spectatorPolicies,
+ bool ratedEligible,
+ bool aiFillEligible,
+ string manifestVersion)
+ {
+ var profile = new GameCapabilityProfile
+ {
+ GameSlug = RequireNonEmpty(gameSlug, nameof(gameSlug)),
+ CapabilityVersion = capabilityVersion,
+ MinPlayers = minPlayers,
+ MaxPlayers = maxPlayers,
+ AllowedModes = allowedModes.ToList(),
+ TimeControls = timeControls.ToList(),
+ TieBreakRules = tieBreakRules.ToList(),
+ SpectatorPolicies = spectatorPolicies.ToList(),
+ RatedEligible = ratedEligible,
+ AiFillEligible = aiFillEligible,
+ ManifestVersion = RequireNonEmpty(manifestVersion, nameof(manifestVersion)),
+ IsActive = true,
+ };
+
+ profile.Validate();
+ return profile;
+ }
+
+ ///
+ /// Manifest re-application. and are the pin and are never
+ /// changed here — publishing different capabilities means publishing a new version row, because a
+ /// lobby that pinned v1 must keep meaning what it meant when it was created.
+ ///
+ public void ApplyManifestUpdate(
+ int minPlayers,
+ int maxPlayers,
+ IEnumerable allowedModes,
+ IEnumerable timeControls,
+ IEnumerable tieBreakRules,
+ IEnumerable spectatorPolicies,
+ bool ratedEligible,
+ bool aiFillEligible,
+ bool isActive,
+ string manifestVersion)
+ {
+ MinPlayers = minPlayers;
+ MaxPlayers = maxPlayers;
+ AllowedModes = allowedModes.ToList();
+ TimeControls = timeControls.ToList();
+ TieBreakRules = tieBreakRules.ToList();
+ SpectatorPolicies = spectatorPolicies.ToList();
+ RatedEligible = ratedEligible;
+ AiFillEligible = aiFillEligible;
+ IsActive = isActive;
+ ManifestVersion = RequireNonEmpty(manifestVersion, nameof(manifestVersion));
+
+ Validate();
+ Touch();
+ }
+
+ public void Deactivate()
+ {
+ if (!IsActive) return; // idempotent
+ IsActive = false;
+ Touch();
+ }
+
+ // ── Validation used by the command layer ─────────────────────────────────
+
+ ///
+ /// Whether this profile permits the given lobby settings. Returns the specific reason on failure so the 6B
+ /// service can distinguish an inactive pin from a merely unsupported combination.
+ ///
+ /// Callers must run this before persistence — that is what makes stale/unsupported combinations fail
+ /// up front rather than producing a lobby nobody can start.
+ ///
+ public CapabilityCheck Permits(LobbySettings settings)
+ {
+ if (!IsActive)
+ return CapabilityCheck.Fail("The pinned capability version is no longer active.");
+
+ if (!string.Equals(settings.GameSlug, GameSlug, StringComparison.Ordinal))
+ return CapabilityCheck.Fail("Settings name a different game than this capability profile.");
+
+ if (settings.CapabilityVersion != CapabilityVersion)
+ return CapabilityCheck.Fail("Settings pin a different capability version than this profile.");
+
+ if (settings.MaxPlayers < MinPlayers || settings.MaxPlayers > MaxPlayers)
+ return CapabilityCheck.Fail($"MaxPlayers must be between {MinPlayers} and {MaxPlayers} for this game.");
+
+ if (!TimeControls.Contains(settings.TimeControlId, StringComparer.Ordinal))
+ return CapabilityCheck.Fail($"Time control '{settings.TimeControlId}' is not supported by this game.");
+
+ if (!TieBreakRules.Contains(settings.TieBreakRuleId, StringComparer.Ordinal))
+ return CapabilityCheck.Fail($"Tie-break rule '{settings.TieBreakRuleId}' is not supported by this game.");
+
+ if (!SpectatorPolicies.Contains(settings.SpectatorPolicy.ToString(), StringComparer.Ordinal))
+ return CapabilityCheck.Fail($"Spectator policy '{settings.SpectatorPolicy}' is not supported by this game.");
+
+ if (settings.Rated && !RatedEligible)
+ return CapabilityCheck.Fail("This game does not support rated play.");
+
+ if (settings.AiFillRequested && !AiFillEligible)
+ return CapabilityCheck.Fail("This game does not support AI fill.");
+
+ return CapabilityCheck.Pass();
+ }
+
+ ///
+ /// Whether this profile permits the given matchmaking ticket (slice 6C).
+ ///
+ /// A ticket is not a lobby and cannot reuse : it names a Mode, which
+ /// has no field for, and it has no privacy, spectator policy, tie-break, or AI-fill
+ /// to validate — a Quick Match ticket configures a search, not a room. Sharing one method would have meant
+ /// inventing lobby-shaped values for a ticket and then checking them, which is how a validator starts passing
+ /// things it never actually examined.
+ ///
+ /// is the exact group size the queue will assemble, so it is checked
+ /// against the closed interval — unlike a lobby's MaxPlayers, it is not merely a ceiling.
+ ///
+ public CapabilityCheck PermitsTicket(
+ string gameSlug, int capabilityVersion, string mode, int playerCount, string timeControlId, bool rated)
+ {
+ if (!IsActive)
+ return CapabilityCheck.Fail("The pinned capability version is no longer active.");
+
+ if (!string.Equals(gameSlug, GameSlug, StringComparison.Ordinal))
+ return CapabilityCheck.Fail("The ticket names a different game than this capability profile.");
+
+ if (capabilityVersion != CapabilityVersion)
+ return CapabilityCheck.Fail("The ticket pins a different capability version than this profile.");
+
+ if (!AllowedModes.Contains(mode, StringComparer.Ordinal))
+ return CapabilityCheck.Fail($"Mode '{mode}' is not supported by this game.");
+
+ if (playerCount < MinPlayers || playerCount > MaxPlayers)
+ return CapabilityCheck.Fail($"PlayerCount must be between {MinPlayers} and {MaxPlayers} for this game.");
+
+ if (!TimeControls.Contains(timeControlId, StringComparer.Ordinal))
+ return CapabilityCheck.Fail($"Time control '{timeControlId}' is not supported by this game.");
+
+ if (rated && !RatedEligible)
+ return CapabilityCheck.Fail("This game does not support rated play.");
+
+ return CapabilityCheck.Pass();
+ }
+
+ ///
+ /// Whether this profile has drifted from M4's catalog row for the same game. A profile that permits seat counts
+ /// or modes the catalog does not is a data bug, not a user error: it would let a lobby be created that M5's
+ /// engine cannot host. The command layer treats drift exactly like an inactive pin — fail closed, before
+ /// persistence.
+ ///
+ /// Takes the two catalog facts it needs rather than the whole aggregate, so it stays a pure,
+ /// unit-testable check.
+ ///
+ public CapabilityCheck ContradictsCatalog(int catalogMinPlayers, int catalogMaxPlayers, IEnumerable catalogModes)
+ {
+ if (MinPlayers < catalogMinPlayers || MaxPlayers > catalogMaxPlayers)
+ {
+ return CapabilityCheck.Fail(
+ $"Capability profile player bounds [{MinPlayers}, {MaxPlayers}] exceed the catalog's " +
+ $"[{catalogMinPlayers}, {catalogMaxPlayers}].");
+ }
+
+ var catalog = catalogModes.ToHashSet(StringComparer.Ordinal);
+ var extra = AllowedModes.FirstOrDefault(m => !catalog.Contains(m));
+ if (extra is not null)
+ return CapabilityCheck.Fail($"Capability profile allows mode '{extra}', which the catalog does not.");
+
+ return CapabilityCheck.Pass();
+ }
+
+ private void Validate()
+ {
+ if (CapabilityVersion < 1)
+ throw new ArgumentException("CapabilityVersion must be at least 1.", nameof(CapabilityVersion));
+ if (MinPlayers < 2)
+ throw new ArgumentException("MinPlayers must be at least 2 — a lobby is a multiplayer surface.", nameof(MinPlayers));
+ if (MinPlayers > MaxPlayers)
+ throw new ArgumentException("MinPlayers must be <= MaxPlayers.", nameof(MinPlayers));
+
+ RequireNonEmptyAllowListed(AllowedModes, GameCatalogAllowLists.Modes, nameof(AllowedModes));
+ RequireNonEmptyAllowListed(TimeControls, LobbyAllowLists.TimeControls, nameof(TimeControls));
+ RequireNonEmptyAllowListed(TieBreakRules, LobbyAllowLists.TieBreakRules, nameof(TieBreakRules));
+
+ if (SpectatorPolicies.Count == 0)
+ throw new ArgumentException("SpectatorPolicies must contain at least one entry.", nameof(SpectatorPolicies));
+ foreach (var policy in SpectatorPolicies)
+ {
+ if (!Enum.TryParse(policy, ignoreCase: false, out _))
+ throw new ArgumentException($"Spectator policy '{policy}' is not a valid SpectatorPolicy.", nameof(SpectatorPolicies));
+ }
+ RequireNoDuplicates(SpectatorPolicies, nameof(SpectatorPolicies));
+
+ // A rated game must actually offer competitive play; "ranked" is M4's mode-allow-list spelling.
+ if (RatedEligible && !AllowedModes.Contains("ranked", StringComparer.Ordinal))
+ throw new ArgumentException("RatedEligible requires the 'ranked' mode to be allowed.", nameof(RatedEligible));
+
+ if (AiFillEligible && !AllowedModes.Contains("ai", StringComparer.Ordinal))
+ throw new ArgumentException("AiFillEligible requires the 'ai' mode to be allowed.", nameof(AiFillEligible));
+ }
+
+ private static void RequireNonEmptyAllowListed(List values, IReadOnlySet allowList, string paramName)
+ {
+ if (values.Count == 0)
+ throw new ArgumentException($"{paramName} must contain at least one entry.", paramName);
+
+ foreach (var value in values)
+ {
+ if (!allowList.Contains(value))
+ throw new ArgumentException($"{paramName} value '{value}' is not in the allow-list.", paramName);
+ }
+
+ RequireNoDuplicates(values, paramName);
+ }
+
+ private static void RequireNoDuplicates(List values, string paramName)
+ {
+ if (values.Distinct(StringComparer.Ordinal).Count() != values.Count)
+ throw new ArgumentException($"{paramName} must not contain duplicates.", paramName);
+ }
+
+ private static string RequireNonEmpty(string value, string paramName)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ throw new ArgumentException($"{paramName} must not be empty.", paramName);
+ return value;
+ }
+}
+
+/// Result of a capability check. The reason is safe to surface — it names a setting, never internals.
+public sealed record CapabilityCheck(bool Allowed, string? Reason)
+{
+ public static CapabilityCheck Pass() => new(true, null);
+ public static CapabilityCheck Fail(string reason) => new(false, reason);
+}
diff --git a/src/SimPle.Domain/Lobbies/Lobby.cs b/src/SimPle.Domain/Lobbies/Lobby.cs
index 75075df..7eeea0e 100644
--- a/src/SimPle.Domain/Lobbies/Lobby.cs
+++ b/src/SimPle.Domain/Lobbies/Lobby.cs
@@ -2,59 +2,387 @@
namespace SimPle.Domain.Lobbies;
+///
+/// A lobby and its seats. Replaces the orphaned pre-module stub (R2), whose Open|Closed|InGame status could
+/// not express this lifecycle and whose plaintext code violated the keyed-digest rule.
+///
+/// This aggregate owns the three invariants the brief flags as easy to get wrong:
+/// deterministic host transfer (Risk #3), readiness-reset scope (Risk #4), and terminal-state rejection.
+/// It does not validate settings against a game's capability profile — that cross-aggregate check needs
+/// M4's catalog and M5's engine registry, so it lives in the 6B service layer, which calls
+/// GameCapabilityProfile.Permits(...) before ever reaching this type.
+///
+/// Every time-sensitive value is passed in as an explicit nowUtc from the caller's injected
+/// TimeProvider (R4). The aggregate never calls DateTime.UtcNow — that is what makes the mandatory
+/// fake-clock expiry tests (Risk #8) possible.
+///
public class Lobby : Entity
{
- public string Code { get; private set; } = default!;
- public Guid GameId { get; private set; }
+ /// An open lobby expires two hours after creation. Fixed policy, not configurable.
+ public static readonly TimeSpan OpenLifetime = TimeSpan.FromHours(2);
+
+ private readonly List _members = new();
+
+ public string GameSlug { get; private set; } = default!;
+ public int CapabilityVersion { get; private set; }
public Guid HostUserId { get; private set; }
- public LobbyPrivacy Privacy { get; private set; } = LobbyPrivacy.Private;
- public int MaxSlots { get; private set; } = 4;
- public string TimeControl { get; private set; } = "Blitz 3+2";
- public bool IsRanked { get; private set; }
- public bool AiFillEnabled { get; private set; }
- public LobbyStatus Status { get; private set; } = LobbyStatus.Open;
- public DateTime ExpiresAt { get; private set; } = DateTime.UtcNow.AddHours(2);
- public Guid? GameSessionId { get; private set; }
-
- private readonly List _slots = [];
- public IReadOnlyList Slots => _slots.AsReadOnly();
+ public LobbyPrivacy Privacy { get; private set; }
+ public int MaxPlayers { get; private set; }
+ public string TimeControlId { get; private set; } = default!;
+ public bool Rated { get; private set; }
+ public string ResolvedRegion { get; private set; } = default!;
+ public SpectatorPolicy SpectatorPolicy { get; private set; }
+ public string TieBreakRuleId { get; private set; } = default!;
+
+ ///
+ /// Stored and displayed, but cannot create an AI participant before M9. Ranked start is disabled while it is
+ /// set — see .
+ ///
+ public bool AiFillRequested { get; private set; }
+
+ public LobbyState State { get; private set; } = LobbyState.Open;
+
+ ///
+ /// Bumped on every mutation. Clients send it back as an expected revision; a mismatch is a typed
+ /// Lobbies.StaleRevision conflict, never a 500. Starts at 1 (the as-created state).
+ ///
+ public int Revision { get; private set; } = 1;
+
+ public DateTime ExpiresAtUtc { get; private set; }
+ public LobbyClosedReason? ClosedReason { get; private set; }
+ public Guid CorrelationId { get; private set; }
+
+ /// Mapped to xmin via IsRowVersion() in EF config (the Npgsql optimistic-concurrency pattern).
+ public uint Version { get; private set; }
+
+ public IReadOnlyList Members => _members;
+
+ /// Members currently holding a seat, oldest-tenured first — the host-transfer order.
+ public IEnumerable JoinedMembers =>
+ _members.Where(m => m.IsJoined).OrderBy(m => m.JoinedAtUtc).ThenBy(m => m.UserId);
+
+ public int JoinedCount => _members.Count(m => m.IsJoined);
+
+ public bool IsTerminal =>
+ State is LobbyState.Started or LobbyState.Closed or LobbyState.Expired;
private Lobby() { }
- public static Lobby Create(Guid gameId, Guid hostUserId, LobbyPrivacy privacy, int maxSlots, bool isRanked)
+ public static Lobby Create(
+ Guid hostUserId,
+ LobbySettings settings,
+ Guid correlationId,
+ DateTime nowUtc)
{
+ if (hostUserId == Guid.Empty)
+ throw new ArgumentException("HostUserId must not be empty.", nameof(hostUserId));
+
var lobby = new Lobby
{
- Code = GenerateCode(),
- GameId = gameId,
HostUserId = hostUserId,
- Privacy = privacy,
- MaxSlots = maxSlots,
- IsRanked = isRanked,
+ CorrelationId = correlationId,
+ State = LobbyState.Open,
+ Revision = 1,
+ ExpiresAtUtc = nowUtc + OpenLifetime,
};
- lobby._slots.Add(new LobbySlot { LobbyId = lobby.Id, UserId = hostUserId, SeatIndex = 0, IsHost = true, IsReady = true });
+
+ lobby.ApplySettings(settings);
+
+ // The host occupies the first seat and is implicitly ready.
+ lobby._members.Add(LobbyMember.Join(lobby.Id, hostUserId, nowUtc, isReady: true));
+
return lobby;
}
- public void Close() { Status = LobbyStatus.Closed; Touch(); }
- public void Start(Guid sessionId) { Status = LobbyStatus.InGame; GameSessionId = sessionId; Touch(); }
- public void ToggleReady(Guid userId) { _slots.FirstOrDefault(s => s.UserId == userId)?.ToggleReady(); Touch(); }
+ // ── Queries ──────────────────────────────────────────────────────────────
- private static string GenerateCode() =>
- $"SP-{Random.Shared.Next(0, 99):D2}{(char)Random.Shared.Next('A', 'Z')}-{Random.Shared.Next(0, 99):D2}";
-}
+ public bool IsExpired(DateTime nowUtc) => nowUtc >= ExpiresAtUtc;
-public class LobbySlot
-{
- public Guid LobbyId { get; set; }
- public int SeatIndex { get; set; }
- public Guid? UserId { get; set; }
- public bool IsHost { get; set; }
- public bool IsAi { get; set; }
- public string? AiDifficulty { get; set; }
- public bool IsReady { get; set; }
- public void ToggleReady() => IsReady = !IsReady;
-}
+ public LobbyMember? FindJoinedMember(Guid userId) =>
+ _members.FirstOrDefault(m => m.UserId == userId && m.IsJoined);
+
+ public bool IsHost(Guid userId) => HostUserId == userId;
+
+ public LobbySettings CurrentSettings => new(
+ GameSlug, CapabilityVersion, Privacy, MaxPlayers, TimeControlId, Rated,
+ ResolvedRegion, SpectatorPolicy, TieBreakRuleId, AiFillRequested);
+
+ ///
+ /// Every joined seat is ready. The host's seat is created ready and is re-marked ready on transfer, so this is
+ /// simply "all joined members ready" — the host is never a blocker.
+ ///
+ public bool IsEveryoneReady => JoinedMembers.All(m => m.IsReady);
+
+ ///
+ /// Domain-side start preconditions. The full Start command additionally validates M3 blocks, M4 capabilities,
+ /// M5 engine availability, and M8 readiness (6B/6C) — none of which this aggregate can see.
+ ///
+ public bool CanStart(DateTime nowUtc) =>
+ State == LobbyState.Open
+ && !IsExpired(nowUtc)
+ && JoinedCount >= 2
+ && IsEveryoneReady
+ // Ranked start is disabled while AI fill is requested: M9 does not exist, so a "ranked" match with an
+ // unfillable AI seat would either hang or silently become unranked.
+ && !(Rated && AiFillRequested);
+
+ // ── Mutations ────────────────────────────────────────────────────────────
+
+ public LobbyOutcome Join(Guid userId, DateTime nowUtc)
+ {
+ var guard = GuardMutable(nowUtc);
+ if (guard != LobbyOutcome.Ok) return guard;
+
+ if (FindJoinedMember(userId) is not null)
+ return LobbyOutcome.AlreadyJoined;
+
+ // Capacity is also backed by a transactional check in 6B: the last-seat loser of a concurrent join catches
+ // 23505 on the member index and reruns the whole command, which re-reads and lands here on Full.
+ if (JoinedCount >= MaxPlayers)
+ return LobbyOutcome.Full;
+
+ _members.Add(LobbyMember.Join(Id, userId, nowUtc, isReady: false));
+ ResetNonHostReadiness();
+ Mutated();
+ return LobbyOutcome.Ok;
+ }
+
+ public LobbyLeaveResult Leave(Guid userId, DateTime nowUtc)
+ {
+ var guard = GuardMutable(nowUtc);
+ if (guard != LobbyOutcome.Ok) return LobbyLeaveResult.Failed(guard);
+
+ var member = FindJoinedMember(userId);
+ if (member is null) return LobbyLeaveResult.Failed(LobbyOutcome.NotMember);
+
+ member.Leave(nowUtc);
+
+ if (!IsHost(userId))
+ {
+ ResetNonHostReadiness();
+ Mutated();
+ return new LobbyLeaveResult(LobbyOutcome.Ok, null, null);
+ }
+
+ // Host left: transfer to the longest-tenured eligible joined human, tie-broken by user id (Risk #3 — this
+ // is fixed policy, so two clients can never disagree on who the host is). JoinedMembers is already in that
+ // exact order.
+ var successor = JoinedMembers.FirstOrDefault();
+ if (successor is null)
+ {
+ CloseInternal(LobbyClosedReason.NoEligibleHost);
+ Mutated();
+ return new LobbyLeaveResult(LobbyOutcome.Ok, null, LobbyClosedReason.NoEligibleHost);
+ }
+
+ HostUserId = successor.UserId;
+ successor.SetReadiness(true); // the new host is implicitly ready
+ ResetNonHostReadiness();
+ Mutated();
+ return new LobbyLeaveResult(LobbyOutcome.Ok, successor.UserId, null);
+ }
+
+ public LobbyOutcome Kick(Guid actorUserId, Guid targetUserId, DateTime nowUtc)
+ {
+ var guard = GuardHostAction(actorUserId, nowUtc);
+ if (guard != LobbyOutcome.Ok) return guard;
+
+ if (actorUserId == targetUserId)
+ return LobbyOutcome.InvalidTarget; // the host cannot kick self; they leave instead
+
+ var target = FindJoinedMember(targetUserId);
+ if (target is null) return LobbyOutcome.InvalidTarget;
+
+ target.Kick(actorUserId, nowUtc);
+ ResetNonHostReadiness();
+ Mutated();
+ return LobbyOutcome.Ok;
+ }
+
+ public LobbyOutcome SetReadiness(Guid userId, bool isReady, DateTime nowUtc)
+ {
+ var guard = GuardMutable(nowUtc);
+ if (guard != LobbyOutcome.Ok) return guard;
+
+ var member = FindJoinedMember(userId);
+ if (member is null) return LobbyOutcome.NotMember;
+
+ // The host is implicitly ready and cannot un-ready: their readiness is not a real signal, and letting them
+ // clear it would create a lobby that can never satisfy IsEveryoneReady.
+ if (IsHost(userId))
+ return LobbyOutcome.InvalidTarget;
+
+ member.SetReadiness(isReady);
+ Mutated();
+ return LobbyOutcome.Ok;
+ }
+
+ ///
+ /// Host-only settings change. Resets all joined non-host readiness when the change is match-affecting
+ /// () — a privacy or spectator-policy toggle alone does not
+ /// invalidate a ready roster.
+ ///
+ public LobbyOutcome ChangeSettings(Guid actorUserId, LobbySettings settings, DateTime nowUtc)
+ {
+ var guard = GuardHostAction(actorUserId, nowUtc);
+ if (guard != LobbyOutcome.Ok) return guard;
+
+ // Shrinking below the current roster would strand seated members with no defined eviction rule.
+ if (settings.MaxPlayers < JoinedCount)
+ return LobbyOutcome.Full;
+
+ var matchAffecting = CurrentSettings.IsMatchAffectingChangeTo(settings);
+
+ ApplySettings(settings);
+ if (matchAffecting)
+ ResetNonHostReadiness();
+
+ Mutated();
+ return LobbyOutcome.Ok;
+ }
+
+ ///
+ /// Open -> Starting. Called only inside the transaction that also commits exactly one MatchRequestedV1, and
+ /// only while the M8 readiness probe is healthy. A committed request is a durable request, not a
+ /// created match (Risk #6) — reaching Started requires M8's MatchCreatedV1.
+ ///
+ public LobbyOutcome BeginStarting(Guid actorUserId, DateTime nowUtc)
+ {
+ var guard = GuardHostAction(actorUserId, nowUtc);
+ if (guard != LobbyOutcome.Ok) return guard;
+
+ if (!CanStart(nowUtc)) return LobbyOutcome.NotStartable;
+
+ State = LobbyState.Starting;
+ Mutated();
+ return LobbyOutcome.Ok;
+ }
+
+ /// Starting -> Started, on M8's MatchCreatedV1. Terminal.
+ public LobbyOutcome MarkStarted()
+ {
+ if (State != LobbyState.Starting) return LobbyOutcome.NotStartable;
+
+ State = LobbyState.Started;
+ Mutated();
+ return LobbyOutcome.Ok;
+ }
+
+ ///
+ /// Starting -> Open, on M8's recoverable MatchCreationFailedV1. Readiness is preserved unless the failure
+ /// identified stale settings or membership, in which case every joined non-host human must re-confirm.
+ ///
+ public LobbyOutcome ReturnToOpen(bool resetReadiness, DateTime nowUtc)
+ {
+ if (State != LobbyState.Starting) return LobbyOutcome.NotStartable;
+
+ // A lobby that expired while M8 was working does not silently reopen.
+ if (IsExpired(nowUtc))
+ {
+ CloseInternal(LobbyClosedReason.Expired);
+ State = LobbyState.Expired;
+ Mutated();
+ return LobbyOutcome.Expired;
+ }
+
+ State = LobbyState.Open;
+ if (resetReadiness)
+ ResetNonHostReadiness();
+
+ Mutated();
+ return LobbyOutcome.Ok;
+ }
+
+ public LobbyOutcome Close(LobbyClosedReason reason)
+ {
+ if (IsTerminal) return LobbyOutcome.Closed;
+
+ CloseInternal(reason);
+ Mutated();
+ return LobbyOutcome.Ok;
+ }
-public enum LobbyPrivacy { Private, Public }
-public enum LobbyStatus { Open, Closed, InGame }
+ ///
+ /// Expiry sweep entry point (6C's expiry worker). Idempotent: returns false when the lobby is already terminal
+ /// or not yet past its deadline, so a re-run of the sweep is a no-op rather than a second state change.
+ ///
+ public bool TryExpire(DateTime nowUtc)
+ {
+ if (IsTerminal || !IsExpired(nowUtc)) return false;
+
+ State = LobbyState.Expired;
+ ClosedReason = LobbyClosedReason.Expired;
+ Mutated();
+ return true;
+ }
+
+ // ── Internals ────────────────────────────────────────────────────────────
+
+ private LobbyOutcome GuardMutable(DateTime nowUtc)
+ {
+ if (IsTerminal) return LobbyOutcome.Closed;
+ if (IsExpired(nowUtc)) return LobbyOutcome.Expired;
+ return LobbyOutcome.Ok;
+ }
+
+ private LobbyOutcome GuardHostAction(Guid actorUserId, DateTime nowUtc)
+ {
+ var guard = GuardMutable(nowUtc);
+ if (guard != LobbyOutcome.Ok) return guard;
+
+ // A non-member gets the privacy-safe not-found rather than a 403 that would confirm the lobby exists.
+ if (FindJoinedMember(actorUserId) is null) return LobbyOutcome.NotMember;
+ if (!IsHost(actorUserId)) return LobbyOutcome.Forbidden;
+
+ return LobbyOutcome.Ok;
+ }
+
+ ///
+ /// Clears readiness for every joined member except the host, who is implicitly ready and is never counted in a
+ /// reset (Risk #4).
+ ///
+ private void ResetNonHostReadiness()
+ {
+ foreach (var member in _members.Where(m => m.IsJoined && m.UserId != HostUserId))
+ member.SetReadiness(false);
+ }
+
+ private void CloseInternal(LobbyClosedReason reason)
+ {
+ State = LobbyState.Closed;
+ ClosedReason = reason;
+ }
+
+ private void Mutated()
+ {
+ Revision += 1;
+ Touch();
+ }
+
+ private void ApplySettings(LobbySettings settings)
+ {
+ if (string.IsNullOrWhiteSpace(settings.GameSlug))
+ throw new ArgumentException("GameSlug must not be empty.", nameof(settings));
+ if (settings.CapabilityVersion < 1)
+ throw new ArgumentException("CapabilityVersion must be at least 1.", nameof(settings));
+ if (settings.MaxPlayers < 2)
+ throw new ArgumentException("MaxPlayers must be at least 2 — a lobby is a multiplayer surface.", nameof(settings));
+ if (!LobbyAllowLists.TimeControls.Contains(settings.TimeControlId))
+ throw new ArgumentException($"TimeControlId '{settings.TimeControlId}' is not in the allow-list.", nameof(settings));
+ if (!LobbyAllowLists.TieBreakRules.Contains(settings.TieBreakRuleId))
+ throw new ArgumentException($"TieBreakRuleId '{settings.TieBreakRuleId}' is not in the allow-list.", nameof(settings));
+ if (!LobbyRegion.IsResolved(settings.ResolvedRegion))
+ throw new ArgumentException($"ResolvedRegion '{settings.ResolvedRegion}' must be an explicit allow-listed region, never 'Auto'.", nameof(settings));
+
+ GameSlug = settings.GameSlug;
+ CapabilityVersion = settings.CapabilityVersion;
+ Privacy = settings.Privacy;
+ MaxPlayers = settings.MaxPlayers;
+ TimeControlId = settings.TimeControlId;
+ Rated = settings.Rated;
+ ResolvedRegion = settings.ResolvedRegion;
+ SpectatorPolicy = settings.SpectatorPolicy;
+ TieBreakRuleId = settings.TieBreakRuleId;
+ AiFillRequested = settings.AiFillRequested;
+ }
+}
diff --git a/src/SimPle.Domain/Lobbies/LobbyAllowLists.cs b/src/SimPle.Domain/Lobbies/LobbyAllowLists.cs
new file mode 100644
index 0000000..948a0cc
--- /dev/null
+++ b/src/SimPle.Domain/Lobbies/LobbyAllowLists.cs
@@ -0,0 +1,74 @@
+namespace SimPle.Domain.Lobbies;
+
+///
+/// Phase-1 platform allow-lists for lobby settings that Module 4's catalog does not model
+/// (time controls, tie-break rules, regions). These are the universe of legal values; which subset a
+/// given game actually permits is declared per-game by
+/// (D2). A value must clear both: it must be in
+/// the platform allow-list here and in the pinned capability profile.
+///
+/// Mirrors in shape and intent.
+///
+public static class LobbyAllowLists
+{
+ public static readonly IReadOnlySet TimeControls = new HashSet(StringComparer.Ordinal)
+ {
+ "untimed", "bullet-1-0", "blitz-3-2", "blitz-5-0", "rapid-10-0", "classical-30-0",
+ };
+
+ public static readonly IReadOnlySet TieBreakRules = new HashSet(StringComparer.Ordinal)
+ {
+ "none", "sudden-death", "fastest-finish", "highest-score", "fewest-moves",
+ };
+
+ ///
+ /// Phase 1 is same-region only: a ticket's region is part of its exact-match candidate pool key, so an
+ /// unrecognized region would silently partition the queue. A lobby/ticket therefore never stores "Auto" —
+ /// it stores a resolved member of this set (see ).
+ ///
+ public static readonly IReadOnlySet Regions = new HashSet(StringComparer.Ordinal)
+ {
+ "us-east", "us-west", "eu-west", "eu-central", "ap-south", "ap-southeast", "sa-east",
+ };
+}
+
+/// Server-side region resolution. "Auto" is a request-time input only; it is never persisted.
+public static class LobbyRegion
+{
+ /// The literal a client sends to ask the server to choose. Never stored.
+ public const string Auto = "Auto";
+
+ ///
+ /// Resolves a requested region to an explicit, allow-listed region, in the brief's order:
+ /// an explicit allow-listed request wins; otherwise the user's profile region if it is allow-listed;
+ /// otherwise the deployment default.
+ ///
+ /// is deliberately treated as untrusted: User.Region is free text
+ /// validated only for length (UpdateProfileRequestValidator), so a profile carrying "Narnia" must fall
+ /// through to the default rather than partition the matchmaking queue into a pool of one.
+ ///
+ public static string Resolve(string? requestedRegion, string? profileRegion, string deploymentDefault)
+ {
+ if (!LobbyAllowLists.Regions.Contains(deploymentDefault))
+ {
+ throw new ArgumentException(
+ $"Deployment default region '{deploymentDefault}' is not allow-listed.", nameof(deploymentDefault));
+ }
+
+ if (requestedRegion is not null
+ && !string.Equals(requestedRegion, Auto, StringComparison.Ordinal)
+ && LobbyAllowLists.Regions.Contains(requestedRegion))
+ {
+ return requestedRegion;
+ }
+
+ if (profileRegion is not null && LobbyAllowLists.Regions.Contains(profileRegion))
+ return profileRegion;
+
+ return deploymentDefault;
+ }
+
+ /// True when the value is an explicit, persistable region (never "Auto").
+ public static bool IsResolved(string region) =>
+ !string.Equals(region, Auto, StringComparison.Ordinal) && LobbyAllowLists.Regions.Contains(region);
+}
diff --git a/src/SimPle.Domain/Lobbies/LobbyEnums.cs b/src/SimPle.Domain/Lobbies/LobbyEnums.cs
new file mode 100644
index 0000000..e36b2f8
--- /dev/null
+++ b/src/SimPle.Domain/Lobbies/LobbyEnums.cs
@@ -0,0 +1,70 @@
+namespace SimPle.Domain.Lobbies;
+
+///
+/// Lobby lifecycle: Open -> Starting -> Started, or Open|Starting -> Closed|Expired.
+/// Starting is entered only when a match request is atomically committed while the M8 readiness probe is
+/// healthy; Started only after M8 returns MatchCreatedV1. Started/Closed/Expired are terminal and
+/// reject every mutation.
+///
+public enum LobbyState
+{
+ Open,
+ Starting,
+ Started,
+ Closed,
+ Expired,
+}
+
+public enum LobbyPrivacy
+{
+ Public,
+ Private,
+}
+
+public enum SpectatorPolicy
+{
+ Anyone,
+ FriendsOnly,
+ Disabled,
+}
+
+/// Auditable reason a lobby reached a terminal closed state.
+public enum LobbyClosedReason
+{
+ HostLeft,
+ NoEligibleHost,
+ HostClosed,
+ Expired,
+ HostSuspended,
+}
+
+/// Membership lifecycle. Readiness is a separate boolean, never a member state.
+public enum LobbyMemberState
+{
+ Joined,
+ Left,
+ Kicked,
+}
+
+public enum LobbyInviteState
+{
+ Pending,
+ Accepted,
+ Revoked,
+ Expired,
+}
+
+/// A rotated or revoked credential is dead immediately; only Active can be redeemed.
+public enum LobbyCredentialState
+{
+ Active,
+ Rotated,
+ Revoked,
+}
+
+public enum LobbyStartRequestState
+{
+ Open,
+ Succeeded,
+ Failed,
+}
diff --git a/src/SimPle.Domain/Lobbies/LobbyInvite.cs b/src/SimPle.Domain/Lobbies/LobbyInvite.cs
new file mode 100644
index 0000000..375baca
--- /dev/null
+++ b/src/SimPle.Domain/Lobbies/LobbyInvite.cs
@@ -0,0 +1,82 @@
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Lobbies;
+
+///
+/// A targeted invitation. Pending -> Accepted|Revoked|Expired.
+///
+/// An invite is not a membership: a user may hold many pending invites while holding at most one joined
+/// lobby or one nonterminal ticket, and an unsolicited invite never blocks them from joining or queueing
+/// elsewhere. Accepting an invite is what creates a .
+///
+public class LobbyInvite : Entity
+{
+ /// A targeted invite expires 30 minutes after it is sent, or when the lobby closes/starts.
+ public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(30);
+
+ public Guid LobbyId { get; private set; }
+ public Guid InviterUserId { get; private set; }
+ public Guid InviteeUserId { get; private set; }
+ public LobbyInviteState State { get; private set; } = LobbyInviteState.Pending;
+ public DateTime ExpiresAtUtc { get; private set; }
+ public DateTime? RespondedAtUtc { get; private set; }
+
+ private LobbyInvite() { }
+
+ public static LobbyInvite Create(Guid lobbyId, Guid inviterUserId, Guid inviteeUserId, DateTime nowUtc)
+ {
+ if (inviterUserId == inviteeUserId)
+ throw new ArgumentException("A user cannot invite themselves.", nameof(inviteeUserId));
+
+ return new LobbyInvite
+ {
+ LobbyId = lobbyId,
+ InviterUserId = inviterUserId,
+ InviteeUserId = inviteeUserId,
+ State = LobbyInviteState.Pending,
+ ExpiresAtUtc = nowUtc + Lifetime,
+ };
+ }
+
+ public bool IsPending => State == LobbyInviteState.Pending;
+
+ public bool IsExpired(DateTime nowUtc) => nowUtc >= ExpiresAtUtc;
+
+ ///
+ /// Redeemable only while pending and unexpired. Redeeming does not extend the deadline — the invite is
+ /// consumed, not refreshed.
+ ///
+ public bool CanAccept(DateTime nowUtc) => IsPending && !IsExpired(nowUtc);
+
+ public LobbyOutcome Accept(DateTime nowUtc)
+ {
+ if (!IsPending) return LobbyOutcome.Closed;
+ if (IsExpired(nowUtc)) return LobbyOutcome.Expired;
+
+ State = LobbyInviteState.Accepted;
+ RespondedAtUtc = nowUtc;
+ Touch();
+ return LobbyOutcome.Ok;
+ }
+
+ public LobbyOutcome Revoke(DateTime nowUtc)
+ {
+ if (!IsPending) return LobbyOutcome.Closed;
+
+ State = LobbyInviteState.Revoked;
+ RespondedAtUtc = nowUtc;
+ Touch();
+ return LobbyOutcome.Ok;
+ }
+
+ /// Expiry sweep entry point. Idempotent — a re-run over an already-terminal invite is a no-op.
+ public bool TryExpire(DateTime nowUtc)
+ {
+ if (!IsPending || !IsExpired(nowUtc)) return false;
+
+ State = LobbyInviteState.Expired;
+ RespondedAtUtc = nowUtc;
+ Touch();
+ return true;
+ }
+}
diff --git a/src/SimPle.Domain/Lobbies/LobbyJoinCredential.cs b/src/SimPle.Domain/Lobbies/LobbyJoinCredential.cs
new file mode 100644
index 0000000..7bffa1e
--- /dev/null
+++ b/src/SimPle.Domain/Lobbies/LobbyJoinCredential.cs
@@ -0,0 +1,164 @@
+using System.Security.Cryptography;
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Lobbies;
+
+///
+/// The join code and link token for one lobby, stored only as keyed digests.
+///
+/// The entity never sees or holds a plaintext credential: takes digests that the caller has
+/// already computed with the server key (ILobbyCredentialHasher). That is what makes "never logged, never in
+/// events, never a resource identifier" (Risk #7) a structural property rather than a coding convention — there is
+/// no plaintext field on the aggregate that could leak into a DTO, a log line, or an outbox payload.
+///
+/// Rotation supersedes rather than mutates: the old row moves to and a
+/// new row is issued at the next , so the old value is dead the instant it is replaced.
+///
+public class LobbyJoinCredential : Entity
+{
+ /// A private join credential expires 30 minutes after issue, or when the lobby closes/starts.
+ public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(30);
+
+ public Guid LobbyId { get; private set; }
+
+ /// Keyed HMAC digest of the human-typed code. Never the plaintext.
+ public string CodeDigest { get; private set; } = default!;
+
+ /// Keyed HMAC digest of the 128-bit share-link token. A separate secret from the code.
+ public string LinkTokenDigest { get; private set; } = default!;
+
+ /// Bumped on every rotation. Generation 1 is the credential minted at lobby creation.
+ public int Generation { get; private set; }
+
+ public LobbyCredentialState State { get; private set; } = LobbyCredentialState.Active;
+ public DateTime ExpiresAtUtc { get; private set; }
+ public DateTime? SupersededAtUtc { get; private set; }
+
+ private LobbyJoinCredential() { }
+
+ public static LobbyJoinCredential Issue(
+ Guid lobbyId,
+ string codeDigest,
+ string linkTokenDigest,
+ int generation,
+ DateTime nowUtc)
+ {
+ if (string.IsNullOrWhiteSpace(codeDigest))
+ throw new ArgumentException("CodeDigest must not be empty.", nameof(codeDigest));
+ if (string.IsNullOrWhiteSpace(linkTokenDigest))
+ throw new ArgumentException("LinkTokenDigest must not be empty.", nameof(linkTokenDigest));
+ if (generation < 1)
+ throw new ArgumentException("Generation must be at least 1.", nameof(generation));
+
+ return new LobbyJoinCredential
+ {
+ LobbyId = lobbyId,
+ CodeDigest = codeDigest,
+ LinkTokenDigest = linkTokenDigest,
+ Generation = generation,
+ State = LobbyCredentialState.Active,
+ ExpiresAtUtc = nowUtc + Lifetime,
+ };
+ }
+
+ public bool IsActive => State == LobbyCredentialState.Active;
+
+ public bool IsExpired(DateTime nowUtc) => nowUtc >= ExpiresAtUtc;
+
+ ///
+ /// Redeemable only while active and unexpired. Redeeming does not extend
+ /// — using a credential never refreshes its deadline.
+ ///
+ public bool CanRedeem(DateTime nowUtc) => IsActive && !IsExpired(nowUtc);
+
+ /// Superseded by a newly issued generation. The old value dies immediately.
+ public void MarkRotated(DateTime nowUtc)
+ {
+ if (!IsActive) return; // idempotent
+
+ State = LobbyCredentialState.Rotated;
+ SupersededAtUtc = nowUtc;
+ Touch();
+ }
+
+ /// Revoked outright with no successor (e.g. the lobby closed).
+ public void Revoke(DateTime nowUtc)
+ {
+ if (!IsActive) return; // idempotent
+
+ State = LobbyCredentialState.Revoked;
+ SupersededAtUtc = nowUtc;
+ Touch();
+ }
+}
+
+///
+/// Generates the plaintext credentials. Pure and key-free — turning a plaintext into a stored digest is the
+/// separate concern of ILobbyCredentialHasher, which holds the server key.
+///
+public static class LobbyCredentialFormat
+{
+ ///
+ /// 32 symbols, deliberately excluding 0/O/1/I so a code read aloud or off a screen cannot be mistyped.
+ /// A 32-symbol alphabet is exactly 5 bits per character, and 256 is an exact multiple of 32, so the
+ /// byte % 32 mapping below is uniform — no modulo bias, no rejection sampling needed.
+ ///
+ public const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
+
+ /// 12 symbols x 5 bits = 60 bits, meeting the brief's "at least 60 bits" for the manual code.
+ public const int CodeLength = 12;
+
+ /// The link token is a separate 128-bit secret, per the brief.
+ public const int LinkTokenBytes = 16;
+
+ public static int CodeEntropyBits => CodeLength * 5;
+
+ /// Generates a fresh manual join code, e.g. K7M2-9QRB-XTFH.
+ public static string NewCode()
+ {
+ Span bytes = stackalloc byte[CodeLength];
+ RandomNumberGenerator.Fill(bytes);
+
+ Span chars = stackalloc char[CodeLength + 2]; // two group separators
+ var c = 0;
+ for (var i = 0; i < CodeLength; i++)
+ {
+ if (i > 0 && i % 4 == 0)
+ chars[c++] = '-';
+ chars[c++] = Alphabet[bytes[i] % Alphabet.Length];
+ }
+
+ return new string(chars);
+ }
+
+ /// Generates a fresh 128-bit share-link token, URL-safe.
+ public static string NewLinkToken()
+ {
+ Span bytes = stackalloc byte[LinkTokenBytes];
+ RandomNumberGenerator.Fill(bytes);
+ return Base64UrlEncode(bytes);
+ }
+
+ ///
+ /// Normalizes user-typed input before hashing: strips separators/whitespace and upper-cases, so
+ /// k7m2-9qrb-xtfh and K7M29QRBXTFH hash to the same digest as the issued value.
+ ///
+ public static string NormalizeCode(string input)
+ {
+ Span buffer = stackalloc char[CodeLength];
+ var written = 0;
+
+ foreach (var ch in input)
+ {
+ if (ch is '-' or ' ' or '\t') continue;
+ if (written == CodeLength) return input.Trim().ToUpperInvariant(); // too long: let the compare fail
+
+ buffer[written++] = char.ToUpperInvariant(ch);
+ }
+
+ return new string(buffer[..written]);
+ }
+
+ private static string Base64UrlEncode(ReadOnlySpan bytes) =>
+ Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+}
diff --git a/src/SimPle.Domain/Lobbies/LobbyMember.cs b/src/SimPle.Domain/Lobbies/LobbyMember.cs
new file mode 100644
index 0000000..ed06830
--- /dev/null
+++ b/src/SimPle.Domain/Lobbies/LobbyMember.cs
@@ -0,0 +1,64 @@
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Lobbies;
+
+///
+/// A seat in a lobby. Child of ; constructible and mutable only through the owning aggregate,
+/// which is what keeps the readiness-reset and host-transfer rules in one place.
+///
+/// is the tenure clock that drives deterministic host transfer. It is supplied by the
+/// caller's injected TimeProvider, never read from — that base-class field
+/// is populated by a raw DateTime.UtcNow the module deliberately does not refactor (R4), so it is not
+/// fake-clock controllable and must never carry a time-sensitive rule.
+///
+public class LobbyMember : Entity
+{
+ public Guid LobbyId { get; private set; }
+ public Guid UserId { get; private set; }
+ public LobbyMemberState State { get; private set; } = LobbyMemberState.Joined;
+
+ /// Readiness is a separate boolean, never a member state. The host is implicitly ready.
+ public bool IsReady { get; private set; }
+
+ public DateTime JoinedAtUtc { get; private set; }
+ public DateTime? LeftAtUtc { get; private set; }
+
+ /// The host who kicked this member. Null for a voluntary leave.
+ public Guid? RemovedByUserId { get; private set; }
+
+ private LobbyMember() { }
+
+ internal static LobbyMember Join(Guid lobbyId, Guid userId, DateTime joinedAtUtc, bool isReady) => new()
+ {
+ LobbyId = lobbyId,
+ UserId = userId,
+ State = LobbyMemberState.Joined,
+ IsReady = isReady,
+ JoinedAtUtc = joinedAtUtc,
+ };
+
+ internal bool IsJoined => State == LobbyMemberState.Joined;
+
+ internal void SetReadiness(bool isReady)
+ {
+ IsReady = isReady;
+ Touch();
+ }
+
+ internal void Leave(DateTime leftAtUtc)
+ {
+ State = LobbyMemberState.Left;
+ IsReady = false;
+ LeftAtUtc = leftAtUtc;
+ Touch();
+ }
+
+ internal void Kick(Guid removedByUserId, DateTime kickedAtUtc)
+ {
+ State = LobbyMemberState.Kicked;
+ IsReady = false;
+ LeftAtUtc = kickedAtUtc;
+ RemovedByUserId = removedByUserId;
+ Touch();
+ }
+}
diff --git a/src/SimPle.Domain/Lobbies/LobbyOutcomes.cs b/src/SimPle.Domain/Lobbies/LobbyOutcomes.cs
new file mode 100644
index 0000000..7064040
--- /dev/null
+++ b/src/SimPle.Domain/Lobbies/LobbyOutcomes.cs
@@ -0,0 +1,53 @@
+namespace SimPle.Domain.Lobbies;
+
+///
+/// Expected domain outcomes of a lobby mutation. These are results, not exceptions: "the lobby is full"
+/// and "you are not the host" are ordinary, testable states that map 1:1 onto the module's error catalogue, so
+/// modelling them as control flow keeps the 6B service layer free of exception-driven branching.
+///
+/// Exceptions remain reserved for programmer error (a malformed setting, an out-of-allow-list value).
+///
+public enum LobbyOutcome
+{
+ /// The mutation was applied and was bumped.
+ Ok,
+
+ /// Lobby is Started/Closed/Expired — terminal states reject every mutation. → Lobbies.Closed
+ Closed,
+
+ /// Past . → Lobbies.Expired
+ Expired,
+
+ /// Capacity reached; the last-seat loser lands here. → Lobbies.Full
+ Full,
+
+ /// Actor is a member but not the host, for a host-only action. → Lobbies.Forbidden
+ Forbidden,
+
+ /// Actor holds no joined membership in this lobby. → privacy-safe Lobbies.NotFound
+ NotMember,
+
+ /// Actor already holds a joined seat here.
+ AlreadyJoined,
+
+ /// Target is not joined, or is the actor where self-targeting is illegal (host cannot kick self).
+ InvalidTarget,
+
+ /// Lobby is not in a state from which a start may begin.
+ NotStartable,
+}
+
+///
+/// A leave is the one mutation with a structural side effect: it may transfer the host or close the lobby.
+/// Callers need all three facts, so they are returned together rather than re-derived by re-reading the aggregate.
+///
+/// Whether the leave applied.
+/// Set when hosting transferred to another member.
+/// Set when the leave closed the lobby (no eligible successor).
+public sealed record LobbyLeaveResult(
+ LobbyOutcome Outcome,
+ Guid? NewHostUserId,
+ LobbyClosedReason? ClosedReason)
+{
+ public static LobbyLeaveResult Failed(LobbyOutcome outcome) => new(outcome, null, null);
+}
diff --git a/src/SimPle.Domain/Lobbies/LobbySettings.cs b/src/SimPle.Domain/Lobbies/LobbySettings.cs
new file mode 100644
index 0000000..83505e7
--- /dev/null
+++ b/src/SimPle.Domain/Lobbies/LobbySettings.cs
@@ -0,0 +1,41 @@
+namespace SimPle.Domain.Lobbies;
+
+///
+/// The full mutable settings tuple of a lobby. Passed whole to so a partial
+/// update can never leave the aggregate half-validated.
+///
+/// is already resolved (see ) — "Auto" never
+/// reaches the domain.
+///
+public sealed record LobbySettings(
+ string GameSlug,
+ int CapabilityVersion,
+ LobbyPrivacy Privacy,
+ int MaxPlayers,
+ string TimeControlId,
+ bool Rated,
+ string ResolvedRegion,
+ SpectatorPolicy SpectatorPolicy,
+ string TieBreakRuleId,
+ bool AiFillRequested)
+{
+ ///
+ /// True when moving to changes something that alters what match gets played,
+ /// and therefore must reset every joined non-host human's readiness (brief: "every match-affecting setting
+ /// change ... resets readiness"; Risk #4).
+ ///
+ /// and are deliberately excluded: they govern who may
+ /// see or reach the lobby, not what is played, so toggling them cannot make a ready roster stale.
+ /// Every other field — game, capability pin, seat count, time control, rated, region, tie-break, and AI fill —
+ /// changes the match itself and does reset readiness.
+ ///
+ public bool IsMatchAffectingChangeTo(LobbySettings next) =>
+ !string.Equals(GameSlug, next.GameSlug, StringComparison.Ordinal)
+ || CapabilityVersion != next.CapabilityVersion
+ || MaxPlayers != next.MaxPlayers
+ || !string.Equals(TimeControlId, next.TimeControlId, StringComparison.Ordinal)
+ || Rated != next.Rated
+ || !string.Equals(ResolvedRegion, next.ResolvedRegion, StringComparison.Ordinal)
+ || !string.Equals(TieBreakRuleId, next.TieBreakRuleId, StringComparison.Ordinal)
+ || AiFillRequested != next.AiFillRequested;
+}
diff --git a/src/SimPle.Domain/Lobbies/LobbyStartRequest.cs b/src/SimPle.Domain/Lobbies/LobbyStartRequest.cs
new file mode 100644
index 0000000..f85b005
--- /dev/null
+++ b/src/SimPle.Domain/Lobbies/LobbyStartRequest.cs
@@ -0,0 +1,84 @@
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Lobbies;
+
+///
+/// The durable record of one attempt to start a lobby. Open -> Succeeded|Failed.
+///
+/// A committed start request means the MatchRequestedV1 outbox row is durable — it does not mean a
+/// match exists (Risk #6). is the correlation M8 echoes back on
+/// MatchCreatedV1/MatchCreationFailedV1.
+///
+/// A partial unique index on (LobbyId, LobbyRevision) WHERE State = 'Open' is what makes a retried start
+/// idempotent: the second attempt at the same revision loses at the index rather than creating a second request.
+/// After a recorded recoverable failure, an explicit retry runs at a new revision and therefore mints a
+/// new .
+///
+public class LobbyStartRequest : Entity
+{
+ public Guid LobbyId { get; private set; }
+
+ /// The lobby revision this request was issued against. Part of the one-open-request-per-revision index.
+ public int LobbyRevision { get; private set; }
+
+ public Guid MatchRequestId { get; private set; }
+ public LobbyStartRequestState State { get; private set; } = LobbyStartRequestState.Open;
+
+ /// Caller-supplied idempotency key, so a client retry of the same command replays rather than re-runs.
+ public string IdempotencyKey { get; private set; } = default!;
+
+ public Guid CorrelationId { get; private set; }
+
+ /// Set when M8 reports a recoverable failure; surfaced to the host verbatim-free (no internals).
+ public string? FailureReason { get; private set; }
+
+ public DateTime? ResolvedAtUtc { get; private set; }
+
+ private LobbyStartRequest() { }
+
+ public static LobbyStartRequest Open(
+ Guid lobbyId,
+ int lobbyRevision,
+ Guid matchRequestId,
+ string idempotencyKey,
+ Guid correlationId)
+ {
+ if (string.IsNullOrWhiteSpace(idempotencyKey))
+ throw new ArgumentException("IdempotencyKey must not be empty.", nameof(idempotencyKey));
+ if (matchRequestId == Guid.Empty)
+ throw new ArgumentException("MatchRequestId must not be empty.", nameof(matchRequestId));
+
+ return new LobbyStartRequest
+ {
+ LobbyId = lobbyId,
+ LobbyRevision = lobbyRevision,
+ MatchRequestId = matchRequestId,
+ IdempotencyKey = idempotencyKey,
+ CorrelationId = correlationId,
+ State = LobbyStartRequestState.Open,
+ };
+ }
+
+ public bool IsOpen => State == LobbyStartRequestState.Open;
+
+ public LobbyOutcome MarkSucceeded(DateTime nowUtc)
+ {
+ if (!IsOpen) return LobbyOutcome.Closed;
+
+ State = LobbyStartRequestState.Succeeded;
+ ResolvedAtUtc = nowUtc;
+ Touch();
+ return LobbyOutcome.Ok;
+ }
+
+ public LobbyOutcome MarkFailed(string failureReason, DateTime nowUtc)
+ {
+ if (!IsOpen) return LobbyOutcome.Closed;
+
+ State = LobbyStartRequestState.Failed;
+ FailureReason = failureReason;
+ ResolvedAtUtc = nowUtc;
+ Touch();
+ return LobbyOutcome.Ok;
+ }
+}
diff --git a/src/SimPle.Domain/Matchmaking/MatchmakingAssignment.cs b/src/SimPle.Domain/Matchmaking/MatchmakingAssignment.cs
new file mode 100644
index 0000000..7ecc2a3
--- /dev/null
+++ b/src/SimPle.Domain/Matchmaking/MatchmakingAssignment.cs
@@ -0,0 +1,82 @@
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Matchmaking;
+
+///
+/// Binds one ticket to one proposed match request. Active -> Superseded|Failed.
+///
+/// This row — specifically the partial unique index UNIQUE (TicketId) WHERE State = 'Active' — is the
+/// correctness boundary that makes double-assignment impossible. FOR UPDATE SKIP LOCKED only stops two
+/// workers from contending on the same row; a requeued ticket or a serialization retry can still attempt a
+/// second assignment, and it is the index, not the row lock, that rejects it (Risk #1). The two-worker real-Postgres
+/// test asserts zero duplicates against exactly this.
+///
+/// ties together the tickets of one proposal, so a group of any supported size shares a
+/// single match request.
+///
+public class MatchmakingAssignment : Entity
+{
+ public Guid TicketId { get; private set; }
+
+ /// The M8 match request this assignment hands off to. Echoed back on MatchCreated/MatchCreationFailed.
+ public Guid MatchRequestId { get; private set; }
+
+ /// Shared by every ticket in the same proposal.
+ public Guid GroupId { get; private set; }
+
+ public MatchmakingAssignmentState State { get; private set; } = MatchmakingAssignmentState.Active;
+
+ public DateTime CreatedAtUtc { get; private set; }
+ public DateTime? ResolvedAtUtc { get; private set; }
+
+ private MatchmakingAssignment() { }
+
+ public static MatchmakingAssignment Create(
+ Guid ticketId,
+ Guid matchRequestId,
+ Guid groupId,
+ DateTime nowUtc)
+ {
+ if (ticketId == Guid.Empty)
+ throw new ArgumentException("TicketId must not be empty.", nameof(ticketId));
+ if (matchRequestId == Guid.Empty)
+ throw new ArgumentException("MatchRequestId must not be empty.", nameof(matchRequestId));
+ if (groupId == Guid.Empty)
+ throw new ArgumentException("GroupId must not be empty.", nameof(groupId));
+
+ return new MatchmakingAssignment
+ {
+ TicketId = ticketId,
+ MatchRequestId = matchRequestId,
+ GroupId = groupId,
+ State = MatchmakingAssignmentState.Active,
+ CreatedAtUtc = nowUtc,
+ };
+ }
+
+ public bool IsActive => State == MatchmakingAssignmentState.Active;
+
+ ///
+ /// Stood down so the ticket may be assigned again (e.g. its group's handoff was requeued). Releasing the active
+ /// slot is what lets the partial unique index accept a fresh assignment for the same ticket.
+ ///
+ public MatchmakingOutcome Supersede(DateTime nowUtc)
+ {
+ if (!IsActive) return MatchmakingOutcome.InvalidTransition;
+
+ State = MatchmakingAssignmentState.Superseded;
+ ResolvedAtUtc = nowUtc;
+ Touch();
+ return MatchmakingOutcome.Ok;
+ }
+
+ public MatchmakingOutcome MarkFailed(DateTime nowUtc)
+ {
+ if (!IsActive) return MatchmakingOutcome.InvalidTransition;
+
+ State = MatchmakingAssignmentState.Failed;
+ ResolvedAtUtc = nowUtc;
+ Touch();
+ return MatchmakingOutcome.Ok;
+ }
+}
diff --git a/src/SimPle.Domain/Matchmaking/MatchmakingBands.cs b/src/SimPle.Domain/Matchmaking/MatchmakingBands.cs
new file mode 100644
index 0000000..f21cea6
--- /dev/null
+++ b/src/SimPle.Domain/Matchmaking/MatchmakingBands.cs
@@ -0,0 +1,40 @@
+namespace SimPle.Domain.Matchmaking;
+
+///
+/// The Phase-1 rating bands, by ticket age. Monotonically widening (±100 → ±200 → ±400) is half the
+/// anti-starvation guarantee; anchoring proposals on the oldest ticket (6C) is the other half. A waiting ticket's
+/// band only ever grows, so it is never indefinitely skipped while the queue serves easier matches — and the
+/// absolute 60-second deadline bounds the worst case, making expiry, not silent starvation, the terminal
+/// outcome.
+///
+/// The boundaries are half-open by construction: [0s,15s) → ±100, [15s,30s) → ±200,
+/// [30s,60s) → ±400, ≥60s → expired. Exactly 15s is already ±200, exactly 60s is already expired.
+/// The brief (Risk #8) makes fake-clock coverage of these three instants mandatory precisely because an
+/// off-by-one here is invisible to a wall-clock test.
+///
+public static class MatchmakingBands
+{
+ public static readonly TimeSpan Deadline = TimeSpan.FromSeconds(60);
+
+ private static readonly TimeSpan SecondBandAt = TimeSpan.FromSeconds(15);
+ private static readonly TimeSpan ThirdBandAt = TimeSpan.FromSeconds(30);
+
+ public const int NarrowBand = 100;
+ public const int MediumBand = 200;
+ public const int WideBand = 400;
+
+ ///
+ /// The half-width of the ticket's rating band at the given age. Returns null once the ticket has reached its
+ /// deadline — an expired ticket has no band, it has a terminal outcome.
+ ///
+ public static int? BandFor(TimeSpan age)
+ {
+ if (age < TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(age), "Ticket age must not be negative.");
+
+ if (age >= Deadline) return null;
+ if (age >= ThirdBandAt) return WideBand;
+ if (age >= SecondBandAt) return MediumBand;
+ return NarrowBand;
+ }
+}
diff --git a/src/SimPle.Domain/Matchmaking/MatchmakingEnums.cs b/src/SimPle.Domain/Matchmaking/MatchmakingEnums.cs
new file mode 100644
index 0000000..505e307
--- /dev/null
+++ b/src/SimPle.Domain/Matchmaking/MatchmakingEnums.cs
@@ -0,0 +1,55 @@
+namespace SimPle.Domain.Matchmaking;
+
+///
+/// Ticket lifecycle: Queued -> Claimed -> Matched|Requeued|Failed, or
+/// Queued -> Cancelled|TimedOut.
+///
+/// Requeued is a transient bookkeeping state that immediately returns to Queued (a failed M8 handoff
+/// retries before the original deadline); Matched, Failed, Cancelled, and TimedOut are
+/// terminal.
+///
+public enum MatchmakingTicketState
+{
+ Queued,
+ Claimed,
+ Matched,
+ Requeued,
+ Failed,
+ Cancelled,
+ TimedOut,
+}
+
+///
+/// Active is the only state the partial unique index counts. A superseded or failed assignment frees the
+/// ticket for a fresh one without ever permitting two live assignments (Risk #1).
+///
+public enum MatchmakingAssignmentState
+{
+ Active,
+ Superseded,
+ Failed,
+}
+
+/// Expected domain outcomes of a ticket mutation. Same rationale as LobbyOutcome.
+public enum MatchmakingOutcome
+{
+ Ok,
+
+ /// Ticket is already terminal. → Matchmaking.TicketExpired / current status.
+ Terminal,
+
+ /// Past the absolute 60-second deadline. → Matchmaking.TicketExpired
+ Expired,
+
+ ///
+ /// A cancel arrived after a worker claim. This is deliberately not an error: the caller returns the
+ /// ticket's current status with HTTP 200 (Matchmaking.CancelTooLate is a 200, per the error catalogue).
+ ///
+ AlreadyClaimed,
+
+ /// The transition is illegal from the ticket's current state.
+ InvalidTransition,
+
+ /// No retry budget remains for another M8 handoff attempt.
+ RetryBudgetExhausted,
+}
diff --git a/src/SimPle.Domain/Matchmaking/MatchmakingTicket.cs b/src/SimPle.Domain/Matchmaking/MatchmakingTicket.cs
new file mode 100644
index 0000000..22cad0d
--- /dev/null
+++ b/src/SimPle.Domain/Matchmaking/MatchmakingTicket.cs
@@ -0,0 +1,242 @@
+using SimPle.Domain.Common;
+
+namespace SimPle.Domain.Matchmaking;
+
+///
+/// One user's place in the Quick Match queue. The ticket snapshots everything the candidate pool keys on,
+/// so a lobby setting or profile change mid-queue can never silently re-pool a waiting ticket.
+///
+/// Rating is a snapshot for the same reason — and, until M10 exists, it is honestly provisional: every user is
+/// 1200 with = provisional-1200-v1. The legacy global User.Elo
+/// column is deliberately not substituted: it is a single cross-game number, so presenting it as a
+/// per-game rating would be a fabricated signal.
+///
+public class MatchmakingTicket : Entity
+{
+ /// The only rating source before M10. Recorded on every ticket so the provenance is auditable.
+ public const string ProvisionalRatingSource = "provisional-1200-v1";
+ public const int ProvisionalRating = 1200;
+
+ /// Attempts to hand a claimed ticket to M8 before it is declared Failed.
+ public const int DefaultRetryBudget = 3;
+
+ public Guid UserId { get; private set; }
+
+ // ── Candidate-pool key: every field below must match exactly for two tickets to be poolable ──
+ public string GameSlug { get; private set; } = default!;
+ public int CapabilityVersion { get; private set; }
+ public string Mode { get; private set; } = default!;
+ public int PlayerCount { get; private set; }
+ public string TimeControlId { get; private set; } = default!;
+ public bool Rated { get; private set; }
+ public string ResolvedRegion { get; private set; } = default!;
+
+ public int Rating { get; private set; }
+ public string RatingSourceVersion { get; private set; } = default!;
+
+ public MatchmakingTicketState State { get; private set; } = MatchmakingTicketState.Queued;
+
+ public DateTime EnqueuedAtUtc { get; private set; }
+
+ /// Absolute, set once at enqueue. A requeue retries before this instant; it never extends it.
+ public DateTime DeadlineAtUtc { get; private set; }
+
+ public int RetryBudget { get; private set; }
+
+ /// Identifies the worker holding the current claim. Cleared on requeue.
+ public string? ClaimedByWorker { get; private set; }
+
+ public DateTime? ClaimedAtUtc { get; private set; }
+ public DateTime? ResolvedAtUtc { get; private set; }
+ public Guid CorrelationId { get; private set; }
+
+ /// Mapped to xmin via IsRowVersion() in EF config.
+ public uint Version { get; private set; }
+
+ private MatchmakingTicket() { }
+
+ public static MatchmakingTicket Enqueue(
+ Guid userId,
+ string gameSlug,
+ int capabilityVersion,
+ string mode,
+ int playerCount,
+ string timeControlId,
+ bool rated,
+ string resolvedRegion,
+ int rating,
+ string ratingSourceVersion,
+ Guid correlationId,
+ DateTime nowUtc)
+ {
+ if (userId == Guid.Empty)
+ throw new ArgumentException("UserId must not be empty.", nameof(userId));
+ if (string.IsNullOrWhiteSpace(gameSlug))
+ throw new ArgumentException("GameSlug must not be empty.", nameof(gameSlug));
+ if (capabilityVersion < 1)
+ throw new ArgumentException("CapabilityVersion must be at least 1.", nameof(capabilityVersion));
+ if (string.IsNullOrWhiteSpace(mode))
+ throw new ArgumentException("Mode must not be empty.", nameof(mode));
+ if (playerCount < 2)
+ throw new ArgumentException("PlayerCount must be at least 2 — Quick Match is a multiplayer queue.", nameof(playerCount));
+ if (string.IsNullOrWhiteSpace(ratingSourceVersion))
+ throw new ArgumentException("RatingSourceVersion must not be empty.", nameof(ratingSourceVersion));
+ if (!SimPle.Domain.Lobbies.LobbyRegion.IsResolved(resolvedRegion))
+ throw new ArgumentException($"ResolvedRegion '{resolvedRegion}' must be an explicit allow-listed region, never 'Auto'.", nameof(resolvedRegion));
+
+ return new MatchmakingTicket
+ {
+ UserId = userId,
+ GameSlug = gameSlug,
+ CapabilityVersion = capabilityVersion,
+ Mode = mode,
+ PlayerCount = playerCount,
+ TimeControlId = timeControlId,
+ Rated = rated,
+ ResolvedRegion = resolvedRegion,
+ Rating = rating,
+ RatingSourceVersion = ratingSourceVersion,
+ State = MatchmakingTicketState.Queued,
+ EnqueuedAtUtc = nowUtc,
+ DeadlineAtUtc = nowUtc + MatchmakingBands.Deadline,
+ RetryBudget = DefaultRetryBudget,
+ CorrelationId = correlationId,
+ };
+ }
+
+ // ── Queries ──────────────────────────────────────────────────────────────
+
+ public bool IsNonTerminal =>
+ State is MatchmakingTicketState.Queued or MatchmakingTicketState.Claimed or MatchmakingTicketState.Requeued;
+
+ public TimeSpan AgeAt(DateTime nowUtc) => nowUtc - EnqueuedAtUtc;
+
+ public bool IsExpired(DateTime nowUtc) => nowUtc >= DeadlineAtUtc;
+
+ /// The ticket's current rating-band half-width, or null once it has reached its deadline.
+ public int? CurrentBand(DateTime nowUtc) => MatchmakingBands.BandFor(AgeAt(nowUtc));
+
+ ///
+ /// The rating window this ticket will accept right now. A group is compatible only when its rating range fits
+ /// every member's window — not just the anchor's (6C enforces that).
+ ///
+ public (int Low, int High)? RatingWindow(DateTime nowUtc)
+ {
+ var band = CurrentBand(nowUtc);
+ return band is null ? null : (Rating - band.Value, Rating + band.Value);
+ }
+
+ // ── Transitions ──────────────────────────────────────────────────────────
+
+ ///
+ /// Queued -> Claimed, by a worker that won the FOR UPDATE SKIP LOCKED row lock. The lock prevents two
+ /// workers contending; it is not exclusivity (Risk #1) — only the partial unique index on active
+ /// assignment makes double-assignment impossible.
+ ///
+ public MatchmakingOutcome Claim(string workerId, DateTime nowUtc)
+ {
+ if (string.IsNullOrWhiteSpace(workerId))
+ throw new ArgumentException("WorkerId must not be empty.", nameof(workerId));
+
+ if (State is not (MatchmakingTicketState.Queued or MatchmakingTicketState.Requeued))
+ return State == MatchmakingTicketState.Claimed
+ ? MatchmakingOutcome.AlreadyClaimed
+ : MatchmakingOutcome.Terminal;
+
+ // A worker must not claim a ticket that has already run out the clock; the expiry sweep owns it now.
+ if (IsExpired(nowUtc)) return MatchmakingOutcome.Expired;
+
+ State = MatchmakingTicketState.Claimed;
+ ClaimedByWorker = workerId;
+ ClaimedAtUtc = nowUtc;
+ Touch();
+ return MatchmakingOutcome.Ok;
+ }
+
+ /// Claimed -> Matched. Terminal: an assignment plus one MatchRequestedV1 committed together.
+ public MatchmakingOutcome MarkMatched(DateTime nowUtc)
+ {
+ if (State != MatchmakingTicketState.Claimed) return MatchmakingOutcome.InvalidTransition;
+
+ State = MatchmakingTicketState.Matched;
+ ResolvedAtUtc = nowUtc;
+ Touch();
+ return MatchmakingOutcome.Ok;
+ }
+
+ ///
+ /// Claimed -> Queued, on a failed M8 handoff. Spends one unit of retry budget and returns the ticket to the
+ /// pool under its original deadline — a requeue never buys more time. With no budget left the ticket
+ /// becomes instead.
+ ///
+ public MatchmakingOutcome Requeue(DateTime nowUtc)
+ {
+ if (State != MatchmakingTicketState.Claimed) return MatchmakingOutcome.InvalidTransition;
+
+ // Terminal states keep ClaimedByWorker: it is the attribution behind the matchmaking-worker-failure signal.
+ // Only a return to Queued clears it, because a queued ticket naming a worker would mean a claim leaked.
+ if (RetryBudget <= 0)
+ {
+ State = MatchmakingTicketState.Failed;
+ ResolvedAtUtc = nowUtc;
+ Touch();
+ return MatchmakingOutcome.RetryBudgetExhausted;
+ }
+
+ // Past its deadline there is nothing to retry into.
+ if (IsExpired(nowUtc))
+ {
+ State = MatchmakingTicketState.TimedOut;
+ ResolvedAtUtc = nowUtc;
+ Touch();
+ return MatchmakingOutcome.Expired;
+ }
+
+ RetryBudget -= 1;
+ State = MatchmakingTicketState.Queued;
+ ClaimedByWorker = null;
+ ClaimedAtUtc = null;
+ Touch();
+ return MatchmakingOutcome.Ok;
+ }
+
+ /// Claimed -> Failed, terminal, when the handoff cannot be retried at all.
+ public MatchmakingOutcome MarkFailed(DateTime nowUtc)
+ {
+ if (State != MatchmakingTicketState.Claimed) return MatchmakingOutcome.InvalidTransition;
+
+ State = MatchmakingTicketState.Failed;
+ ResolvedAtUtc = nowUtc;
+ Touch();
+ return MatchmakingOutcome.Ok;
+ }
+
+ ///
+ /// User-initiated cancel. Commits only while Queued: once a worker has claimed the ticket, the cancel
+ /// is too late and the caller returns the ticket's current status with a 200, not an error.
+ ///
+ public MatchmakingOutcome Cancel(DateTime nowUtc)
+ {
+ if (State == MatchmakingTicketState.Claimed) return MatchmakingOutcome.AlreadyClaimed;
+ if (State != MatchmakingTicketState.Queued) return MatchmakingOutcome.Terminal;
+
+ State = MatchmakingTicketState.Cancelled;
+ ResolvedAtUtc = nowUtc;
+ Touch();
+ return MatchmakingOutcome.Ok;
+ }
+
+ ///
+ /// Expiry sweep entry point (6C's expiry worker). Idempotent: a re-run over an already-terminal or not-yet-due
+ /// ticket is a no-op rather than a second state change.
+ ///
+ public bool TryTimeOut(DateTime nowUtc)
+ {
+ if (!IsNonTerminal || !IsExpired(nowUtc)) return false;
+
+ State = MatchmakingTicketState.TimedOut;
+ ResolvedAtUtc = nowUtc;
+ Touch();
+ return true;
+ }
+}
diff --git a/src/SimPle.Infrastructure/Migrations/20260711195731_AddLobbyMatchmakingAndCapabilities.Designer.cs b/src/SimPle.Infrastructure/Migrations/20260711195731_AddLobbyMatchmakingAndCapabilities.Designer.cs
new file mode 100644
index 0000000..6531937
--- /dev/null
+++ b/src/SimPle.Infrastructure/Migrations/20260711195731_AddLobbyMatchmakingAndCapabilities.Designer.cs
@@ -0,0 +1,1912 @@
+//
+using System;
+using System.Collections.Generic;
+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("20260711195731_AddLobbyMatchmakingAndCapabilities")]
+ partial class AddLobbyMatchmakingAndCapabilities
+ {
+ ///
+ 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.Capabilities.CapabilitySeedHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AppliedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Checksum")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ManifestVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ManifestVersion")
+ .IsUnique();
+
+ b.ToTable("capability_seed_history", (string)null);
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Capabilities.GameCapabilityProfile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AiFillEligible")
+ .HasColumnType("boolean");
+
+ b.Property>("AllowedModes")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("CapabilityVersion")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GameSlug")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("ManifestVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("MaxPlayers")
+ .HasColumnType("integer");
+
+ b.Property("MinPlayers")
+ .HasColumnType("integer");
+
+ b.Property("RatedEligible")
+ .HasColumnType("boolean");
+
+ b.Property>("SpectatorPolicies")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property>("TieBreakRules")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property>("TimeControls")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameSlug")
+ .IsUnique()
+ .HasDatabaseName("ux_game_capability_profiles_one_active_per_game")
+ .HasFilter("\"IsActive\" = true");
+
+ b.HasIndex("GameSlug", "CapabilityVersion")
+ .IsUnique()
+ .HasDatabaseName("ux_game_capability_profiles_pin");
+
+ b.ToTable("game_capability_profiles", null, t =>
+ {
+ t.HasCheckConstraint("ck_game_capability_profiles_modes_nonempty", "cardinality(\"AllowedModes\") > 0");
+
+ t.HasCheckConstraint("ck_game_capability_profiles_players", "\"MinPlayers\" >= 2 AND \"MinPlayers\" <= \"MaxPlayers\" AND \"MaxPlayers\" <= 8");
+
+ t.HasCheckConstraint("ck_game_capability_profiles_spectators_nonempty", "cardinality(\"SpectatorPolicies\") > 0");
+
+ t.HasCheckConstraint("ck_game_capability_profiles_tie_breaks_nonempty", "cardinality(\"TieBreakRules\") > 0");
+
+ t.HasCheckConstraint("ck_game_capability_profiles_time_controls_nonempty", "cardinality(\"TimeControls\") > 0");
+
+ t.HasCheckConstraint("ck_game_capability_profiles_version", "\"CapabilityVersion\" >= 1");
+ });
+ });
+
+ 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("LifecycleVersion")
+ .HasColumnType("integer");
+
+ 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.Lobbies.Lobby", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AiFillRequested")
+ .HasColumnType("boolean");
+
+ b.Property("CapabilityVersion")
+ .HasColumnType("integer");
+
+ b.Property("ClosedReason")
+ .HasMaxLength(24)
+ .HasColumnType("character varying(24)");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GameSlug")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("HostUserId")
+ .HasColumnType("uuid");
+
+ b.Property("MaxPlayers")
+ .HasColumnType("integer");
+
+ b.Property("Privacy")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("Rated")
+ .HasColumnType("boolean");
+
+ b.Property("ResolvedRegion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Revision")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.Property("SpectatorPolicy")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("TieBreakRuleId")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("TimeControlId")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpiresAtUtc")
+ .HasDatabaseName("ix_lobbies_expiry_sweep")
+ .HasFilter("\"State\" IN ('Open', 'Starting')");
+
+ b.HasIndex("HostUserId")
+ .HasDatabaseName("ix_lobbies_host");
+
+ b.HasIndex("CreatedAt", "Id")
+ .HasDatabaseName("ix_lobbies_public_discovery")
+ .HasFilter("\"State\" = 'Open' AND \"Privacy\" = 'Public'");
+
+ b.ToTable("lobbies", null, t =>
+ {
+ t.HasCheckConstraint("ck_lobbies_capability_version", "\"CapabilityVersion\" >= 1");
+
+ t.HasCheckConstraint("ck_lobbies_closed_reason_iff_terminal", "(\"State\" IN ('Closed', 'Expired')) = (\"ClosedReason\" IS NOT NULL)");
+
+ t.HasCheckConstraint("ck_lobbies_max_players", "\"MaxPlayers\" >= 2 AND \"MaxPlayers\" <= 8");
+
+ t.HasCheckConstraint("ck_lobbies_revision", "\"Revision\" >= 1");
+ });
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyInvite", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("InviteeUserId")
+ .HasColumnType("uuid");
+
+ b.Property("InviterUserId")
+ .HasColumnType("uuid");
+
+ b.Property("LobbyId")
+ .HasColumnType("uuid");
+
+ b.Property("RespondedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpiresAtUtc")
+ .HasDatabaseName("ix_lobby_invites_expiry_sweep")
+ .HasFilter("\"State\" = 'Pending'");
+
+ b.HasIndex("InviterUserId");
+
+ b.HasIndex("LobbyId", "InviteeUserId")
+ .IsUnique()
+ .HasDatabaseName("ux_lobby_invites_one_pending_per_invitee")
+ .HasFilter("\"State\" = 'Pending'");
+
+ b.HasIndex("InviteeUserId", "CreatedAt", "Id")
+ .HasDatabaseName("ix_lobby_invites_invitee_pending")
+ .HasFilter("\"State\" = 'Pending'");
+
+ b.ToTable("lobby_invites", null, t =>
+ {
+ t.HasCheckConstraint("ck_lobby_invites_no_self_invite", "\"InviterUserId\" <> \"InviteeUserId\"");
+
+ t.HasCheckConstraint("ck_lobby_invites_responded_iff_terminal", "(\"State\" <> 'Pending') = (\"RespondedAtUtc\" IS NOT NULL)");
+ });
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyJoinCredential", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CodeDigest")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Generation")
+ .HasColumnType("integer");
+
+ b.Property("LinkTokenDigest")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("LobbyId")
+ .HasColumnType("uuid");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("SupersededAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CodeDigest")
+ .IsUnique()
+ .HasDatabaseName("ux_lobby_join_credentials_active_code")
+ .HasFilter("\"State\" = 'Active'");
+
+ b.HasIndex("LinkTokenDigest")
+ .IsUnique()
+ .HasDatabaseName("ux_lobby_join_credentials_active_link_token")
+ .HasFilter("\"State\" = 'Active'");
+
+ b.HasIndex("LobbyId")
+ .IsUnique()
+ .HasDatabaseName("ux_lobby_join_credentials_one_active_per_lobby")
+ .HasFilter("\"State\" = 'Active'");
+
+ b.ToTable("lobby_join_credentials", null, t =>
+ {
+ t.HasCheckConstraint("ck_lobby_join_credentials_generation", "\"Generation\" >= 1");
+
+ t.HasCheckConstraint("ck_lobby_join_credentials_superseded_iff_terminal", "(\"State\" <> 'Active') = (\"SupersededAtUtc\" IS NOT NULL)");
+ });
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyMember", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsReady")
+ .HasColumnType("boolean");
+
+ b.Property("JoinedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LeftAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LobbyId")
+ .HasColumnType("uuid");
+
+ b.Property("RemovedByUserId")
+ .HasColumnType("uuid");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique()
+ .HasDatabaseName("ux_lobby_members_one_joined_per_user")
+ .HasFilter("\"State\" = 'Joined'");
+
+ b.HasIndex("LobbyId", "JoinedAtUtc", "UserId")
+ .HasDatabaseName("ix_lobby_members_lobby_tenure");
+
+ b.ToTable("lobby_members", null, t =>
+ {
+ t.HasCheckConstraint("ck_lobby_members_left_at_iff_terminal", "(\"State\" IN ('Left', 'Kicked')) = (\"LeftAtUtc\" IS NOT NULL)");
+
+ t.HasCheckConstraint("ck_lobby_members_removed_by_only_on_kick", "\"RemovedByUserId\" IS NULL OR \"State\" = 'Kicked'");
+ });
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyStartRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FailureReason")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("IdempotencyKey")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("LobbyId")
+ .HasColumnType("uuid");
+
+ b.Property("LobbyRevision")
+ .HasColumnType("integer");
+
+ b.Property("MatchRequestId")
+ .HasColumnType("uuid");
+
+ b.Property("ResolvedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MatchRequestId")
+ .IsUnique()
+ .HasDatabaseName("ux_lobby_start_requests_match_request");
+
+ b.HasIndex("LobbyId", "IdempotencyKey")
+ .IsUnique()
+ .HasDatabaseName("ux_lobby_start_requests_idempotency");
+
+ b.HasIndex("LobbyId", "LobbyRevision")
+ .IsUnique()
+ .HasDatabaseName("ux_lobby_start_requests_one_open_per_revision")
+ .HasFilter("\"State\" = 'Open'");
+
+ b.ToTable("lobby_start_requests", null, t =>
+ {
+ t.HasCheckConstraint("ck_lobby_start_requests_failure_reason_only_on_failed", "\"FailureReason\" IS NULL OR \"State\" = 'Failed'");
+
+ t.HasCheckConstraint("ck_lobby_start_requests_resolved_iff_terminal", "(\"State\" <> 'Open') = (\"ResolvedAtUtc\" IS NOT NULL)");
+
+ t.HasCheckConstraint("ck_lobby_start_requests_revision", "\"LobbyRevision\" >= 1");
+ });
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingAssignment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GroupId")
+ .HasColumnType("uuid");
+
+ b.Property("MatchRequestId")
+ .HasColumnType("uuid");
+
+ b.Property("ResolvedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("TicketId")
+ .HasColumnType("uuid");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GroupId")
+ .HasDatabaseName("ix_matchmaking_assignments_group");
+
+ b.HasIndex("MatchRequestId")
+ .HasDatabaseName("ix_matchmaking_assignments_match_request");
+
+ b.HasIndex("TicketId")
+ .IsUnique()
+ .HasDatabaseName("ux_matchmaking_assignments_one_active_per_ticket")
+ .HasFilter("\"State\" = 'Active'");
+
+ b.ToTable("matchmaking_assignments", null, t =>
+ {
+ t.HasCheckConstraint("ck_matchmaking_assignments_resolved_iff_terminal", "(\"State\" <> 'Active') = (\"ResolvedAtUtc\" IS NOT NULL)");
+ });
+ });
+
+ modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingTicket", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CapabilityVersion")
+ .HasColumnType("integer");
+
+ b.Property("ClaimedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ClaimedByWorker")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeadlineAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EnqueuedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GameSlug")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Mode")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("PlayerCount")
+ .HasColumnType("integer");
+
+ b.Property("Rated")
+ .HasColumnType("boolean");
+
+ b.Property("Rating")
+ .HasColumnType("integer");
+
+ b.Property("RatingSourceVersion")
+ .IsRequired()
+ .HasMaxLength(48)
+ .HasColumnType("character varying(48)");
+
+ b.Property("ResolvedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ResolvedRegion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("RetryBudget")
+ .HasColumnType("integer");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("TimeControlId")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeadlineAtUtc")
+ .HasDatabaseName("ix_matchmaking_tickets_expiry_sweep")
+ .HasFilter("\"State\" IN ('Queued', 'Claimed', 'Requeued')");
+
+ b.HasIndex("UserId")
+ .IsUnique()
+ .HasDatabaseName("ux_matchmaking_tickets_one_nonterminal_per_user")
+ .HasFilter("\"State\" IN ('Queued', 'Claimed', 'Requeued')");
+
+ b.HasIndex("GameSlug", "CapabilityVersion", "Mode", "PlayerCount", "TimeControlId", "Rated", "ResolvedRegion", "EnqueuedAtUtc", "Id")
+ .HasDatabaseName("ix_matchmaking_tickets_candidate_pool")
+ .HasFilter("\"State\" = 'Queued'");
+
+ b.ToTable("matchmaking_tickets", null, t =>
+ {
+ t.HasCheckConstraint("ck_matchmaking_tickets_capability_version", "\"CapabilityVersion\" >= 1");
+
+ t.HasCheckConstraint("ck_matchmaking_tickets_deadline_after_enqueue", "\"DeadlineAtUtc\" > \"EnqueuedAtUtc\"");
+
+ t.HasCheckConstraint("ck_matchmaking_tickets_no_worker_while_queued", "\"State\" <> 'Queued' OR \"ClaimedByWorker\" IS NULL");
+
+ t.HasCheckConstraint("ck_matchmaking_tickets_player_count", "\"PlayerCount\" >= 2 AND \"PlayerCount\" <= 8");
+
+ t.HasCheckConstraint("ck_matchmaking_tickets_retry_budget", "\"RetryBudget\" >= 0");
+ });
+ });
+
+ 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