diff --git a/coverage.unit.runsettings b/coverage.unit.runsettings index bbc9cff..012f34f 100644 --- a/coverage.unit.runsettings +++ b/coverage.unit.runsettings @@ -14,7 +14,6 @@ [SimPle.Domain]SimPle.Domain.Chat.*, [SimPle.Domain]SimPle.Domain.Games.*, [SimPle.Domain]SimPle.Domain.Hardware.*, - [SimPle.Domain]SimPle.Domain.Lobbies.*, [SimPle.Domain]SimPle.Domain.Notifications.* GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute diff --git a/src/SimPle.Api/Controllers/LobbiesController.cs b/src/SimPle.Api/Controllers/LobbiesController.cs new file mode 100644 index 0000000..5ad4dc2 --- /dev/null +++ b/src/SimPle.Api/Controllers/LobbiesController.cs @@ -0,0 +1,506 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using SimPle.Api.Models; +using SimPle.Application.Lobbies.DTOs; +using SimPle.Application.Lobbies.Services; +using SimPle.Shared.Common; +using Swashbuckle.AspNetCore.Annotations; + +namespace SimPle.Api.Controllers; + +/// +/// The Module 6 lobby command surface. +/// +/// Every route requires authentication, and the actor is always the JWT sub claim — no endpoint accepts a +/// caller-supplied hostId/userId. Every state-changing endpoint additionally requires the +/// X-Requested-With: XMLHttpRequest CSRF header, matching the rest of the API. +/// +/// +/// Privacy-safe not-found (OWASP API1:2023). A lobby, invite, or credential the caller is not +/// entitled to reach returns 404, never 403 — a 403 would confirm the id exists. Missing, expired, +/// private-and-unauthorized, and another user's ids are all deliberately indistinguishable. 403 is reserved +/// for a caller who is already a visible member and merely lacks the host role, where nothing is leaked +/// by saying so. +/// +/// +/// +/// A join credential is never a resource identifier. It travels in a request body on exactly one +/// route (POST /api/lobbies/join) and is returned in exactly two places (create and rotate). No path, query +/// string, log line, or event ever carries it. +/// +/// +[ApiController] +[Route("api/lobbies")] +[Authorize] +[Produces("application/json")] +[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status500InternalServerError)] +public sealed class LobbiesController : ControllerBase +{ + private readonly ILobbiesService _lobbies; + + public LobbiesController(ILobbiesService lobbies) + { + _lobbies = lobbies; + } + + // ── Create & read ──────────────────────────────────────────────────────── + + [HttpPost] + [EnableRateLimiting("lobby-create")] + [SwaggerOperation( + Summary = "Create a lobby and issue its join code + share link", + Description = "The plaintext join code and link token are returned here and at rotation only — they are " + + "stored as keyed digests and appear in no other response, log, or event.", + OperationId = "Lobbies_Create", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(CreateLobbyResultDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task Create([FromBody] CreateLobbyRequestDto request, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _lobbies.CreateAsync(userId, request, ct); + if (!result.IsSuccess) return MapError(result.Error!); + + return StatusCode(StatusCodes.Status201Created, result.Value); + } + + [HttpGet] + [EnableRateLimiting("lobby-read")] + [SwaggerOperation( + Summary = "Browse open public lobbies (keyset cursor paged)", + Description = "Private, expired, full, and blocked lobbies never appear and never affect totals or cursors.", + OperationId = "Lobbies_GetPublic", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(CursorPage), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task GetPublic( + [FromQuery] int limit = 20, + [FromQuery] string? cursor = null, + CancellationToken ct = default) + { + if (!TryGetUserId(out var userId)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _lobbies.GetPublicAsync(userId, limit, cursor, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + [HttpGet("capabilities/{gameSlug}")] + [EnableRateLimiting("lobby-read")] + [SwaggerOperation( + Summary = "Get the active capability profile for a game", + Description = "The pinned-version source (D2): capabilityVersion plus the allowed modes, time controls, " + + "tie-break rules, and spectator policies a client reads before create-lobby or a matchmaking " + + "ticket create, instead of guessing values the server will reject.", + OperationId = "Lobbies_GetCapabilityProfile", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(GameCapabilityProfileDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + public async Task GetCapabilityProfile([FromRoute] string gameSlug, CancellationToken ct) + { + if (!TryGetUserId(out _)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _lobbies.GetCapabilityProfileAsync(gameSlug, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + [HttpGet("{lobbyId:guid}")] + [EnableRateLimiting("lobby-read")] + [SwaggerOperation( + Summary = "Get a lobby the caller may see", + Description = "Members see the full lobby. Non-members see an open public lobby. Everything else is a " + + "privacy-safe 404 — a private or foreign lobby is indistinguishable from one that does not exist.", + OperationId = "Lobbies_Get", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task Get([FromRoute] Guid lobbyId, CancellationToken ct) + { + if (!TryGetUserId(out var userId)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _lobbies.GetAsync(userId, lobbyId, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + [HttpGet("me/invites")] + [EnableRateLimiting("lobby-read")] + [SwaggerOperation(Summary = "The caller's pending, unexpired lobby invites", + OperationId = "Lobbies_GetMyInvites", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task GetMyInvites(CancellationToken ct) + { + if (!TryGetUserId(out var userId)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _lobbies.GetMyInvitesAsync(userId, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + [HttpGet("me/active")] + [EnableRateLimiting("lobby-read")] + [SwaggerOperation( + Summary = "The caller's active lobby or matchmaking ticket", + Description = "At most one of the two is ever set — that is the one-active-lobby-or-ticket invariant.", + OperationId = "Lobbies_GetMyActive", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(ActiveContextDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task GetMyActive(CancellationToken ct) + { + if (!TryGetUserId(out var userId)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _lobbies.GetMyActiveAsync(userId, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + // ── Join ───────────────────────────────────────────────────────────────── + + [HttpPost("join")] + [EnableRateLimiting("lobby-join")] + [SwaggerOperation( + Summary = "Join a lobby by join code, share link token, or (for a Public+Open lobby) its id", + Description = "Exactly one of code, linkToken, or lobbyId. The credential forms are supplied in the body, " + + "never in the path — a credential is a secret, not a resource id. A wrong, expired, rotated, " + + "revoked, or closed-lobby credential all return the identical Lobbies.CredentialInvalid, so " + + "this endpoint is not an oracle; failed credential attempts are throttled specifically. " + + "lobbyId carries no throttle and is only honored when the target is currently Public and " + + "Open — the same visibility rule as the public browse listing — so a private, foreign, or " + + "non-open lobby's id answers with the identical privacy-safe Lobbies.NotFound.", + OperationId = "Lobbies_Join", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task Join([FromBody] JoinLobbyRequestDto request, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _lobbies.JoinByCredentialAsync(userId, request, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + // ── Membership ─────────────────────────────────────────────────────────── + + [HttpPost("{lobbyId:guid}/leave")] + [EnableRateLimiting("lobby-write")] + [SwaggerOperation( + Summary = "Leave a lobby", + Description = "If the host leaves, hosting transfers to the longest-tenured eligible member (tie-broken by " + + "user id); the lobby closes when none exists.", + OperationId = "Lobbies_Leave", Tags = new[] { "Lobbies" })] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + public async Task Leave([FromRoute] Guid lobbyId, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _lobbies.LeaveAsync(userId, lobbyId, ct); + return result.IsSuccess ? NoContent() : MapError(result.Error!); + } + + [HttpPut("{lobbyId:guid}/ready")] + [EnableRateLimiting("lobby-write")] + [SwaggerOperation( + Summary = "Set the caller's own readiness", + Description = "The host is implicitly ready and cannot un-ready.", + OperationId = "Lobbies_SetReadiness", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + public async Task SetReadiness( + [FromRoute] Guid lobbyId, [FromBody] SetReadinessRequestDto request, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _lobbies.SetReadinessAsync(userId, lobbyId, request, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + [HttpPatch("{lobbyId:guid}/settings")] + [EnableRateLimiting("lobby-write")] + [SwaggerOperation( + Summary = "Host: change match settings", + Description = "Validated against the pinned capability profile before persistence. Every match-affecting " + + "change resets readiness for all joined non-host members; privacy and spectator policy do not.", + OperationId = "Lobbies_UpdateSettings", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + public async Task UpdateSettings( + [FromRoute] Guid lobbyId, [FromBody] UpdateLobbySettingsRequestDto request, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _lobbies.UpdateSettingsAsync(userId, lobbyId, request, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + [HttpPost("{lobbyId:guid}/kick")] + [EnableRateLimiting("lobby-write")] + [SwaggerOperation(Summary = "Host: remove a member from the lobby", + OperationId = "Lobbies_Kick", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + public async Task Kick( + [FromRoute] Guid lobbyId, [FromBody] KickMemberRequestDto request, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _lobbies.KickAsync(userId, lobbyId, request, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + // ── Credential rotation ────────────────────────────────────────────────── + + [HttpPost("{lobbyId:guid}/credential/rotate")] + [EnableRateLimiting("lobby-write")] + [SwaggerOperation( + Summary = "Host: rotate the join code and share link", + Description = "The previous code and link are invalidated immediately — there is no window in which both work.", + OperationId = "Lobbies_RotateCredential", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(LobbyCredentialDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + public async Task RotateCredential([FromRoute] Guid lobbyId, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _lobbies.RotateCredentialAsync(userId, lobbyId, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + // ── Invites ────────────────────────────────────────────────────────────── + + [HttpPost("{lobbyId:guid}/invites")] + [EnableRateLimiting("lobby-invite")] + [SwaggerOperation( + Summary = "Host: invite a friend to the lobby", + Description = "Accepted friends only. This is what stops the invite endpoint being a way to reveal a " + + "private lobby's existence to an arbitrary user id.", + OperationId = "Lobbies_CreateInvite", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(LobbyInviteDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task CreateInvite( + [FromRoute] Guid lobbyId, [FromBody] CreateInviteRequestDto request, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _lobbies.CreateInviteAsync(userId, lobbyId, request, ct); + if (!result.IsSuccess) return MapError(result.Error!); + + return StatusCode(StatusCodes.Status201Created, result.Value); + } + + [HttpDelete("{lobbyId:guid}/invites/{inviteId:guid}")] + [EnableRateLimiting("lobby-write")] + [SwaggerOperation(Summary = "Host: revoke a pending invite", + OperationId = "Lobbies_RevokeInvite", Tags = new[] { "Lobbies" })] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + public async Task RevokeInvite( + [FromRoute] Guid lobbyId, [FromRoute] Guid inviteId, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _lobbies.RevokeInviteAsync(userId, lobbyId, inviteId, ct); + return result.IsSuccess ? NoContent() : MapError(result.Error!); + } + + /// + /// Reconciliation R6 — a route the approved spec's table omits. + /// + /// Without it an invitee cannot reach a seat at all: join takes a credential, and handing every invitee the + /// lobby's private code would both leak it and leave a revoked invite still redeemable. Accept runs the same + /// bounded transaction as a credential join, so an invited member is admitted under identical block, capacity, + /// and one-active-lobby-or-ticket rules. + /// + [HttpPost("invites/{inviteId:guid}/accept")] + [EnableRateLimiting("lobby-join")] + [SwaggerOperation( + Summary = "Accept a lobby invite and take a seat", + Description = "Enforces the same blocks, capacity, and one-active-lobby-or-ticket rules as a credential " + + "join — an invite is permission to try, never a bypass. Another user's invite id is a " + + "privacy-safe 404.", + OperationId = "Lobbies_AcceptInvite", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(LobbyDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + public async Task AcceptInvite([FromRoute] Guid inviteId, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _lobbies.AcceptInviteAsync(userId, inviteId, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + /// Reconciliation R6, with accept above. + [HttpPost("invites/{inviteId:guid}/decline")] + [EnableRateLimiting("lobby-write")] + [SwaggerOperation(Summary = "Decline a lobby invite", + OperationId = "Lobbies_DeclineInvite", Tags = new[] { "Lobbies" })] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + public async Task DeclineInvite([FromRoute] Guid inviteId, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _lobbies.DeclineInviteAsync(userId, inviteId, ct); + return result.IsSuccess ? NoContent() : MapError(result.Error!); + } + + // ── Start ──────────────────────────────────────────────────────────────── + + [HttpPost("{lobbyId:guid}/start")] + [EnableRateLimiting("lobby-write")] + [SwaggerOperation( + Summary = "Host: start the match", + Description = "Returns 503 Lobbies.MatchRuntimeUnavailable while Module 8 is not registered — the lobby " + + "stays Open and no room is created. A committed match request is a durable request, never a " + + "created match.", + OperationId = "Lobbies_Start", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(StartLobbyResultDto), StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status503ServiceUnavailable)] + public async Task Start( + [FromRoute] Guid lobbyId, [FromBody] StartLobbyRequestDto request, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _lobbies.StartAsync(userId, lobbyId, request, ct); + if (!result.IsSuccess) return MapError(result.Error!); + + // 202, not 200: the lobby has entered Starting and a request is durable, but the match does not exist yet + // and will not until M8 answers. A 200 would imply the work is done. + return Accepted(result.Value); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private const string CsrfHeader = "X-Requested-With"; + private const string CsrfHeaderValue = "XMLHttpRequest"; + + private bool HasCsrfHeader() => + string.Equals(Request.Headers[CsrfHeader], CsrfHeaderValue, StringComparison.Ordinal); + + private IActionResult MissingCsrfHeader() => BadRequest(Error( + "Auth.CsrfHeaderRequired", + $"The {CsrfHeader} header is required for this request.")); + + private bool TryGetUserId(out Guid userId) => + Guid.TryParse(User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value, out userId); + + /// + /// Maps the Module 6 error catalogue to HTTP. + /// + /// Lobbies.NotFound and Lobbies.CredentialInvalid both become 404 — the credential case + /// deliberately included, so a wrong code is indistinguishable from a lobby that does not exist. Only + /// Lobbies.Forbidden (a visible member lacking the host role) is a 403; nothing is leaked by it. + /// + private IActionResult MapError(Error error) + { + var body = new ApiErrorResponse(new ApiErrorDetail(error.Code, error.Message, error.RetryAfterUtc)); + + switch (error.Code) + { + case LobbyErrors.NotFound: + case LobbyErrors.CredentialInvalid: + case LobbyErrors.CapabilityNotFound: + return NotFound(body); + + case LobbyErrors.Forbidden: + case LobbyErrors.Blocked: + return StatusCode(StatusCodes.Status403Forbidden, body); + + case LobbyErrors.Full: + case LobbyErrors.Closed: + case LobbyErrors.Expired: + case LobbyErrors.StaleRevision: + case LobbyErrors.AlreadyActive: + case LobbyErrors.CapabilityDisabled: + case LobbyErrors.ConcurrencyConflict: + case LobbyErrors.NotStartable: + return Conflict(body); + + case LobbyErrors.MatchRuntimeUnavailable: + return StatusCode(StatusCodes.Status503ServiceUnavailable, body); + + case LobbyErrors.RateLimitExceeded: + if (error.RetryAfterUtc is DateTime until) + { + var seconds = Math.Max(0, (int)Math.Ceiling((until - DateTime.UtcNow).TotalSeconds)); + Response.Headers.RetryAfter = seconds.ToString(); + } + return StatusCode(StatusCodes.Status429TooManyRequests, body); + + default: + // Validation.Failed, Pagination.InvalidCursor, Lobbies.InvalidTarget, Auth.CsrfHeaderRequired. + return BadRequest(body); + } + } + + private static ApiErrorResponse Error(string code, string message) => + new(new ApiErrorDetail(code, message)); +} diff --git a/src/SimPle.Api/Controllers/MatchRematchController.cs b/src/SimPle.Api/Controllers/MatchRematchController.cs new file mode 100644 index 0000000..778a160 --- /dev/null +++ b/src/SimPle.Api/Controllers/MatchRematchController.cs @@ -0,0 +1,84 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using SimPle.Api.Models; +using SimPle.Application.Lobbies.DTOs; +using SimPle.Application.Lobbies.Services; +using Swashbuckle.AspNetCore.Annotations; + +namespace SimPle.Api.Controllers; + +/// +/// Rematch: creates a new lobby (which Module 6 owns) addressed off the terminal match it replays (which +/// Module 8 will own). +/// +/// +/// This lives in its own controller with a real api/matches prefix rather than as an absolute-route action +/// hanging off . The absolute form works at runtime but +/// scripts/check-contract-drift.mjs statically concatenates the controller prefix with the action template, +/// and recorded the route as POST /api/lobbies//api/matches/*/rematch-lobbies. The frontend slice would then +/// call the real path, find no matching route in the inventory, and trip a false drift failure on a gate that is +/// supposed to catch exactly this class of mistake. A correctly-prefixed controller keeps the tooling honest. +/// +/// +/// +/// Module 8 is free to add its own MatchesController for match resources; ASP.NET routes by template, not +/// by class, so two controllers may share the prefix as long as their action templates do not collide. +/// +/// +[ApiController] +[Route("api/matches")] +[Authorize] +[Produces("application/json")] +[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status500InternalServerError)] +public sealed class MatchRematchController : ControllerBase +{ + private readonly ILobbiesService _lobbies; + + public MatchRematchController(ILobbiesService lobbies) + { + _lobbies = lobbies; + } + + [HttpPost("{terminalMatchId:guid}/rematch-lobbies")] + [EnableRateLimiting("lobby-create")] + [SwaggerOperation( + Summary = "Create a rematch lobby from a finished match", + Description = "Returns 503 Lobbies.MatchRuntimeUnavailable — Module 8 owns match records, so there is no " + + "terminal match to read the prior settings and participants from, and inventing a lobby from " + + "defaults would produce one that silently is not the rematch it claims to be. When M8 lands, " + + "nobody is auto-joined or auto-readied by a rematch and blocks are re-checked.", + OperationId = "Lobbies_CreateRematch", Tags = new[] { "Lobbies" })] + [ProducesResponseType(typeof(CreateLobbyResultDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status503ServiceUnavailable)] + public async Task CreateRematch([FromRoute] Guid terminalMatchId, CancellationToken ct) + { + if (!HasCsrfHeader()) + { + return BadRequest(new ApiErrorResponse(new ApiErrorDetail( + "Auth.CsrfHeaderRequired", "The X-Requested-With header is required for this request."))); + } + + if (!Guid.TryParse(User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value, out var userId)) + return Unauthorized(); + + var result = await _lobbies.CreateRematchLobbyAsync(userId, terminalMatchId, ct); + if (!result.IsSuccess) + { + var body = new ApiErrorResponse(new ApiErrorDetail(result.Error!.Code, result.Error.Message)); + return result.Error.Code == LobbyErrors.MatchRuntimeUnavailable + ? StatusCode(StatusCodes.Status503ServiceUnavailable, body) + : BadRequest(body); + } + + return StatusCode(StatusCodes.Status201Created, result.Value); + } + + private bool HasCsrfHeader() => + string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.Ordinal); +} diff --git a/src/SimPle.Api/Controllers/MatchmakingController.cs b/src/SimPle.Api/Controllers/MatchmakingController.cs new file mode 100644 index 0000000..3751f0f --- /dev/null +++ b/src/SimPle.Api/Controllers/MatchmakingController.cs @@ -0,0 +1,177 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using SimPle.Api.Models; +using SimPle.Application.Lobbies.Services; +using SimPle.Application.Matchmaking.DTOs; +using SimPle.Application.Matchmaking.Services; +using SimPle.Shared.Common; +using Swashbuckle.AspNetCore.Annotations; + +namespace SimPle.Api.Controllers; + +/// +/// The Module 6 Quick Match ticket surface (slice 6C). +/// +/// +/// Every route requires authentication, and the actor is always the JWT sub claim — no endpoint accepts a +/// caller-supplied userId or rating. Every state-changing endpoint additionally requires the +/// X-Requested-With: XMLHttpRequest CSRF header, matching the rest of the API. +/// +/// +/// +/// Privacy-safe not-found (OWASP API1:2023). Another user's ticket id returns 404, never +/// 403 — a 403 would confirm the id exists. A missing ticket and a foreign one are indistinguishable. +/// +/// +/// +/// These endpoints work before Module 8. Enqueue, poll, and cancel are fully functional today; the +/// matching worker is what waits for a match runtime. A ticket therefore queues, widens through its rating bands, +/// and honestly times out — and dependencyReadiness on every response is how the client knows that without +/// hardcoding it. +/// +/// +[ApiController] +[Route("api/matchmaking")] +[Authorize] +[Produces("application/json")] +[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status500InternalServerError)] +public sealed class MatchmakingController : ControllerBase +{ + private readonly IMatchmakingService _matchmaking; + + public MatchmakingController(IMatchmakingService matchmaking) + { + _matchmaking = matchmaking; + } + + [HttpPost("tickets")] + [EnableRateLimiting("matchmaking-enqueue")] + [SwaggerOperation( + Summary = "Enqueue a Quick Match ticket", + Description = "Idempotent by pool key: re-sending the identical ticket returns the live one rather than " + + "minting a second. A *different* ticket while one is live is 409 Matchmaking.AlreadyQueued, " + + "as is enqueueing while already in a lobby — a user holds one active lobby OR one active " + + "ticket, never both. The rating is a server-side snapshot (provisional 1200 until Module 10); " + + "a client cannot supply one and so cannot choose its own opponents.", + OperationId = "Matchmaking_CreateTicket", Tags = new[] { "Matchmaking" })] + [ProducesResponseType(typeof(TicketDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task CreateTicket([FromBody] CreateTicketRequestDto request, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _matchmaking.EnqueueAsync(userId, request, ct); + if (!result.IsSuccess) return MapError(result.Error!); + + return StatusCode(StatusCodes.Status201Created, result.Value); + } + + [HttpGet("tickets/{ticketId:guid}")] + [EnableRateLimiting("matchmaking-status")] + [SwaggerOperation( + Summary = "Poll a Quick Match ticket's status", + Description = "Polled every 2 seconds until Module 7 supplies live delivery. `currentBand` is the rating " + + "half-width the ticket accepts right now (100 → 200 → 400 as it ages, null once it has " + + "reached its deadline), so the UI can show the search visibly widening. Another user's " + + "ticket id is a privacy-safe 404.", + OperationId = "Matchmaking_GetTicket", Tags = new[] { "Matchmaking" })] + [ProducesResponseType(typeof(TicketDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task GetTicket([FromRoute] Guid ticketId, CancellationToken ct) + { + if (!TryGetUserId(out var userId)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _matchmaking.GetTicketAsync(userId, ticketId, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + [HttpDelete("tickets/{ticketId:guid}")] + [EnableRateLimiting("matchmaking-write")] + [SwaggerOperation( + Summary = "Cancel a Quick Match ticket", + Description = "Returns 200 with the ticket's current status. A cancel that arrives after a worker has " + + "claimed the ticket is deliberately NOT an error — the user did nothing wrong and the queue " + + "simply got there first, so they receive the ticket's real state rather than a failure.", + OperationId = "Matchmaking_CancelTicket", Tags = new[] { "Matchmaking" })] + [ProducesResponseType(typeof(TicketDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)] + public async Task CancelTicket([FromRoute] Guid ticketId, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _matchmaking.CancelAsync(userId, ticketId, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private const string CsrfHeader = "X-Requested-With"; + private const string CsrfHeaderValue = "XMLHttpRequest"; + + private bool HasCsrfHeader() => + string.Equals(Request.Headers[CsrfHeader], CsrfHeaderValue, StringComparison.Ordinal); + + private IActionResult MissingCsrfHeader() => BadRequest(new ApiErrorResponse(new ApiErrorDetail( + "Auth.CsrfHeaderRequired", $"The {CsrfHeader} header is required for this request."))); + + private bool TryGetUserId(out Guid userId) => + Guid.TryParse(User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value, out userId); + + /// + /// Maps the matchmaking error catalogue to HTTP. Enqueue shares several codes with the lobby surface — a user + /// who is already in a lobby, a disabled capability, a spent retry budget — and they are mapped identically + /// here, because they mean the same thing and a client should not have to learn two vocabularies for one module. + /// + private IActionResult MapError(Error error) + { + var body = new ApiErrorResponse(new ApiErrorDetail(error.Code, error.Message, error.RetryAfterUtc)); + + switch (error.Code) + { + case MatchmakingErrors.TicketNotFound: + return NotFound(body); + + case LobbyErrors.Forbidden: + case LobbyErrors.Blocked: + return StatusCode(StatusCodes.Status403Forbidden, body); + + case MatchmakingErrors.AlreadyQueued: + case MatchmakingErrors.TicketExpired: + case LobbyErrors.AlreadyActive: + case LobbyErrors.CapabilityDisabled: + case LobbyErrors.ConcurrencyConflict: + return Conflict(body); + + case MatchmakingErrors.RuntimeUnavailable: + return StatusCode(StatusCodes.Status503ServiceUnavailable, body); + + case LobbyErrors.RateLimitExceeded: + if (error.RetryAfterUtc is DateTime until) + { + var seconds = Math.Max(0, (int)Math.Ceiling((until - DateTime.UtcNow).TotalSeconds)); + Response.Headers.RetryAfter = seconds.ToString(); + } + return StatusCode(StatusCodes.Status429TooManyRequests, body); + + default: + // Validation.Failed, Auth.CsrfHeaderRequired. + return BadRequest(body); + } + } +} diff --git a/src/SimPle.Api/Program.cs b/src/SimPle.Api/Program.cs index 08e402d..345c7ac 100644 --- a/src/SimPle.Api/Program.cs +++ b/src/SimPle.Api/Program.cs @@ -21,8 +21,11 @@ using SimPle.Application.Common.Options; using SimPle.Application.GameHost.Services; using SimPle.Domain.GameHost; +using SimPle.Domain.Games; +using SimPle.Domain.Lobbies; using SimPle.Infrastructure; using SimPle.Infrastructure.Auth; +using SimPle.Infrastructure.Capabilities; using SimPle.Infrastructure.Games; using SimPle.Infrastructure.Persistence; @@ -90,6 +93,22 @@ "Jwt:SecretKey must be configured outside committed appsettings with at least 32 characters.") .ValidateOnStart(); +// Module 6 — the server key that keys every lobby join-code / link-token digest. Validated on start for the same +// reason as Jwt:SecretKey: a join code carries only ~60 bits, so an unkeyed or well-known digest would make every +// code in the database offline-guessable. There is deliberately no dev fallback — the module fails closed rather +// than silently degrading to a weak key. +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(LobbyCredentialOptions.SectionName)) + .Validate(options => + !string.IsNullOrWhiteSpace(options.Key) && + options.Key.Length >= 32 && + !options.Key.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase) && + !options.Key.StartsWith("CONFIGURE", StringComparison.OrdinalIgnoreCase), + "LobbyCredential:Key must be configured outside committed appsettings with at least 32 characters.") + .Validate(options => LobbyAllowLists.Regions.Contains(options.DefaultRegion), + "LobbyCredential:DefaultRegion must be an allow-listed region (see LobbyAllowLists.Regions).") + .ValidateOnStart(); + builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(RecaptchaOptions.SectionName)) .Validate(options => @@ -286,6 +305,31 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse( options.AddPolicy("catalog-read", context => AuthWindow(context, 120, TimeSpan.FromMinutes(1))); options.AddPolicy("game-favorites", context => FriendWindow(context, "gfav", 60, TimeSpan.FromMinutes(1))); + // Module 6 lobby policies. All per-account (every lobby route is [Authorize]) with the usual coarse per-IP + // fallback. Creating a lobby is the expensive one — it mints a credential and takes a seat — so it is the + // tightest; reads are generous because the lobby page polls. + // + // Note that lobby-join throttles *all* join attempts, successful or not. It is NOT the defense against + // join-code guessing: a window loose enough to let invited members join freely is far too loose to protect a + // ~60-bit code. That job belongs to ILobbyJoinThrottle, which counts failures only (see + // MemoryCacheLobbyJoinThrottle) — the two are chained, and both must pass. + options.AddPolicy("lobby-create", context => FriendWindow(context, "lbcr", 10, TimeSpan.FromMinutes(1))); + options.AddPolicy("lobby-read", context => FriendWindow(context, "lbrd", 120, TimeSpan.FromMinutes(1))); + options.AddPolicy("lobby-join", context => FriendWindow(context, "lbjn", 20, TimeSpan.FromMinutes(1))); + options.AddPolicy("lobby-write", context => FriendWindow(context, "lbwr", 60, TimeSpan.FromMinutes(1))); + options.AddPolicy("lobby-invite", context => FriendWindow(context, "lbiv", 20, TimeSpan.FromMinutes(1))); + + // Module 6 matchmaking policies (slice 6C). Per-account, with the usual coarse per-IP fallback. + // + // matchmaking-status is by far the loosest of the three, and deliberately so: the queue modal polls a ticket + // every 2 seconds for up to its 60-second deadline, which is ~30 requests per ticket before the user has done + // anything at all. A limit tight enough to look prudent here would throttle the module's own normal operation. + // Enqueue is the tight one — it takes the user's single active slot, so repeating it fast is either a bug or an + // attempt to churn the queue (OWASP API4:2023). + options.AddPolicy("matchmaking-enqueue", context => FriendWindow(context, "mmen", 10, TimeSpan.FromMinutes(1))); + options.AddPolicy("matchmaking-status", context => FriendWindow(context, "mmst", 120, TimeSpan.FromMinutes(1))); + options.AddPolicy("matchmaking-write", context => FriendWindow(context, "mmwr", 30, TimeSpan.FromMinutes(1))); + // Chained per-IP ceiling for discovery/people-search (spec: 30/min/account + 120/hour/IP), and for // catalog search (spec: 120/min/IP catalog-read + 30/min/IP catalog-search when `query` is present). // Scoped by request path/query so it only "bites" on these routes; every other route gets a permanent @@ -426,6 +470,56 @@ static RateLimitPartition FriendWindow(HttpContext context, string prefi Environment.Exit(seedResult.Success ? 0 : 1); } +// Module 6 — capability profiles (D2). Runs after --seed-game-catalog: every profile is FK'd to games.slug and is +// validated as a subset of its catalog row, so seeding capabilities into an unseeded catalog fails closed with a +// message naming the missing game rather than writing a profile nothing can resolve. +if (args.Contains("--seed-game-capabilities")) +{ + using var scope = app.Services.CreateScope(); + var seedDb = scope.ServiceProvider.GetRequiredService(); + var seedClock = scope.ServiceProvider.GetRequiredService(); + var seedLogger = scope.ServiceProvider.GetRequiredService().CreateLogger(); + var seeder = new GameCapabilitySeeder(seedDb, seedClock, seedLogger); + var seedResult = seeder.SeedAsync().GetAwaiter().GetResult(); + Console.WriteLine(seedResult.Message); + Environment.Exit(seedResult.Success ? 0 : 1); +} + +// Local/CI-only lifecycle promotion. GameCatalogSeeder always seeds new games as ComingSoon (Module 4); +// no controller or seeder ever called Game.MakeAvailable(), so no game could reach Available anywhere. +// Mirrors the --seed-game-* flags above: never exposed as an HTTP endpoint, CLI-only. +if (args.Contains("--publish-game")) +{ + var slugIndex = Array.IndexOf(args, "--publish-game") + 1; + var slug = slugIndex > 0 && slugIndex < args.Length ? args[slugIndex] : null; + if (string.IsNullOrWhiteSpace(slug)) + { + Console.WriteLine("--publish-game requires a slug argument."); + Environment.Exit(1); + } + + using var scope = app.Services.CreateScope(); + var seedDb = scope.ServiceProvider.GetRequiredService(); + var game = seedDb.Games.FirstOrDefault(g => g.Slug == slug); + if (game is null) + { + Console.WriteLine($"No game with slug '{slug}' found."); + Environment.Exit(1); + } + + if (game!.Lifecycle != GameLifecycle.Available) + { + game.MakeAvailable(); + seedDb.SaveChanges(); + Console.WriteLine($"Promoted '{slug}' to Available."); + } + else + { + Console.WriteLine($"'{slug}' is already Available; no-op."); + } + Environment.Exit(0); +} + app.Run(); public partial class Program { } diff --git a/src/SimPle.Api/appsettings.Development.example.json b/src/SimPle.Api/appsettings.Development.example.json index 7f642bf..2033f54 100644 --- a/src/SimPle.Api/appsettings.Development.example.json +++ b/src/SimPle.Api/appsettings.Development.example.json @@ -24,6 +24,10 @@ "VerificationUrl": "https://www.google.com/recaptcha/api/siteverify", "DevBypassToken": "dev-captcha-bypass-token" }, + "LobbyCredential": { + "Key": "REPLACE_WITH_AT_LEAST_32_CHARACTER_SECRET_KEY", + "DefaultRegion": "eu-west" + }, "Google": { "ClientId": "REPLACE_WITH_GOOGLE_OAUTH_CLIENT_ID" }, diff --git a/src/SimPle.Application/Common/Interfaces/ILobbyCommandRunner.cs b/src/SimPle.Application/Common/Interfaces/ILobbyCommandRunner.cs new file mode 100644 index 0000000..71f84c7 --- /dev/null +++ b/src/SimPle.Application/Common/Interfaces/ILobbyCommandRunner.cs @@ -0,0 +1,54 @@ +using SimPle.Shared.Common; + +namespace SimPle.Application.Common.Interfaces; + +/// +/// Runs one lobby command as a bounded, retried, whole-transaction unit of work (reconciliation R3). +/// +/// +/// The existing PostgresRetry.SaveChangesAsync is not sufficient here and is deliberately left untouched. +/// It re-issues only the save, on 40001/40P01 only. A last-seat join that loses the capacity +/// race must instead re-read the lobby to discover it is now full and answer a typed Lobbies.Full; +/// replaying just the save would re-commit a decision made against state that is now stale. The brief (Risk #5) is +/// explicit: the application reruns the entire bounded transaction including the read and decision logic, +/// then surfaces a typed conflict — never a 500. +/// +/// +/// +/// Three distinct failures mean "someone beat you; look again", and all three are retried: +/// +/// 23505 unique violation — e.g. ux_lobby_members_one_joined_per_user rejecting a second seat. +/// 40001/40P01 serialization failure / deadlock. +/// A row-version (xmin) mismatch on the lobby, surfaced by EF as +/// DbUpdateConcurrencyException. This is the one that actually catches concurrent last-seat joins: +/// every join bumps Revision, so two racers both UPDATE … WHERE xmin = @loaded and the loser +/// affects zero rows. No index does this work — the member index is keyed on +/// UserId, so it happily admits two different users into the same last seat. +/// +/// +/// +/// +/// Between attempts the change tracker is cleared, so the rerun genuinely re-reads rather than re-deciding against +/// the losing attempt's stale entities. Once the budget is spent the runner returns a typed +/// Lobbies.ConcurrencyConflict — the exception never escapes as a 500. +/// +/// +public interface ILobbyCommandRunner +{ + /// + /// Runs inside one transaction, retrying the whole delegate on contention. + /// + /// takes a transaction-scoped advisory lock keyed on the actor. That is what + /// makes the cross-table "one active lobby or one active ticket" check real: two filtered + /// unique indexes on different tables cannot see each other, so without serializing an actor's seat-acquiring + /// commands against each other, a concurrent join and enqueue would both read "nothing active" and both commit + /// (brief Risk #2). The lock is released by the transaction, so nothing can leak it. + /// + /// A Result.Fail returned by the command is a decision, not a fault: it commits (nothing was written) + /// and is not retried. Only a contention exception triggers a rerun. + /// + Task> RunAsync( + Guid actorUserId, + Func>> command, + CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Common/Interfaces/ILobbyRepository.cs b/src/SimPle.Application/Common/Interfaces/ILobbyRepository.cs new file mode 100644 index 0000000..4617bf3 --- /dev/null +++ b/src/SimPle.Application/Common/Interfaces/ILobbyRepository.cs @@ -0,0 +1,155 @@ +using SimPle.Domain.Capabilities; +using SimPle.Domain.Games; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Outbox; +using SimPle.Domain.Users; + +namespace SimPle.Application.Common.Interfaces; + +/// +/// Data access for lobbies, invites, join credentials, and start requests. +/// +/// Reads used inside a command are tracked — the command layer mutates the aggregate it read and saves it +/// in the same unit of work. Reads used to render a response are AsNoTracking. +/// +/// Every write stages its caller-built rows in the same SaveChanges as the +/// aggregate mutation, so a state change and its integration event commit or roll back together. There is no +/// method here that saves an event without its cause, or a cause without its event. +/// +public interface ILobbyRepository +{ + // ── Lobby reads ────────────────────────────────────────────────────────── + + /// Tracked, with the member collection loaded. The read half of a read-decide-write command. + Task GetForUpdateAsync(Guid lobbyId, CancellationToken ct = default); + + /// Untracked, with members. For rendering a response. + Task GetByIdAsync(Guid lobbyId, CancellationToken ct = default); + + /// + /// Public discovery: Open + Public only, keyset-ordered on (CreatedAt, Id). A private, + /// expired, closed, or started lobby is not merely filtered out of the projection — it never enters the query, + /// so it cannot affect page length or the cursor. + /// + Task> GetPublicPageAsync( + int limit, DateTime? afterCreatedAt, Guid? afterId, CancellationToken ct = default); + + /// The user's single joined nonterminal lobby, or null. + Task GetActiveLobbyForUserAsync(Guid userId, CancellationToken ct = default); + + /// + /// The user's single nonterminal matchmaking ticket id, or null. + /// + /// Module 6B owns no ticket commands (those are 6C), but it must read this table: "one active lobby + /// or one active ticket" is a cross-table invariant, and the two filtered unique indexes + /// cannot see each other (brief Risk #2). The join/accept commands therefore check it inside the transaction + /// that seats the member. + /// + Task GetActiveTicketIdForUserAsync(Guid userId, CancellationToken ct = default); + + // ── Credentials ────────────────────────────────────────────────────────── + + Task GetActiveCredentialAsync(Guid lobbyId, CancellationToken ct = default); + + /// + /// Resolves an active credential by its keyed digest. Returns null for a wrong, expired, rotated, revoked, or + /// unknown value alike — the caller maps every one of them to the same Lobbies.CredentialInvalid, so + /// the endpoint is not an oracle for which lobbies exist. + /// + Task FindActiveByCodeDigestAsync(string codeDigest, CancellationToken ct = default); + + Task FindActiveByLinkTokenDigestAsync(string linkTokenDigest, CancellationToken ct = default); + + // ── Invites ────────────────────────────────────────────────────────────── + + Task GetInviteForUpdateAsync(Guid inviteId, CancellationToken ct = default); + + Task GetPendingInviteAsync(Guid lobbyId, Guid inviteeUserId, CancellationToken ct = default); + + /// + /// The invitee's pending, unexpired invites with the lobby and inviter needed to render them. The dashboard's + /// "N active" badge is this same bounded query — a count is never shown without the list it summarizes. + /// + Task> GetPendingInvitesForUserAsync( + Guid inviteeUserId, DateTime nowUtc, int limit, CancellationToken ct = default); + + // ── Expiry sweep (slice 6C) ────────────────────────────────────────────── + + /// + /// Open/Starting lobbies past their 2-hour deadline, tracked, bounded, oldest first. + /// + /// A lobby is swept to Expired rather than merely being treated as expired on read. Both exist, + /// and both are needed: the read-time IsExpired check is what makes an expired lobby immediately + /// unusable even if the sweep is behind, while the sweep is what actually frees its members from the + /// one-active-lobby invariant — a member row still pointing at a lobby whose state says Open would keep + /// its owner locked out of joining anything else forever. + /// + Task> GetExpiredLobbiesAsync( + DateTime nowUtc, int batchSize, CancellationToken ct = default); + + /// Pending invites past their 30-minute deadline, tracked, bounded, oldest first. + Task> GetExpiredInvitesAsync( + DateTime nowUtc, int batchSize, CancellationToken ct = default); + + // ── Start requests ─────────────────────────────────────────────────────── + + Task GetOpenStartRequestAsync(Guid lobbyId, int lobbyRevision, CancellationToken ct = default); + + Task GetStartRequestByIdempotencyKeyAsync( + Guid lobbyId, string idempotencyKey, CancellationToken ct = default); + + // ── Cross-module reads (M3 blocks, M4 catalog, M6 capability profiles) ─── + + Task GetCapabilityProfileAsync( + string gameSlug, int capabilityVersion, CancellationToken ct = default); + + /// + /// The highest-versioned IsActive profile for a slug — the one a new lobby/ticket should pin. Not for + /// re-validating an existing lobby, which pins an exact (GameSlug, CapabilityVersion) instead. + /// + Task GetActiveCapabilityProfileAsync( + string gameSlug, CancellationToken ct = default); + + Task GetGameAsync(string gameSlug, CancellationToken ct = default); + + Task> GetGameModesAsync(Guid gameId, CancellationToken ct = default); + + /// + /// The subset of that have a block with in + /// either direction. Returns the offenders rather than a bool so the caller can distinguish "you + /// blocked the host" from "a member blocked you" when it matters, and so one round trip covers a whole roster. + /// + Task> GetBlockedCounterpartsAsync( + Guid userId, IReadOnlyList candidateUserIds, CancellationToken ct = default); + + Task> GetUsersAsync( + IReadOnlyList userIds, CancellationToken ct = default); + + Task AreFriendsAsync(Guid userA, Guid userB, CancellationToken ct = default); + + // ── Writes (each stages its outbox rows in the same SaveChanges) ───────── + + Task AddLobbyAsync( + Lobby lobby, LobbyJoinCredential credential, IReadOnlyList events, + CancellationToken ct = default); + + Task AddInviteAsync(LobbyInvite invite, IReadOnlyList events, CancellationToken ct = default); + + Task AddStartRequestAsync( + LobbyStartRequest request, IReadOnlyList events, CancellationToken ct = default); + + /// Rotation: the outgoing credential is already MarkRotated-ed by the caller. + Task RotateCredentialAsync( + LobbyJoinCredential outgoing, LobbyJoinCredential incoming, IReadOnlyList events, + CancellationToken ct = default); + + /// + /// Persists whatever the command mutated on already-tracked aggregates, plus its events. + /// + /// This is the call that can throw — a unique-index violation (23505) from a racing writer, or a row-version + /// (xmin) mismatch from a lost update. Both are deliberately allowed to propagate: catching them here would + /// mean deciding the outcome without re-reading, which is exactly the bug R3 exists to prevent. The bounded + /// retry above the command re-runs the whole read-decide-write instead. + /// + Task SaveAsync(IReadOnlyList events, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Common/Interfaces/IMatchmakingRepository.cs b/src/SimPle.Application/Common/Interfaces/IMatchmakingRepository.cs new file mode 100644 index 0000000..cc03f69 --- /dev/null +++ b/src/SimPle.Application/Common/Interfaces/IMatchmakingRepository.cs @@ -0,0 +1,103 @@ +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Common.Interfaces; + +/// +/// Data access for matchmaking tickets and assignments. +/// +/// Like , nothing here catches a unique-violation or row-version exception: +/// contention propagates to the bounded whole-command retry, which re-reads and answers truthfully instead of +/// deciding against stale state (R3). +/// +public interface IMatchmakingRepository +{ + // ── Ticket reads ───────────────────────────────────────────────────────── + + /// Untracked. For rendering a status response. + Task GetTicketAsync(Guid ticketId, CancellationToken ct = default); + + /// Tracked. The read half of a read-decide-write command (cancel). + Task GetTicketForUpdateAsync(Guid ticketId, CancellationToken ct = default); + + /// + /// The user's single nonterminal ticket, tracked, or null. + /// + /// This is the queue's half of the cross-table "one active lobby or one active ticket" + /// invariant. It is only sound inside the command runner's transaction-scoped advisory lock on the actor — + /// the ticket index and the lobby-member index live on different tables and cannot see each other (Risk #2). + /// + Task GetActiveTicketForUserAsync(Guid userId, CancellationToken ct = default); + + /// The active assignment for a ticket, or null. Used to project a matched ticket's handoff. + Task GetActiveAssignmentAsync(Guid ticketId, CancellationToken ct = default); + + // ── Worker claim ───────────────────────────────────────────────────────── + + /// + /// Claims up to queued, not-yet-expired tickets for this worker cycle, using + /// SELECT … FOR UPDATE SKIP LOCKED so two workers never block on — or double-claim — the same row. + /// Oldest first, which is what anchors proposals on the longest-waiting ticket (anti-starvation). + /// + /// + /// The row lock is not exclusivity (Risk #1). It stops two workers contending on one + /// row; a requeued ticket or a serialization retry can still attempt a second assignment. The partial unique + /// index UNIQUE (TicketId) WHERE State = 'Active' is the correctness boundary that makes double + /// assignment impossible, and the two-worker test asserts against exactly that. + /// + /// + /// + /// Must be called inside a transaction: the lock is transaction-scoped, so a worker crash rolls the claim back + /// and returns the ticket to Queued with no compensating action and no lease to expire. + /// + /// + /// Returns tracked entities — the caller mutates and saves them in the same unit of work. + /// + Task> ClaimQueuedTicketsAsync( + int batchSize, DateTime nowUtc, CancellationToken ct = default); + + // ── Expiry sweep ───────────────────────────────────────────────────────── + + /// Nonterminal tickets past their deadline, tracked, bounded. Oldest first. + Task> GetExpiredTicketsAsync( + DateTime nowUtc, int batchSize, CancellationToken ct = default); + + // ── Observability ──────────────────────────────────────────────────────── + + /// + /// The age of the oldest queued ticket, or null when the queue is empty. Backs the + /// matchmaking-queue-age signal — the one number that says whether the queue is healthy without + /// exposing who is in it. + /// + Task GetOldestQueuedAgeAsync(DateTime nowUtc, CancellationToken ct = default); + + // ── Cross-module reads ─────────────────────────────────────────────────── + + /// + /// Every M3 block among , in one round trip. + /// + /// Deliberately not called once per ticket owner: + /// that is the right shape for a lobby roster (one actor against a handful of members) and the wrong one for a + /// worker batch, where it would issue one query per ticket every cycle — a self-inflicted load problem that + /// grows exactly when the queue is busiest. + /// + Task> GetBlockedPairsAsync( + IReadOnlyList userIds, CancellationToken ct = default); + + // ── Writes ─────────────────────────────────────────────────────────────── + + Task AddTicketAsync(MatchmakingTicket ticket, CancellationToken ct = default); + + /// + /// Commits one proposal: every member ticket's state change, one per ticket + /// sharing a group id, and the single MatchRequestedV1 — all in one SaveChanges, so an assignment + /// can never exist without its event, nor an event without its assignment. + /// + Task AddAssignmentsAsync( + IReadOnlyList assignments, + IReadOnlyList events, + CancellationToken ct = default); + + /// Persists whatever the caller mutated on already-tracked entities, plus any events. + Task SaveAsync(IReadOnlyList events, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Common/Interfaces/IOutboxRepository.cs b/src/SimPle.Application/Common/Interfaces/IOutboxRepository.cs new file mode 100644 index 0000000..0be1482 --- /dev/null +++ b/src/SimPle.Application/Common/Interfaces/IOutboxRepository.cs @@ -0,0 +1,52 @@ +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Common.Interfaces; + +/// +/// Data access for the transactional outbox's delivery side (D3). The producing side — +/// staging rows inside the transaction that caused them — belongs to each module's own +/// repository and is not here. +/// +public interface IOutboxRepository +{ + /// + /// Leases up to undelivered messages for one handler, oldest first. + /// + /// + /// "Undelivered" means: the message's type is one this handler consumes, and it has no + /// row for this handler that is Processed, DeadLettered, or currently + /// leased to a live dispatcher. There is deliberately no global "processed" flag on the message — each handler + /// tracks its own progress, so one slow or failing consumer can never starve another, and a message is retained + /// until every registered handler has acknowledged it. + /// + /// + /// + /// The lease is what makes two dispatcher instances safe. It is taken with FOR UPDATE SKIP LOCKED over + /// the delivery rows in the same transaction that writes the lease expiry, so a second dispatcher skips rows the + /// first is holding rather than blocking on them — and an expired lease (a crashed dispatcher) is reclaimable, + /// which is why it is a timestamp and not a boolean. + /// + /// + /// + /// Returns the messages together with their (created-or-reclaimed) delivery rows, tracked. The caller marks each + /// one processed or failed and saves. + /// + /// + Task> LeaseAsync( + string handlerName, + IReadOnlyList eventTypes, + int batchSize, + DateTime nowUtc, + DateTime leaseUntilUtc, + CancellationToken ct = default); + + /// Persists the delivery-row state changes the dispatcher made. + Task SaveAsync(CancellationToken ct = default); + + /// + /// How far behind the oldest unprocessed delivery is, or null when there is none. Backs the outbox lag + /// signal: a dispatcher that has quietly stopped looks exactly like an idle one until this number grows. + /// + Task GetOldestPendingAgeAsync( + string handlerName, IReadOnlyList eventTypes, DateTime nowUtc, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Common/Interfaces/IWorkerTransaction.cs b/src/SimPle.Application/Common/Interfaces/IWorkerTransaction.cs new file mode 100644 index 0000000..d289a7a --- /dev/null +++ b/src/SimPle.Application/Common/Interfaces/IWorkerTransaction.cs @@ -0,0 +1,33 @@ +namespace SimPle.Application.Common.Interfaces; + +/// +/// One background-worker unit of work: a single transaction, retried as a whole on contention. +/// +/// +/// This is 's sibling, minus the advisory lock. That difference is deliberate and +/// is the reason there are two: the command runner serializes one actor's seat-acquiring commands against +/// each other, which is meaningless for a worker — a worker has no actor, and taking a lock keyed on some arbitrary +/// ticket's owner would serialize unrelated workers for no benefit. What the worker needs from a transaction is the +/// other property: FOR UPDATE SKIP LOCKED row locks are transaction-scoped, so they must be taken and +/// released inside one, and a crash mid-cycle must roll the claims back rather than strand them. +/// +/// +/// +/// Contention is retried rather than surfaced. A worker has nobody to report a conflict to, and the losing side of +/// a claim race has nothing to apologise for — the tickets it failed to claim are still Queued and the next +/// cycle will see them. +/// +/// +public interface IWorkerTransaction +{ + /// + /// Runs inside one transaction. On contention the whole delegate is re-run — including + /// its reads — after the change tracker is cleared, so the rerun genuinely re-reads rather than re-deciding + /// against the losing attempt's stale entities. + /// + /// If the retry budget is exhausted the exception propagates: the caller is a background loop, and the honest + /// response to persistent contention there is to log the cycle as failed and try again on the next tick, not to + /// invent a result. + /// + Task RunAsync(Func> work, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Common/Options/LobbyCredentialOptions.cs b/src/SimPle.Application/Common/Options/LobbyCredentialOptions.cs new file mode 100644 index 0000000..e6b9496 --- /dev/null +++ b/src/SimPle.Application/Common/Options/LobbyCredentialOptions.cs @@ -0,0 +1,24 @@ +namespace SimPle.Application.Common.Options; + +/// +/// The server key used to compute keyed digests of lobby join codes and link tokens. +/// +/// This is a secret and, like Jwt:SecretKey and Recaptcha:SecretKey, must be configured +/// outside committed appsettings (environment variable LobbyCredential__Key, user-secrets, or the +/// deployment's secret store). Startup validation fails closed when it is absent or a placeholder, so the module +/// can never silently fall back to an unkeyed or well-known digest — which would make every join code in the +/// database offline-guessable. +/// +public sealed class LobbyCredentialOptions +{ + public const string SectionName = "LobbyCredential"; + + /// At least 32 characters, matching the JWT key's floor. + public string Key { get; set; } = string.Empty; + + /// + /// The region a lobby/ticket falls back to when neither the request nor the user's profile names an + /// allow-listed one. Must itself be allow-listed (LobbyAllowLists.Regions). + /// + public string DefaultRegion { get; set; } = "eu-west"; +} diff --git a/src/SimPle.Application/Common/Options/MatchmakingOptions.cs b/src/SimPle.Application/Common/Options/MatchmakingOptions.cs new file mode 100644 index 0000000..efd3058 --- /dev/null +++ b/src/SimPle.Application/Common/Options/MatchmakingOptions.cs @@ -0,0 +1,74 @@ +namespace SimPle.Application.Common.Options; + +/// +/// The matching worker's loop and batch shape (slice 6C). +/// +/// None of these are secrets, and all have working defaults — the module runs correctly with no configuration at +/// all. They exist so a deployment can widen the batch or slow the loop without a rebuild. +/// +public sealed class MatchmakingOptions +{ + public const string SectionName = "Matchmaking"; + + /// + /// How often a worker attempts a cycle. Two seconds matches the client's ticket poll: a shorter loop would make + /// the queue no faster from the player's side, since they cannot learn about a match until their next poll. + /// + public TimeSpan Interval { get; set; } = TimeSpan.FromSeconds(2); + + /// + /// Tickets claimed per cycle. Bounded on purpose: an unbounded claim would let one worker take the whole queue + /// and turn a second worker into a spectator, and would make a single failed cycle roll back everything. + /// + public int BatchSize { get; set; } = 100; + + /// + /// Whether the matching worker is hosted at all. Distinct from the M8 readiness gate — that gate is the + /// correctness boundary (the cycle refuses to run without a match runtime, no matter what this says); + /// this is an operational switch for the rollback plan, which calls for disabling the workers while preserving + /// every lobby and ticket record. + /// + public bool WorkerEnabled { get; set; } = true; +} + +/// The expiry sweep's loop and batch shape. The sweep runs with or without Module 8 — see IExpirySweeper. +public sealed class ExpiryOptions +{ + public const string SectionName = "Expiry"; + + /// + /// Comfortably inside the benchmark's 5-second expiry-lag budget, so a ticket's honest TimedOut lands + /// within a poll or two of its actual deadline rather than whenever the sweep next happens to wake. + /// + public TimeSpan Interval { get; set; } = TimeSpan.FromSeconds(2); + + /// Rows of each kind (tickets, lobbies, invites) per sweep. + public int BatchSize { get; set; } = 200; + + public bool WorkerEnabled { get; set; } = true; +} + +/// The outbox dispatcher's loop, batch, lease, and retry budget (D3). +public sealed class OutboxOptions +{ + public const string SectionName = "Outbox"; + + public TimeSpan Interval { get; set; } = TimeSpan.FromSeconds(5); + + public int BatchSize { get; set; } = 50; + + /// + /// How long a leased delivery is considered in-flight. It must comfortably exceed the slowest handler, because + /// a lease that expires while its handler is still working invites a second dispatcher to run the same event + /// concurrently — survivable (handlers are idempotent) but wasteful, and it makes the attempt count lie. + /// + public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Attempts before a delivery is dead-lettered. Bounded so that one permanently-broken handler cannot retry + /// forever and crowd out every other event in the batch. + /// + public int MaxAttempts { get; set; } = 5; + + public bool WorkerEnabled { get; set; } = true; +} diff --git a/src/SimPle.Application/DependencyInjection.cs b/src/SimPle.Application/DependencyInjection.cs index 01a6aec..9911f37 100644 --- a/src/SimPle.Application/DependencyInjection.cs +++ b/src/SimPle.Application/DependencyInjection.cs @@ -1,8 +1,13 @@ using Microsoft.Extensions.DependencyInjection; using SimPle.Application.Auth.Services; +using SimPle.Application.Expiry; using SimPle.Application.Friends.Services; using SimPle.Application.GameHost.Services; using SimPle.Application.Games.Services; +using SimPle.Application.Lobbies.Services; +using SimPle.Application.Matchmaking.Services; +using SimPle.Application.Outbox; +using SimPle.Application.Outbox.Handlers; using SimPle.Application.People.Services; using SimPle.Application.Profiles.Services; @@ -17,6 +22,19 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + + // Module 6, slice 6C. The coordinator and the sweeper are application services rather than logic inside a + // BackgroundService on purpose: that is what lets the real-Postgres tests run two coordinators concurrently + // and assert zero duplicate assignment, which a timer tick buried in a hosted service could not. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // The outbox dispatcher (D3) — the codebase's first consumer side. M7/M8/M11 register their own + // IOutboxHandler here and inherit the machinery; they do not each build a consumer. + services.AddScoped(); + services.AddScoped(); // IGameRegistry is registered separately by the composition root: building it requires the list of // installed IHostedGameDefinition instances, which is composition-root knowledge (currently empty — diff --git a/src/SimPle.Application/Expiry/ExpirySweeper.cs b/src/SimPle.Application/Expiry/ExpirySweeper.cs new file mode 100644 index 0000000..b352b76 --- /dev/null +++ b/src/SimPle.Application/Expiry/ExpirySweeper.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.Lobbies.Outbox; +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Expiry; + +/// +/// The bounded expiry sweep. See for why it runs without Module 8 and why credentials +/// are not swept. +/// +public sealed class ExpirySweeper : IExpirySweeper +{ + private readonly IMatchmakingRepository _tickets; + private readonly ILobbyRepository _lobbies; + private readonly IWorkerTransaction _transaction; + private readonly ExpiryOptions _options; + private readonly TimeProvider _clock; + private readonly ILogger _logger; + + public ExpirySweeper( + IMatchmakingRepository tickets, + ILobbyRepository lobbies, + IWorkerTransaction transaction, + IOptions options, + TimeProvider clock, + ILogger logger) + { + _tickets = tickets; + _lobbies = lobbies; + _transaction = transaction; + _options = options.Value; + _clock = clock; + _logger = logger; + } + + public Task SweepAsync(CancellationToken ct = default) => + _transaction.RunAsync(async token => + { + var nowUtc = _clock.GetUtcNow().UtcDateTime; + var batchSize = _options.BatchSize; + + var ticketRows = await _tickets.GetExpiredTicketsAsync(nowUtc, batchSize, token); + var lobbyRows = await _lobbies.GetExpiredLobbiesAsync(nowUtc, batchSize, token); + var inviteRows = await _lobbies.GetExpiredInvitesAsync(nowUtc, batchSize, token); + + var ticketsExpired = 0; + var maxLag = TimeSpan.Zero; + foreach (var ticket in ticketRows) + { + if (!ticket.TryTimeOut(nowUtc)) continue; // idempotent: already terminal, or not actually due + + ticketsExpired++; + + // How late we were, per ticket. This is the signal — a sweep whose lag is creeping up is a sweep + // that is about to start letting tickets outlive their own deadline visibly. + var lag = nowUtc - ticket.DeadlineAtUtc; + if (lag > maxLag) maxLag = lag; + } + + var events = new List(); + + var lobbiesExpired = 0; + foreach (var lobby in lobbyRows) + { + if (!lobby.TryExpire(nowUtc)) continue; + + lobbiesExpired++; + + // The close event carries the reason the aggregate recorded (Expired), so a consumer never has to + // infer why a lobby ended from the absence of anything else. + events.Add(LobbyOutbox.LobbyClosedEvent(lobby)); + } + + var invitesExpired = 0; + foreach (var invite in inviteRows) + { + if (invite.TryExpire(nowUtc)) invitesExpired++; + } + + // An expired invite emits no event: nobody acted, and M11 has no notification to send for "an invite you + // ignored has quietly lapsed". The state change alone is the record. + if (ticketsExpired + lobbiesExpired + invitesExpired > 0) + { + // One save covers tickets, lobbies, and invites: both repositories are scoped over the *same* + // AppDbContext, so every entity read above is tracked by one change tracker and commits in one + // transaction. Saving through each repository in turn would split the sweep into three commits and + // let a crash leave a lobby expired but its invites still pending. + await _tickets.SaveAsync(events, token); + + _logger.LogInformation( + "Expiry sweep. Tickets={Tickets} Lobbies={Lobbies} Invites={Invites} MaxTicketLagMs={LagMs}", + ticketsExpired, lobbiesExpired, invitesExpired, (long)maxLag.TotalMilliseconds); + } + + return new ExpirySweepResult(ticketsExpired, lobbiesExpired, invitesExpired, maxLag); + }, ct); +} diff --git a/src/SimPle.Application/Expiry/IExpirySweeper.cs b/src/SimPle.Application/Expiry/IExpirySweeper.cs new file mode 100644 index 0000000..09cd5e3 --- /dev/null +++ b/src/SimPle.Application/Expiry/IExpirySweeper.cs @@ -0,0 +1,47 @@ +namespace SimPle.Application.Expiry; + +/// +/// What one expiry sweep did. is how far past its deadline the most overdue ticket +/// in this batch was — the module's expiry lag signal, and the number the benchmark holds under 5 seconds. +/// It is measured, not assumed: a sweep that silently fell behind would otherwise look identical to one that had +/// nothing to do. +/// +public sealed record ExpirySweepResult( + int TicketsExpired, + int LobbiesExpired, + int InvitesExpired, + TimeSpan MaxTicketLag) +{ + public static readonly ExpirySweepResult Empty = new(0, 0, 0, TimeSpan.Zero); + + public int Total => TicketsExpired + LobbiesExpired + InvitesExpired; +} + +/// +/// Sweeps tickets past their 60-second deadline, lobbies past their 2-hour lifetime, and invites past their +/// 30-minute lifetime. +/// +/// +/// Unlike matching, the sweep runs with or without Module 8. That is the honest half of the queue: +/// a player who enqueues before a match runtime exists still watches their band widen and still gets a truthful +/// TimedOut at sixty seconds, instead of a ticket that sits Queued forever because the only thing +/// that could have resolved it does not exist yet. +/// +/// +/// +/// Every transition it drives is idempotent at the domain level (TryTimeOut / TryExpire return false +/// rather than transitioning twice), so a sweep that overlaps with a previous run, or re-runs after a crash, cannot +/// double-expire anything. +/// +/// +/// +/// Join credentials are deliberately not swept. A credential is already dead the moment it is past its +/// deadline — LobbyJoinCredential.CanRedeem checks the clock, and the join path calls it — so a sweep would +/// change nothing a caller can observe. Rewriting its state row would be bookkeeping that buys no behavior, and the +/// row is retained as the audit trail of which generation was live when. +/// +/// +public interface IExpirySweeper +{ + Task SweepAsync(CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Games/GameEntryActions.cs b/src/SimPle.Application/Games/GameEntryActions.cs index 32e6994..f20542b 100644 --- a/src/SimPle.Application/Games/GameEntryActions.cs +++ b/src/SimPle.Application/Games/GameEntryActions.cs @@ -6,15 +6,17 @@ namespace SimPle.Application.Games; /// The fixed set of entry-point actions projected onto every catalog/detail DTO. In M4 every action is /// deferred — no game engine exists yet — per the spec's entry-actions table. A later module flips its /// own action to enabled only after its backend and E2E gate pass; this list is never mutated per-request. +/// M6 (Lobby & Matchmaking System) flipped its 3 owned actions after its backend + E2E gate passed; the +/// frontend still re-checks each enabled action against the specific game's capabilities/player count. /// public static class GameEntryActions { public static readonly IReadOnlyList All = new[] { new GameEntryActionDto("play-vs-ai", "deferred", "Games.EntryDeferred.AI", 9), - new GameEntryActionDto("quick-match", "deferred", "Games.EntryDeferred.QuickMatch", 6), - new GameEntryActionDto("create-lobby", "deferred", "Games.EntryDeferred.Lobby", 6), - new GameEntryActionDto("invite-friend", "deferred", "Games.EntryDeferred.Invite", 6), + new GameEntryActionDto("quick-match", "enabled", "Games.EntryDeferred.QuickMatch", 6), + new GameEntryActionDto("create-lobby", "enabled", "Games.EntryDeferred.Lobby", 6), + new GameEntryActionDto("invite-friend", "enabled", "Games.EntryDeferred.Invite", 6), new GameEntryActionDto("enter-match-room", "deferred", "Games.EntryDeferred.MatchRoom", 8), }; } diff --git a/src/SimPle.Application/Lobbies/DTOs/LobbyDtos.cs b/src/SimPle.Application/Lobbies/DTOs/LobbyDtos.cs new file mode 100644 index 0000000..bf8b900 --- /dev/null +++ b/src/SimPle.Application/Lobbies/DTOs/LobbyDtos.cs @@ -0,0 +1,146 @@ +using SimPle.Shared.Common; + +namespace SimPle.Application.Lobbies.DTOs; + +/// +/// The full lobby view for a member or an authorized viewer. +/// +/// It deliberately carries no credential. The join code and link token appear in exactly one place — +/// , returned only by create and rotate — so there is no read path that could +/// hand a private code to someone who merely holds the lobby id (Risk #7). +/// +/// and exist so the client can render +/// honest enabled/disabled controls without re-deriving host role, lifecycle, or which later modules are live. +/// A client that guessed would eventually guess wrong and offer an action the server rejects. +/// +public sealed record LobbyDto( + Guid LobbyId, + string GameSlug, + int CapabilityVersion, + string Privacy, + int MaxPlayers, + string TimeControlId, + bool Rated, + string ResolvedRegion, + string SpectatorPolicy, + string TieBreakRuleId, + bool AiFillRequested, + string State, + int Revision, + DateTime ExpiresAtUtc, + string? ClosedReason, + Guid HostUserId, + IReadOnlyList Seats, + IReadOnlyList AllowedActions, + DependencyReadinessDto DependencyReadiness); + +/// +/// One seat. Identity is the canonical shared — the same shape M3 search and +/// friend lists use — so a member avatar/name always links to the real profile and a lobby never invents its own +/// identity fields. +/// +public sealed record LobbySeatDto( + PublicIdentityDto Identity, + bool IsHost, + bool IsReady, + DateTime JoinedAtUtc); + +/// +/// Which downstream modules are actually live. Every value is false in Module 6 and is read from the +/// real probe, never hardcoded in the client — so when M7/M8/M9 land, the UI turns on without a frontend +/// change, and until then it cannot claim an action that would fail. +/// +public sealed record DependencyReadinessDto( + bool Chat, + bool MatchRuntime, + bool AiParticipants); + +/// +/// The plaintext join credential. Returned only from create and rotate, and never persisted in this +/// form — the database holds keyed digests alone. Rotating supersedes the previous value immediately. +/// +public sealed record LobbyCredentialDto( + string Code, + string LinkToken, + int Generation, + DateTime ExpiresAtUtc); + +public sealed record CreateLobbyResultDto( + LobbyDto Lobby, + LobbyCredentialDto Credential); + +/// +/// A public-discovery row. Strictly narrower than : no seat roster, no readiness, no +/// credential — a lobby you have not joined tells you only what you need in order to decide to join it. +/// Private lobbies never appear here at all. +/// +public sealed record LobbySummaryDto( + Guid LobbyId, + string GameSlug, + int MaxPlayers, + int JoinedCount, + string TimeControlId, + bool Rated, + string ResolvedRegion, + string SpectatorPolicy, + PublicIdentityDto Host, + DateTime CreatedAt, + DateTime ExpiresAtUtc); + +public sealed record LobbyInviteDto( + Guid InviteId, + Guid LobbyId, + string GameSlug, + PublicIdentityDto Inviter, + string State, + DateTime ExpiresAtUtc); + +/// +/// The dashboard's "what am I currently in" query. Exactly one of the two is ever set — that is the +/// one-active-lobby-or-ticket invariant, surfaced rather than re-derived by the client. +/// +public sealed record ActiveContextDto( + LobbyDto? Lobby, + Guid? TicketId); + +/// +/// The outcome of a start attempt. Before M8 registers a consumer no start can succeed at all, so this type is +/// reachable only once M8 exists; the honest 503 (Lobbies.MatchRuntimeUnavailable) is what M6 returns +/// today. A committed is a durable request, never a created match. +/// +public sealed record StartLobbyResultDto( + Guid LobbyId, + Guid MatchRequestId, + string State, + int Revision); + +/// +/// The capability source (D2), read-only. Lets a client populate capabilityVersion and the allowed +/// modes/time controls/tie-break rules/spectator policies before calling create-lobby or create-ticket, +/// instead of guessing values the server will reject. Always the highest-versioned IsActive profile for the +/// slug — a lobby/ticket already in flight keeps meaning what it meant at creation via its own pinned version, so +/// this route is never used to reinterpret an existing lobby. +/// +public sealed record GameCapabilityProfileDto( + string GameSlug, + int CapabilityVersion, + int MinPlayers, + int MaxPlayers, + IReadOnlyList AllowedModes, + IReadOnlyList TimeControls, + IReadOnlyList TieBreakRules, + IReadOnlyList SpectatorPolicies, + bool RatedEligible, + bool AiFillEligible); + +/// The action names surfaced in . +public static class LobbyActions +{ + public const string Leave = "leave"; + public const string Ready = "ready"; + public const string Settings = "settings"; + public const string Invite = "invite"; + public const string Kick = "kick"; + public const string RotateCredential = "rotate-credential"; + public const string Start = "start"; +} diff --git a/src/SimPle.Application/Lobbies/DTOs/LobbyRequests.cs b/src/SimPle.Application/Lobbies/DTOs/LobbyRequests.cs new file mode 100644 index 0000000..3bdcac8 --- /dev/null +++ b/src/SimPle.Application/Lobbies/DTOs/LobbyRequests.cs @@ -0,0 +1,76 @@ +namespace SimPle.Application.Lobbies.DTOs; + +/// +/// Lobby creation. may be "Auto" or omitted — the server resolves it and stores +/// an explicit region; "Auto" is never persisted. +/// +/// There is deliberately no hostUserId: the actor is always the JWT sub claim. A body-supplied +/// identity is the classic BOLA vector and is not accepted anywhere in this module. +/// +public sealed record CreateLobbyRequestDto( + string GameSlug, + int CapabilityVersion, + string Privacy, + int MaxPlayers, + string TimeControlId, + bool Rated, + string? Region, + string SpectatorPolicy, + string TieBreakRuleId, + bool AiFillRequested); + +/// +/// Join a lobby. Exactly one of / / +/// is supplied. +/// +/// The credential travels in the body, never in the path: a credential is a secret, not a resource +/// identifier, and a path segment lands in access logs, referrers, and browser history. +/// +/// is a resource id, not a secret — it is only ever honored when the target lobby is +/// currently Public and Open (the same visibility rule as the public browse listing and single-lobby +/// read), so naming a private or foreign lobby's id here answers with the same privacy-safe not-found as everywhere +/// else in this module. It carries no join throttle: unlike a guessed code, it identifies a specific already-public +/// row rather than searching a credential space. +/// +public sealed record JoinLobbyRequestDto( + string? Code, + string? LinkToken, + Guid? LobbyId = null); + +/// +/// Host settings change. Sends the whole settings tuple, so a partial update can never leave the lobby +/// half-validated. is the optimistic-concurrency token: a mismatch is a typed +/// Lobbies.StaleRevision carrying the current state, never a 500 and never a silent overwrite. +/// +public sealed record UpdateLobbySettingsRequestDto( + string GameSlug, + int CapabilityVersion, + string Privacy, + int MaxPlayers, + string TimeControlId, + bool Rated, + string? Region, + string SpectatorPolicy, + string TieBreakRuleId, + bool AiFillRequested, + int ExpectedRevision); + +public sealed record SetReadinessRequestDto( + bool IsReady, + int ExpectedRevision); + +public sealed record KickMemberRequestDto( + Guid TargetUserId, + int ExpectedRevision); + +public sealed record CreateInviteRequestDto( + Guid InviteeUserId); + +/// +/// Start. makes a client retry replay the existing request rather than mint a +/// second one — the partial unique index on (LobbyId, LobbyRevision) WHERE State = 'Open' is what actually +/// enforces that; the key is how the caller recognizes its own prior attempt. +/// +public sealed record StartLobbyRequestDto( + int ExpectedRevision, + string IdempotencyKey); diff --git a/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs b/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs new file mode 100644 index 0000000..fd09138 --- /dev/null +++ b/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Lobbies.Outbox; + +/// +/// Builds Module 6's lobby integration events, mirroring +/// and . Every payload carries minimum ids only. +/// +/// No join code, link token, or digest ever appears in a payload. An outbox row is durable, +/// replayable, and read by every future consumer (M7/M8/M11) — a credential that reached one would be permanently +/// disclosed to modules that have no business holding it (Risk #7). +/// +/// Each message is staged inside the same transaction as the aggregate mutation. The unique +/// (AggregateId, EventType, AggregateDomainVersion) index is what makes a retried transition idempotent: +/// is the domain version, so replaying a command at the same revision cannot stage +/// the same logical event twice. +/// +/// MatchRequestedV1 is the one event M8 consumes to create a match. Committing it means a durable +/// request exists — never that a match does (Risk #6). +/// +public static class LobbyOutbox +{ + public const int EventVersion = 1; + + private const string LobbyAggregate = "Lobby"; + private const string InviteAggregate = "LobbyInvite"; + private const string StartRequestAggregate = "LobbyStartRequest"; + + public const string LobbyCreated = "LobbyCreatedV1"; + public const string LobbyMemberJoined = "LobbyMemberJoinedV1"; + public const string LobbyMemberLeft = "LobbyMemberLeftV1"; + public const string LobbyMemberKicked = "LobbyMemberKickedV1"; + public const string LobbyHostTransferred = "LobbyHostTransferredV1"; + public const string LobbySettingsChanged = "LobbySettingsChangedV1"; + public const string LobbyClosed = "LobbyClosedV1"; + public const string LobbyInviteCreated = "LobbyInviteCreatedV1"; + public const string LobbyInviteAccepted = "LobbyInviteAcceptedV1"; + public const string LobbyInviteRevoked = "LobbyInviteRevokedV1"; + public const string LobbyCredentialRotated = "LobbyCredentialRotatedV1"; + public const string MatchRequested = "MatchRequestedV1"; + + public static OutboxMessage LobbyCreatedEvent(Lobby lobby) => + LobbyEvent(lobby, LobbyCreated, new { lobbyId = lobby.Id, hostUserId = lobby.HostUserId, gameSlug = lobby.GameSlug }); + + public static OutboxMessage MemberJoinedEvent(Lobby lobby, Guid userId) => + LobbyEvent(lobby, LobbyMemberJoined, new { lobbyId = lobby.Id, userId }); + + public static OutboxMessage MemberLeftEvent(Lobby lobby, Guid userId) => + LobbyEvent(lobby, LobbyMemberLeft, new { lobbyId = lobby.Id, userId }); + + public static OutboxMessage MemberKickedEvent(Lobby lobby, Guid userId, Guid removedByUserId) => + LobbyEvent(lobby, LobbyMemberKicked, new { lobbyId = lobby.Id, userId, removedByUserId }); + + public static OutboxMessage HostTransferredEvent(Lobby lobby, Guid newHostUserId) => + LobbyEvent(lobby, LobbyHostTransferred, new { lobbyId = lobby.Id, newHostUserId }); + + public static OutboxMessage SettingsChangedEvent(Lobby lobby) => + LobbyEvent(lobby, LobbySettingsChanged, new { lobbyId = lobby.Id, gameSlug = lobby.GameSlug, capabilityVersion = lobby.CapabilityVersion }); + + public static OutboxMessage LobbyClosedEvent(Lobby lobby) => + LobbyEvent(lobby, LobbyClosed, new { lobbyId = lobby.Id, reason = lobby.ClosedReason?.ToString() }); + + /// Rotation carries the generation only — never the old or new digest, let alone the plaintext. + public static OutboxMessage CredentialRotatedEvent(Lobby lobby, int generation) => + LobbyEvent(lobby, LobbyCredentialRotated, new { lobbyId = lobby.Id, generation }); + + /// + /// The event M8 consumes. Ids only: M8 re-reads the lobby it names rather than trusting a settings snapshot + /// that could already be stale by the time it is delivered. + /// + public static OutboxMessage MatchRequestedEvent(Lobby lobby, LobbyStartRequest request) => + OutboxMessage.Create( + StartRequestAggregate, request.Id, MatchRequested, EventVersion, + aggregateDomainVersion: request.LobbyRevision, requestCycleId: request.LobbyRevision, + Serialize(new + { + matchRequestId = request.MatchRequestId, + lobbyId = request.LobbyId, + lobbyRevision = request.LobbyRevision, + })); + + public static OutboxMessage InviteCreatedEvent(LobbyInvite invite) => + InviteEvent(invite, LobbyInviteCreated); + + public static OutboxMessage InviteAcceptedEvent(LobbyInvite invite) => + InviteEvent(invite, LobbyInviteAccepted); + + public static OutboxMessage InviteRevokedEvent(LobbyInvite invite) => + InviteEvent(invite, LobbyInviteRevoked); + + /// + /// The lobby's doubles as the aggregate domain version: it is bumped by exactly + /// the mutations that emit an event, so the outbox's uniqueness index makes a replayed command a no-op rather + /// than a duplicate delivery. + /// + private static OutboxMessage LobbyEvent(Lobby lobby, string eventType, object payload) => + OutboxMessage.Create( + LobbyAggregate, lobby.Id, eventType, EventVersion, + aggregateDomainVersion: lobby.Revision, requestCycleId: lobby.Revision, + Serialize(payload)); + + /// + /// An invite has no revision counter — but its lifecycle is Pending -> Accepted|Revoked|Expired, + /// a single terminal step, so the state ordinal is a sufficient and stable domain version. + /// + private static OutboxMessage InviteEvent(LobbyInvite invite, string eventType) => + OutboxMessage.Create( + InviteAggregate, invite.Id, eventType, EventVersion, + aggregateDomainVersion: (int)invite.State, requestCycleId: (int)invite.State, + Serialize(new + { + inviteId = invite.Id, + lobbyId = invite.LobbyId, + inviterUserId = invite.InviterUserId, + inviteeUserId = invite.InviteeUserId, + })); + + private static string Serialize(object payload) => JsonSerializer.Serialize(payload); +} diff --git a/src/SimPle.Application/Lobbies/Services/ILobbiesService.cs b/src/SimPle.Application/Lobbies/Services/ILobbiesService.cs new file mode 100644 index 0000000..42b8255 --- /dev/null +++ b/src/SimPle.Application/Lobbies/Services/ILobbiesService.cs @@ -0,0 +1,140 @@ +using SimPle.Application.Lobbies.DTOs; +using SimPle.Shared.Common; + +namespace SimPle.Application.Lobbies.Services; + +/// +/// The lobby command surface (slice 6B). Matchmaking tickets, the matching/expiry workers, and the outbox +/// dispatcher are slice 6C and are not here. +/// +/// Every method takes the actor as its first argument and it is always the JWT sub claim — no method +/// accepts a caller-supplied identity. Authorization is re-evaluated against the object's current membership and +/// host role on every call, never against a prior step's result. +/// +public interface ILobbiesService +{ + Task> CreateAsync( + Guid actorUserId, CreateLobbyRequestDto request, CancellationToken ct = default); + + /// + /// The active capability profile (D2) for a game slug — the pinned-version source a client reads before + /// calling or a matchmaking ticket create, so it never has to guess a + /// capabilityVersion or an allowed time control/tie-break rule/spectator policy. + /// + Task> GetCapabilityProfileAsync( + string gameSlug, CancellationToken ct = default); + + /// Member or authorized viewer. A foreign/private/missing lobby is one indistinguishable 404. + Task> GetAsync(Guid actorUserId, Guid lobbyId, CancellationToken ct = default); + + /// Bounded public discovery. Private lobbies never appear and never affect totals or cursors. + Task>> GetPublicAsync( + Guid actorUserId, int limit, string? cursor, CancellationToken ct = default); + + /// + /// Join a lobby. Code/link token (secrets, carried in the body — never a path segment) or a lobbyId (a resource + /// identifier, only honored when that lobby is currently Public and Open). + /// + Task> JoinByCredentialAsync( + Guid actorUserId, JoinLobbyRequestDto request, CancellationToken ct = default); + + Task LeaveAsync(Guid actorUserId, Guid lobbyId, CancellationToken ct = default); + + Task> SetReadinessAsync( + Guid actorUserId, Guid lobbyId, SetReadinessRequestDto request, CancellationToken ct = default); + + Task> UpdateSettingsAsync( + Guid actorUserId, Guid lobbyId, UpdateLobbySettingsRequestDto request, CancellationToken ct = default); + + Task> KickAsync( + Guid actorUserId, Guid lobbyId, KickMemberRequestDto request, CancellationToken ct = default); + + Task> RotateCredentialAsync( + Guid actorUserId, Guid lobbyId, CancellationToken ct = default); + + Task> CreateInviteAsync( + Guid actorUserId, Guid lobbyId, CreateInviteRequestDto request, CancellationToken ct = default); + + Task RevokeInviteAsync( + Guid actorUserId, Guid lobbyId, Guid inviteId, CancellationToken ct = default); + + /// + /// Redeem a targeted invite (R6 — a route the approved spec's table omits). + /// + /// Without it an invitee has no way to reach a seat: join takes a credential, and handing every invitee the + /// lobby's private code would both leak it and make a revoked invite still redeemable. Accept runs the same + /// bounded transaction as a credential join — block re-check, one-active-lobby-or-ticket, capacity, readiness + /// reset — so an invited member is seated under exactly the same invariants as any other. + /// + Task> AcceptInviteAsync(Guid actorUserId, Guid inviteId, CancellationToken ct = default); + + Task DeclineInviteAsync(Guid actorUserId, Guid inviteId, CancellationToken ct = default); + + /// Returns Lobbies.MatchRuntimeUnavailable while no M8 consumer is registered. + Task> StartAsync( + Guid actorUserId, Guid lobbyId, StartLobbyRequestDto request, CancellationToken ct = default); + + Task>> GetMyInvitesAsync( + Guid actorUserId, CancellationToken ct = default); + + /// The dashboard's active lobby or ticket. Never both — that is the invariant. + Task> GetMyActiveAsync(Guid actorUserId, CancellationToken ct = default); + + /// + /// Rematch from a terminal match. M8 owns matches and does not exist, so there is no terminal match to read + /// and this honestly returns Lobbies.MatchRuntimeUnavailable rather than fabricating a lobby from + /// settings nobody recorded. + /// + Task> CreateRematchLobbyAsync( + Guid actorUserId, Guid terminalMatchId, CancellationToken ct = default); +} + +/// +/// Module 6's error catalogue, in the codebase's PascalCase dot-namespaced house style +/// (reconciliation R1 — the brief writes them lobby.snake_case, but every existing +/// consumer, including the frontend's switch (error.code) mappings, reads Games.NotFound / +/// Friends.RequestCooldown. Identical semantics; one casing scheme.) +/// +public static class LobbyErrors +{ + /// + /// Privacy-safe. Deliberately returned for missing, expired-and-swept, private-and- + /// unauthorized, and another user's lobby alike. A 403 here would confirm the id exists, which is + /// exactly the BOLA leak (OWASP API1:2023) the 404 exists to close. + /// + public const string NotFound = "Lobbies.NotFound"; + + public const string Full = "Lobbies.Full"; + public const string Closed = "Lobbies.Closed"; + public const string Expired = "Lobbies.Expired"; + public const string Blocked = "Lobbies.Blocked"; + public const string StaleRevision = "Lobbies.StaleRevision"; + + /// Actor is a member but is not the host. Safe to disclose — they can already see the lobby. + public const string Forbidden = "Lobbies.Forbidden"; + + public const string AlreadyActive = "Lobbies.AlreadyActive"; + public const string CapabilityDisabled = "Lobbies.CapabilityDisabled"; + + /// No active capability profile exists for the requested slug. Not privacy-sensitive — game slugs are + /// public catalog data — so this is a plain 404 naming the actual code, unlike . + public const string CapabilityNotFound = "Lobbies.CapabilityNotFound"; + + /// M8 is not registered. Start, rematch, and queue execution are honestly unavailable. + public const string MatchRuntimeUnavailable = "Lobbies.MatchRuntimeUnavailable"; + + /// + /// Deliberately indistinguishable across wrong / expired / rotated / revoked / closed-lobby. + /// Any finer-grained answer would turn the join endpoint into an oracle for which codes are live. + /// + public const string CredentialInvalid = "Lobbies.CredentialInvalid"; + + /// The bounded retry budget was spent. A typed 409 — never a 500 (brief Risk #5). + public const string ConcurrencyConflict = "Lobbies.ConcurrencyConflict"; + + public const string NotStartable = "Lobbies.NotStartable"; + public const string InvalidTarget = "Lobbies.InvalidTarget"; + public const string ValidationFailed = "Validation.Failed"; + public const string InvalidCursor = "Pagination.InvalidCursor"; + public const string RateLimitExceeded = "RateLimit.Exceeded"; +} diff --git a/src/SimPle.Application/Lobbies/Services/ILobbyCredentialHasher.cs b/src/SimPle.Application/Lobbies/Services/ILobbyCredentialHasher.cs new file mode 100644 index 0000000..828ef6b --- /dev/null +++ b/src/SimPle.Application/Lobbies/Services/ILobbyCredentialHasher.cs @@ -0,0 +1,26 @@ +namespace SimPle.Application.Lobbies.Services; + +/// +/// Turns a plaintext join credential into the keyed digest that is the only form ever persisted, and compares a +/// user-supplied attempt against a stored digest in constant time. +/// +/// Keyed (HMAC), not a bare hash: a join code carries only ~60 bits, so an attacker who obtained the database could +/// exhaust an unkeyed digest offline. The server key makes the stored digests useless without it. +/// +/// Constant-time comparison closes the timing side channel — a byte-by-byte compare would leak how much of a +/// guessed code was correct, turning a 60-bit search into a per-character one. +/// +public interface ILobbyCredentialHasher +{ + /// Digest of a manual join code. Normalizes the input first, so casing and separators do not matter. + string HashCode(string plaintextCode); + + /// Digest of a 128-bit share-link token. Not normalized — the token is machine-generated and exact. + string HashLinkToken(string plaintextLinkToken); + + /// + /// Constant-time equality of two digests. Always compare through this — never with ==, which + /// short-circuits on the first differing byte. + /// + bool DigestsMatch(string storedDigest, string candidateDigest); +} diff --git a/src/SimPle.Application/Lobbies/Services/ILobbyJoinThrottle.cs b/src/SimPle.Application/Lobbies/Services/ILobbyJoinThrottle.cs new file mode 100644 index 0000000..c2aaff0 --- /dev/null +++ b/src/SimPle.Application/Lobbies/Services/ILobbyJoinThrottle.cs @@ -0,0 +1,27 @@ +namespace SimPle.Application.Lobbies.Services; + +/// +/// Throttles failed join-credential attempts specifically (OWASP API4:2023). +/// +/// This cannot be an ASP.NET rate-limit policy, and the distinction matters. A rate limiter spends a permit on +/// every request, so a policy tight enough to stop code-guessing (a wrong 60-bit code is cheap to spam) would +/// equally punish members legitimately joining lobbies they were invited to. Only failures are interesting, so +/// only failures are counted — a caller with a correct code is never throttled by this at all. +/// +/// The counter is keyed on the authenticated actor, so it survives a client changing IPs, and it is bumped +/// after the constant-time digest comparison, so it leaks nothing about which part of a guess was right. +/// +public interface ILobbyJoinThrottle +{ + /// + /// The instant the actor may next attempt a credential join, or null when they are not throttled. + /// Returned rather than a bare bool so the caller can emit an accurate Retry-After. + /// + Task GetRetryAfterUtcAsync(Guid actorUserId, CancellationToken ct = default); + + /// Records one failed credential attempt. Called only on failure. + Task RecordFailureAsync(Guid actorUserId, CancellationToken ct = default); + + /// Clears the actor's failure streak after a successful join. + Task ClearAsync(Guid actorUserId, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Lobbies/Services/IMatchRuntimeProbe.cs b/src/SimPle.Application/Lobbies/Services/IMatchRuntimeProbe.cs new file mode 100644 index 0000000..2e9dc1b --- /dev/null +++ b/src/SimPle.Application/Lobbies/Services/IMatchRuntimeProbe.cs @@ -0,0 +1,47 @@ +namespace SimPle.Application.Lobbies.Services; + +/// +/// Module 8's readiness probe, as seen from Module 6. +/// +/// M8 does not exist. Rather than scatter // TODO: M8 through the command layer, M6 builds the real call +/// site now against this interface and ships an implementation that honestly reports "no runtime". When M8 lands +/// it replaces the implementation and every gate below turns on without a single change to the lobby commands. +/// +/// This is what keeps the brief's central promise enforceable: until a consumer is registered, Start returns +/// Lobbies.MatchRuntimeUnavailable, the lobby stays Open, and nothing anywhere claims a match was +/// created (Risk #6). +/// +public interface IMatchRuntimeProbe +{ + /// + /// Whether a match runtime is registered and healthy. False in Module 6, which is what makes Start honestly + /// unavailable rather than silently broken. + /// + Task IsAvailableAsync(CancellationToken ct = default); + + /// + /// Whether the user is a participant in a live (PendingStart|Active|PausePending|Paused) match. + /// + /// Re-asked at join, invite-accept, enqueue, assignment, and start rather than once up front — the state can + /// change between steps, so a single check would be a race, not a guarantee (brief Risk #2). + /// + /// With no runtime there are no matches, so this is trivially false today. That is a true answer, not a stub: + /// a user genuinely cannot be in a live match when no match can exist. + /// + Task IsInActiveMatchAsync(Guid userId, CancellationToken ct = default); +} + +/// +/// Module 7's live-delivery readiness, and Module 9's AI participants. Both are surfaced to the client through +/// dependencyReadiness so the UI renders an honest disabled control naming the owning module instead of +/// hardcoding "coming soon" copy it would later have to hunt down and remove. +/// +public interface IChatRuntimeProbe +{ + Task IsAvailableAsync(CancellationToken ct = default); +} + +public interface IAiParticipantProbe +{ + Task IsAvailableAsync(CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Lobbies/Services/LobbiesService.cs b/src/SimPle.Application/Lobbies/Services/LobbiesService.cs new file mode 100644 index 0000000..75d73fd --- /dev/null +++ b/src/SimPle.Application/Lobbies/Services/LobbiesService.cs @@ -0,0 +1,945 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.Common.Pagination; +using SimPle.Application.GameHost.Services; +using SimPle.Application.Lobbies.DTOs; +using SimPle.Application.Lobbies.Outbox; +using SimPle.Domain.Games; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Outbox; +using SimPle.Domain.Users; +using SimPle.Shared.Common; + +namespace SimPle.Application.Lobbies.Services; + +/// +/// The lobby command surface. Every mutation runs through , which re-runs the +/// whole read-decide-write delegate on contention (R3) — so the code below may be executed more than once per +/// request and must therefore make no decision it did not re-read. +/// +public sealed class LobbiesService : ILobbiesService +{ + private readonly ILobbyRepository _lobbies; + private readonly ILobbyCommandRunner _runner; + private readonly ILobbyCredentialHasher _hasher; + private readonly ILobbyJoinThrottle _joinThrottle; + private readonly IMatchRuntimeProbe _matchRuntime; + private readonly IChatRuntimeProbe _chatRuntime; + private readonly IAiParticipantProbe _aiParticipants; + private readonly IGameRegistry _engines; + private readonly IUserRepository _users; + private readonly IFileStorageService _storage; + private readonly StorageOptions _storageOptions; + private readonly LobbyCredentialOptions _credentialOptions; + private readonly TimeProvider _clock; + private readonly ILogger _logger; + + private const int DefaultLimit = 20; + private const int MaxLimit = 50; + + /// + /// Bounded retries when a freshly generated join code collides with a live one (23505 on the code digest + /// index). At 60 bits of entropy against a handful of concurrently open lobbies, a single collision is already + /// vanishingly unlikely and a second is not a thing that happens — but "vanishingly unlikely" is not "cannot", + /// and an unbounded loop on a bug in the generator would spin forever. + /// + private const int MaxCredentialAttempts = 5; + + public LobbiesService( + ILobbyRepository lobbies, + ILobbyCommandRunner runner, + ILobbyCredentialHasher hasher, + ILobbyJoinThrottle joinThrottle, + IMatchRuntimeProbe matchRuntime, + IChatRuntimeProbe chatRuntime, + IAiParticipantProbe aiParticipants, + IGameRegistry engines, + IUserRepository users, + IFileStorageService storage, + IOptions storageOptions, + IOptions credentialOptions, + TimeProvider clock, + ILogger logger) + { + _lobbies = lobbies; + _runner = runner; + _hasher = hasher; + _joinThrottle = joinThrottle; + _matchRuntime = matchRuntime; + _chatRuntime = chatRuntime; + _aiParticipants = aiParticipants; + _engines = engines; + _users = users; + _storage = storage; + _storageOptions = storageOptions.Value; + _credentialOptions = credentialOptions.Value; + _clock = clock; + _logger = logger; + } + + private DateTime NowUtc => _clock.GetUtcNow().UtcDateTime; + + // ── Create ─────────────────────────────────────────────────────────────── + + public Task> CreateAsync( + Guid actorUserId, CreateLobbyRequestDto request, CancellationToken ct = default) => + _runner.RunAsync(actorUserId, async token => + { + var actor = await _users.GetByIdAsync(actorUserId, token); + if (actor is null || actor.IsAccountSuspended()) + return Fail(LobbyErrors.Forbidden, "Your account cannot host a lobby."); + + var settingsResult = BuildSettings( + request.GameSlug, request.CapabilityVersion, request.Privacy, request.MaxPlayers, + request.TimeControlId, request.Rated, request.Region, request.SpectatorPolicy, + request.TieBreakRuleId, request.AiFillRequested, actor); + if (!settingsResult.IsSuccess) + return Result.Fail(settingsResult.Error!); + + var settings = settingsResult.Value!; + + var capability = await ValidateCapabilityAsync(settings, token); + if (!capability.IsSuccess) + return Result.Fail(capability.Error!); + + // Re-checked inside the transaction that inserts, not once up front — the state can change between a + // pre-flight check and the write, and only the transaction sees the truth (brief Risk #2). + var active = await EnsureNotAlreadyActiveAsync(actorUserId, token); + if (!active.IsSuccess) + return Result.Fail(active.Error!); + + var nowUtc = NowUtc; + var lobby = Lobby.Create(actorUserId, settings, correlationId: Guid.NewGuid(), nowUtc); + + var (credential, plaintext) = IssueCredential(lobby.Id, generation: 1, nowUtc); + + await _lobbies.AddLobbyAsync( + lobby, credential, + new[] { LobbyOutbox.LobbyCreatedEvent(lobby) }, + token); + + var dto = await ProjectAsync(lobby, actorUserId, token); + return Result.Ok(new CreateLobbyResultDto(dto, plaintext)); + }, ct); + + // ── Reads ──────────────────────────────────────────────────────────────── + + public async Task> GetAsync(Guid actorUserId, Guid lobbyId, CancellationToken ct = default) + { + var lobby = await _lobbies.GetByIdAsync(lobbyId, ct); + if (lobby is null) return NotFound(); + + // Authorization, re-evaluated on every read against the object's current membership — never against a + // token, a prior response, or the fact that the caller happens to hold the id. + var isMember = lobby.FindJoinedMember(actorUserId) is not null; + var isPubliclyVisible = lobby.Privacy == LobbyPrivacy.Public && lobby.State == LobbyState.Open; + + // A non-member looking at a private lobby gets the same 404 as a caller naming an id that never existed. + if (!isMember && !isPubliclyVisible) return NotFound(); + + return Result.Ok(await ProjectAsync(lobby, actorUserId, ct)); + } + + public async Task>> GetPublicAsync( + Guid actorUserId, int limit, string? cursor, CancellationToken ct = default) + { + if (limit < 1 || limit > MaxLimit) + return Fail>(LobbyErrors.ValidationFailed, "Page size must be between 1 and 50."); + + DateTime? afterCreatedAt = null; + Guid? afterId = null; + if (cursor is not null) + { + if (!Cursor.TryDecodeTimeId(cursor, out var createdAt, out var id)) + return Fail>(LobbyErrors.InvalidCursor, "The pagination cursor is invalid."); + afterCreatedAt = createdAt; + afterId = id; + } + + var nowUtc = NowUtc; + var rows = await _lobbies.GetPublicPageAsync(limit, afterCreatedAt, afterId, ct); + + // The keyset index cannot express "not expired", "not full", or "not blocked", so those three are applied + // here. That means a page can come back shorter than `limit` — which is correct and deliberate: topping it + // up would either leak that a hidden row existed (via the cursor skipping ahead) or require an unbounded + // scan. The cursor still advances past every row the query saw, so pagination never duplicates or stalls. + var hostIds = rows.Select(l => l.HostUserId).Distinct().ToList(); + var blockedHosts = await _lobbies.GetBlockedCounterpartsAsync(actorUserId, hostIds, ct); + var blocked = blockedHosts.ToHashSet(); + + var visible = rows + .Where(l => !l.IsExpired(nowUtc)) + .Where(l => l.JoinedCount < l.MaxPlayers) + .Where(l => !blocked.Contains(l.HostUserId)) + .ToList(); + + var users = await _lobbies.GetUsersAsync(visible.Select(l => l.HostUserId).Distinct().ToList(), ct); + + var items = new List(visible.Count); + foreach (var lobby in visible) + { + if (!users.TryGetValue(lobby.HostUserId, out var host)) continue; + items.Add(new LobbySummaryDto( + lobby.Id, lobby.GameSlug, lobby.MaxPlayers, lobby.JoinedCount, lobby.TimeControlId, + lobby.Rated, lobby.ResolvedRegion, lobby.SpectatorPolicy.ToString(), + await ToIdentityAsync(host, ct), lobby.CreatedAt, lobby.ExpiresAtUtc)); + } + + // The cursor is derived from the last row the *query* returned, not the last visible one — otherwise a + // trailing filtered-out row would be re-fetched forever and the page would never advance. + var next = rows.Count == limit + ? Cursor.EncodeTimeId(rows[^1].CreatedAt, rows[^1].Id) + : null; + + return Result>.Ok(new CursorPage(items, next)); + } + + public async Task>> GetMyInvitesAsync( + Guid actorUserId, CancellationToken ct = default) + { + var rows = await _lobbies.GetPendingInvitesForUserAsync(actorUserId, NowUtc, DefaultLimit, ct); + + var items = new List(rows.Count); + foreach (var (invite, lobby, inviter) in rows) + { + items.Add(new LobbyInviteDto( + invite.Id, invite.LobbyId, lobby.GameSlug, + await ToIdentityAsync(inviter, ct), invite.State.ToString(), invite.ExpiresAtUtc)); + } + + return Result>.Ok(items); + } + + public async Task> GetCapabilityProfileAsync( + string gameSlug, CancellationToken ct = default) + { + var profile = await _lobbies.GetActiveCapabilityProfileAsync(gameSlug, ct); + if (profile is null) + return Fail( + LobbyErrors.CapabilityNotFound, "That game has no active capability profile."); + + return Result.Ok(new GameCapabilityProfileDto( + profile.GameSlug, profile.CapabilityVersion, profile.MinPlayers, profile.MaxPlayers, + profile.AllowedModes, profile.TimeControls, profile.TieBreakRules, profile.SpectatorPolicies, + profile.RatedEligible, profile.AiFillEligible)); + } + + public async Task> GetMyActiveAsync(Guid actorUserId, CancellationToken ct = default) + { + var lobby = await _lobbies.GetActiveLobbyForUserAsync(actorUserId, ct); + if (lobby is not null) + return Result.Ok(new ActiveContextDto(await ProjectAsync(lobby, actorUserId, ct), null)); + + var ticketId = await _lobbies.GetActiveTicketIdForUserAsync(actorUserId, ct); + return Result.Ok(new ActiveContextDto(null, ticketId)); + } + + // ── Join by credential ─────────────────────────────────────────────────── + + public async Task> JoinByCredentialAsync( + Guid actorUserId, JoinLobbyRequestDto request, CancellationToken ct = default) + { + var hasCode = !string.IsNullOrWhiteSpace(request.Code); + var hasToken = !string.IsNullOrWhiteSpace(request.LinkToken); + var hasLobbyId = request.LobbyId is Guid id && id != Guid.Empty; + + var modeCount = (hasCode ? 1 : 0) + (hasToken ? 1 : 0) + (hasLobbyId ? 1 : 0); + if (modeCount != 1) + return Fail(LobbyErrors.ValidationFailed, "Supply exactly one of code, linkToken, or lobbyId."); + + // Joining by id skips the credential path entirely: a lobbyId is a resource identifier, not a secret, so + // there is nothing here for the join throttle to protect against. It is only ever honored for a lobby that + // is already Public and Open — the same visibility rule as the public browse listing and single-lobby + // read — so naming a private, foreign, closed, or expired lobby's id answers with the identical + // privacy-safe not-found used everywhere else in this module. + if (hasLobbyId) + { + return await _runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + var lobby = await _lobbies.GetForUpdateAsync(request.LobbyId!.Value, token); + if (lobby is null || lobby.Privacy != LobbyPrivacy.Public || lobby.IsTerminal || lobby.IsExpired(nowUtc)) + return NotFound(); + + return await SeatMemberAsync(lobby, actorUserId, nowUtc, extraEvents: null, token); + }, ct); + } + + // Failed attempts are throttled specifically (a wrong 60-bit code is cheap to spam), and the check runs + // before any digest work so a throttled attacker cannot even measure the comparison. + var retryAfter = await _joinThrottle.GetRetryAfterUtcAsync(actorUserId, ct); + if (retryAfter is DateTime until) + { + return Result.Fail(new Error( + LobbyErrors.RateLimitExceeded, "Too many failed join attempts. Please try again later.") + { + RetryAfterUtc = until, + }); + } + + var result = await _runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + + var credential = hasCode + ? await _lobbies.FindActiveByCodeDigestAsync(_hasher.HashCode(request.Code!), token) + : await _lobbies.FindActiveByLinkTokenDigestAsync(_hasher.HashLinkToken(request.LinkToken!), token); + + // Wrong, expired, rotated, revoked, and unknown all land here, and all answer identically. A caller + // learns only "that did not work" — never whether the lobby exists. + if (credential is null || !credential.CanRedeem(nowUtc)) + return CredentialInvalid(); + + var lobby = await _lobbies.GetForUpdateAsync(credential.LobbyId, token); + if (lobby is null || lobby.IsTerminal || lobby.IsExpired(nowUtc)) + return CredentialInvalid(); + + return await SeatMemberAsync(lobby, actorUserId, nowUtc, extraEvents: null, token); + }, ct); + + // Only a genuinely invalid credential feeds the throttle. A caller rejected for being already-active, or + // for landing in a full lobby, learned nothing about the code — counting those would let an unrelated + // failure lock a legitimate user out of joining. + if (!result.IsSuccess && result.Error!.Code == LobbyErrors.CredentialInvalid) + await _joinThrottle.RecordFailureAsync(actorUserId, ct); + else if (result.IsSuccess) + await _joinThrottle.ClearAsync(actorUserId, ct); + + return result; + } + + // ── Membership mutations ───────────────────────────────────────────────── + + public Task LeaveAsync(Guid actorUserId, Guid lobbyId, CancellationToken ct = default) => + Unit(_runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token); + if (lobby is null) return NotFound(); + + // A caller who is not a seated member must not be able to tell a real lobby from a fictional one. + if (lobby.FindJoinedMember(actorUserId) is null) return NotFound(); + + var leave = lobby.Leave(actorUserId, nowUtc); + if (leave.Outcome != LobbyOutcome.Ok) return MapOutcome(leave.Outcome); + + var events = new List { LobbyOutbox.MemberLeftEvent(lobby, actorUserId) }; + if (leave.NewHostUserId is Guid newHost) + events.Add(LobbyOutbox.HostTransferredEvent(lobby, newHost)); + if (leave.ClosedReason is not null) + events.Add(LobbyOutbox.LobbyClosedEvent(lobby)); + + await _lobbies.SaveAsync(events, token); + return Result.Ok(true); + }, ct)); + + public Task> SetReadinessAsync( + Guid actorUserId, Guid lobbyId, SetReadinessRequestDto request, CancellationToken ct = default) => + _runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token); + if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) return NotFound(); + + var stale = CheckRevision(lobby, request.ExpectedRevision); + if (stale is not null) return Result.Fail(stale); + + var outcome = lobby.SetReadiness(actorUserId, request.IsReady, nowUtc); + if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome); + + await _lobbies.SaveAsync(Array.Empty(), token); + return Result.Ok(await ProjectAsync(lobby, actorUserId, token)); + }, ct); + + public Task> UpdateSettingsAsync( + Guid actorUserId, Guid lobbyId, UpdateLobbySettingsRequestDto request, CancellationToken ct = default) => + _runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token); + if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) return NotFound(); + + var stale = CheckRevision(lobby, request.ExpectedRevision); + if (stale is not null) return Result.Fail(stale); + + var actor = await _users.GetByIdAsync(actorUserId, token); + if (actor is null) return NotFound(); + + var settingsResult = BuildSettings( + request.GameSlug, request.CapabilityVersion, request.Privacy, request.MaxPlayers, + request.TimeControlId, request.Rated, request.Region, request.SpectatorPolicy, + request.TieBreakRuleId, request.AiFillRequested, actor); + if (!settingsResult.IsSuccess) return Result.Fail(settingsResult.Error!); + + // Capability is re-validated on every change, not just at create — a profile can be deactivated + // underneath a live lobby, which is exactly what makes "capability disabled after create" a real path. + var capability = await ValidateCapabilityAsync(settingsResult.Value!, token); + if (!capability.IsSuccess) return Result.Fail(capability.Error!); + + var outcome = lobby.ChangeSettings(actorUserId, settingsResult.Value!, nowUtc); + if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome); + + await _lobbies.SaveAsync(new[] { LobbyOutbox.SettingsChangedEvent(lobby) }, token); + return Result.Ok(await ProjectAsync(lobby, actorUserId, token)); + }, ct); + + public Task> KickAsync( + Guid actorUserId, Guid lobbyId, KickMemberRequestDto request, CancellationToken ct = default) => + _runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token); + if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) return NotFound(); + + var stale = CheckRevision(lobby, request.ExpectedRevision); + if (stale is not null) return Result.Fail(stale); + + var outcome = lobby.Kick(actorUserId, request.TargetUserId, nowUtc); + if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome); + + await _lobbies.SaveAsync( + new[] { LobbyOutbox.MemberKickedEvent(lobby, request.TargetUserId, actorUserId) }, token); + return Result.Ok(await ProjectAsync(lobby, actorUserId, token)); + }, ct); + + // ── Credential rotation ────────────────────────────────────────────────── + + public Task> RotateCredentialAsync( + Guid actorUserId, Guid lobbyId, CancellationToken ct = default) => + _runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token); + if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) + return NotFound(); + + if (!lobby.IsHost(actorUserId)) + return Fail(LobbyErrors.Forbidden, "Only the host can rotate the join credential."); + if (lobby.IsTerminal) return MapOutcome(LobbyOutcome.Closed); + if (lobby.IsExpired(nowUtc)) return MapOutcome(LobbyOutcome.Expired); + + var outgoing = await _lobbies.GetActiveCredentialAsync(lobbyId, token); + if (outgoing is null) return NotFound(); + + outgoing.MarkRotated(nowUtc); + var (incoming, plaintext) = IssueCredential(lobbyId, outgoing.Generation + 1, nowUtc); + + // The old value is dead the instant this commits — the rotated row can no longer be redeemed, and the + // new row is a different secret entirely. There is no window in which both work. + await _lobbies.RotateCredentialAsync( + outgoing, incoming, + new[] { LobbyOutbox.CredentialRotatedEvent(lobby, incoming.Generation) }, + token); + + return Result.Ok(plaintext); + }, ct); + + // ── Invites ────────────────────────────────────────────────────────────── + + public Task> CreateInviteAsync( + Guid actorUserId, Guid lobbyId, CreateInviteRequestDto request, CancellationToken ct = default) => + _runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token); + if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) return NotFound(); + + if (!lobby.IsHost(actorUserId)) + return Fail(LobbyErrors.Forbidden, "Only the host can invite."); + if (lobby.IsTerminal) return MapOutcome(LobbyOutcome.Closed); + if (lobby.IsExpired(nowUtc)) return MapOutcome(LobbyOutcome.Expired); + + if (request.InviteeUserId == actorUserId) + return Fail(LobbyErrors.InvalidTarget, "You cannot invite yourself."); + + var invitee = await _users.GetByIdAsync(request.InviteeUserId, token); + if (invitee is null || invitee.IsAccountSuspended()) + return Fail(LobbyErrors.InvalidTarget, "That player cannot be invited."); + + // Friends-only. This is what stops the invite endpoint being a way to reveal a private lobby (and its + // existence) to an arbitrary user id. + if (!await _lobbies.AreFriendsAsync(actorUserId, request.InviteeUserId, token)) + return Fail(LobbyErrors.InvalidTarget, "You can only invite friends."); + + var blocked = await _lobbies.GetBlockedCounterpartsAsync( + request.InviteeUserId, RosterOf(lobby), token); + if (blocked.Count > 0) + return Fail(LobbyErrors.Blocked, "A block exists between that player and this lobby."); + + // Re-inviting someone who already holds a live invite replays it rather than stacking duplicates. + var existing = await _lobbies.GetPendingInviteAsync(lobbyId, request.InviteeUserId, token); + if (existing is not null && existing.CanAccept(nowUtc)) + { + var inviterForExisting = await _users.GetByIdAsync(actorUserId, token); + return Result.Ok(new LobbyInviteDto( + existing.Id, lobbyId, lobby.GameSlug, await ToIdentityAsync(inviterForExisting!, token), + existing.State.ToString(), existing.ExpiresAtUtc)); + } + + var invite = LobbyInvite.Create(lobbyId, actorUserId, request.InviteeUserId, nowUtc); + await _lobbies.AddInviteAsync(invite, new[] { LobbyOutbox.InviteCreatedEvent(invite) }, token); + + var inviter = await _users.GetByIdAsync(actorUserId, token); + return Result.Ok(new LobbyInviteDto( + invite.Id, lobbyId, lobby.GameSlug, await ToIdentityAsync(inviter!, token), + invite.State.ToString(), invite.ExpiresAtUtc)); + }, ct); + + public Task RevokeInviteAsync( + Guid actorUserId, Guid lobbyId, Guid inviteId, CancellationToken ct = default) => + Unit(_runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token); + if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) return NotFound(); + if (!lobby.IsHost(actorUserId)) + return Fail(LobbyErrors.Forbidden, "Only the host can revoke an invite."); + + var invite = await _lobbies.GetInviteForUpdateAsync(inviteId, token); + // An invite id belonging to a different lobby is a 404, not a 403 — the caller must not learn it exists. + if (invite is null || invite.LobbyId != lobbyId) return NotFound(); + + var outcome = invite.Revoke(nowUtc); + if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome); + + await _lobbies.SaveAsync(new[] { LobbyOutbox.InviteRevokedEvent(invite) }, token); + return Result.Ok(true); + }, ct)); + + public Task> AcceptInviteAsync( + Guid actorUserId, Guid inviteId, CancellationToken ct = default) => + _runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + var invite = await _lobbies.GetInviteForUpdateAsync(inviteId, token); + + // Another user's invite id is indistinguishable from one that never existed. + if (invite is null || invite.InviteeUserId != actorUserId) return NotFound(); + + if (!invite.CanAccept(nowUtc)) + { + return invite.IsExpired(nowUtc) + ? MapOutcome(LobbyOutcome.Expired) + : MapOutcome(LobbyOutcome.Closed); + } + + var lobby = await _lobbies.GetForUpdateAsync(invite.LobbyId, token); + if (lobby is null) return NotFound(); + if (lobby.IsTerminal) return MapOutcome(LobbyOutcome.Closed); + if (lobby.IsExpired(nowUtc)) return MapOutcome(LobbyOutcome.Expired); + + var accept = invite.Accept(nowUtc); + if (accept != LobbyOutcome.Ok) return MapOutcome(accept); + + // Accepting seats the member under exactly the invariants a credential join enforces — an invite is a + // permission to try, never a bypass of blocks, capacity, or one-active-lobby-or-ticket. + return await SeatMemberAsync( + lobby, actorUserId, nowUtc, + extraEvents: new[] { LobbyOutbox.InviteAcceptedEvent(invite) }, + token); + }, ct); + + public Task DeclineInviteAsync(Guid actorUserId, Guid inviteId, CancellationToken ct = default) => + Unit(_runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + var invite = await _lobbies.GetInviteForUpdateAsync(inviteId, token); + if (invite is null || invite.InviteeUserId != actorUserId) return NotFound(); + + // Declining is modelled as a revoke of the same pending invite: the lifecycle has one terminal + // "withdrawn" state, and who withdrew it is already recorded by the actor on the event. + var outcome = invite.Revoke(nowUtc); + if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome); + + await _lobbies.SaveAsync(new[] { LobbyOutbox.InviteRevokedEvent(invite) }, token); + return Result.Ok(true); + }, ct)); + + // ── Start ──────────────────────────────────────────────────────────────── + + public Task> StartAsync( + Guid actorUserId, Guid lobbyId, StartLobbyRequestDto request, CancellationToken ct = default) => + _runner.RunAsync(actorUserId, async token => + { + var nowUtc = NowUtc; + var lobby = await _lobbies.GetForUpdateAsync(lobbyId, token); + if (lobby is null || lobby.FindJoinedMember(actorUserId) is null) + return NotFound(); + + if (!lobby.IsHost(actorUserId)) + return Fail(LobbyErrors.Forbidden, "Only the host can start the match."); + + // An already-committed start replays rather than minting a second request. Checked before the + // revision guard: a client retrying the identical command must get its own result back, not a + // stale-revision error caused by its own first attempt having bumped the revision. + var replay = await _lobbies.GetStartRequestByIdempotencyKeyAsync(lobbyId, request.IdempotencyKey, token); + if (replay is not null) + { + return Result.Ok(new StartLobbyResultDto( + lobbyId, replay.MatchRequestId, lobby.State.ToString(), lobby.Revision)); + } + + var stale = CheckRevision(lobby, request.ExpectedRevision); + if (stale is not null) return Result.Fail(stale); + + // ── The M8 gate, checked FIRST (deliberate ordering; see below) ── + // + // The spec lists the engine (M5) check before the runtime (M8) check. With zero engines installed, + // following that order literally would answer `Lobbies.CapabilityDisabled` — blaming *this lobby's + // configuration* for what is actually a platform-wide absence of any match runtime at all. That is a + // misleading error, and it contradicts the spec's own normative promise that "until M8 registers a + // consumer, Start returns Lobbies.MatchRuntimeUnavailable". The honest answer wins. + if (!await _matchRuntime.IsAvailableAsync(token)) + { + _logger.LogInformation( + "Security: Start rejected, no match runtime. ActorId={ActorId} LobbyId={LobbyId} Action={Action} Result={Result}", + actorUserId, lobbyId, "LobbyStart", "MatchRuntimeUnavailable"); + return Fail( + LobbyErrors.MatchRuntimeUnavailable, + "Matches cannot start yet — the match runtime arrives with Module 8."); + } + + var roster = RosterOf(lobby); + + // Blocks are re-checked at start, not trusted from join time: a block created while the lobby filled + // must stop the match, and only a check here sees it. + foreach (var member in roster) + { + var blocked = await _lobbies.GetBlockedCounterpartsAsync(member, roster, token); + if (blocked.Count > 0) + return Fail(LobbyErrors.Blocked, "A block exists between two members of this lobby."); + } + + foreach (var member in roster) + { + if (await _matchRuntime.IsInActiveMatchAsync(member, token)) + return Fail(LobbyErrors.AlreadyActive, "A member is already in a live match."); + } + + var capability = await ValidateCapabilityAsync(lobby.CurrentSettings, token); + if (!capability.IsSuccess) return Result.Fail(capability.Error!); + + // M5 engine availability. The lobby pins a *capability* version, not an engine version — the two are + // different concepts and M6 has no field for the latter. Until M8 exists to instantiate a specific + // engine, the answerable question is "can any installed engine host this game at all", which is what + // this asks. Selecting the exact (slug, engineVersion) is M8's, since M8 is what records it on the + // match it creates. + if (!_engines.RegisteredDefinitions.Any(d => string.Equals(d.Slug, lobby.GameSlug, StringComparison.Ordinal))) + { + return Fail( + LobbyErrors.CapabilityDisabled, "No engine is installed for this game."); + } + + var outcome = lobby.BeginStarting(actorUserId, nowUtc); + if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome); + + // The state change and the event commit together or not at all. A committed MatchRequestedV1 is a + // durable *request* — not a match (Risk #6). + var startRequest = LobbyStartRequest.Open( + lobbyId, lobby.Revision, matchRequestId: Guid.NewGuid(), + request.IdempotencyKey, lobby.CorrelationId); + + await _lobbies.AddStartRequestAsync( + startRequest, + new[] { LobbyOutbox.MatchRequestedEvent(lobby, startRequest) }, + token); + + return Result.Ok(new StartLobbyResultDto( + lobbyId, startRequest.MatchRequestId, lobby.State.ToString(), lobby.Revision)); + }, ct); + + public Task> CreateRematchLobbyAsync( + Guid actorUserId, Guid terminalMatchId, CancellationToken ct = default) + { + // M8 owns matches. There is no match store to read a terminal match's pinned settings and participants + // from, so there is nothing honest to build a rematch out of. Fabricating one from defaults would produce + // a lobby that silently is not the rematch it claims to be. + _logger.LogInformation( + "Security: Rematch rejected, no match runtime. ActorId={ActorId} MatchId={MatchId} Action={Action} Result={Result}", + actorUserId, terminalMatchId, "LobbyRematch", "MatchRuntimeUnavailable"); + + return Task.FromResult(Fail( + LobbyErrors.MatchRuntimeUnavailable, + "Rematch arrives with Module 8, which owns match records.")); + } + + // ── Shared command logic ───────────────────────────────────────────────── + + /// + /// Seats an actor in a lobby they are entitled to reach. Shared verbatim by credential-join and invite-accept + /// so the two can never drift apart on blocks, capacity, or the one-active-lobby-or-ticket rule. + /// + private async Task> SeatMemberAsync( + Lobby lobby, Guid actorUserId, DateTime nowUtc, IReadOnlyList? extraEvents, CancellationToken ct) + { + // Already seated: idempotent, so a double-submitted join returns the lobby rather than an error. + if (lobby.FindJoinedMember(actorUserId) is not null) + return Result.Ok(await ProjectAsync(lobby, actorUserId, ct)); + + var actor = await _users.GetByIdAsync(actorUserId, ct); + if (actor is null || actor.IsAccountSuspended()) + return Fail(LobbyErrors.Forbidden, "Your account cannot join a lobby."); + + var active = await EnsureNotAlreadyActiveAsync(actorUserId, ct); + if (!active.IsSuccess) return Result.Fail(active.Error!); + + var blocked = await _lobbies.GetBlockedCounterpartsAsync(actorUserId, RosterOf(lobby), ct); + if (blocked.Count > 0) + return Fail(LobbyErrors.Blocked, "A block exists between you and a member of this lobby."); + + if (await _matchRuntime.IsInActiveMatchAsync(actorUserId, ct)) + return Fail(LobbyErrors.AlreadyActive, "You are already in a live match."); + + var capability = await ValidateCapabilityAsync(lobby.CurrentSettings, ct); + if (!capability.IsSuccess) return Result.Fail(capability.Error!); + + // Capacity as the aggregate sees it. This is the *optimistic* half: two racers can both pass it. What + // actually decides the last seat is the lobby's xmin row version — both bump Revision, the loser's UPDATE + // affects zero rows, and the command runner re-runs the whole delegate, which re-reads and lands on Full + // below. That is why this method must never cache state across a retry. + var outcome = lobby.Join(actorUserId, nowUtc); + if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome); + + var events = new List { LobbyOutbox.MemberJoinedEvent(lobby, actorUserId) }; + if (extraEvents is not null) events.AddRange(extraEvents); + + await _lobbies.SaveAsync(events, ct); + return Result.Ok(await ProjectAsync(lobby, actorUserId, ct)); + } + + /// + /// The cross-table invariant: a user holds at most one joined lobby or one nonterminal + /// ticket, never both and never two. + /// + /// Two filtered unique indexes cannot see each other, so this check is what covers the gap — and it is only + /// sound because the command runner holds a transaction-scoped advisory lock on the actor, serializing their + /// own seat-acquiring commands against each other (brief Risk #2). Without that lock a concurrent join and + /// enqueue would both read "nothing active" and both commit. + /// + private async Task EnsureNotAlreadyActiveAsync(Guid userId, CancellationToken ct) + { + var lobby = await _lobbies.GetActiveLobbyForUserAsync(userId, ct); + if (lobby is not null) + return Result.Fail(LobbyErrors.AlreadyActive, "You are already in a lobby."); + + var ticketId = await _lobbies.GetActiveTicketIdForUserAsync(userId, ct); + if (ticketId is not null) + return Result.Fail(LobbyErrors.AlreadyActive, "You are already in the Quick Match queue."); + + return Result.Ok(); + } + + /// + /// Validates settings against the pinned capability profile, M4's catalog, and the platform allow-lists — + /// always before persistence, which is what turns "capability disabled after create" into a real, + /// testable path rather than a lobby nobody can start. + /// + private async Task ValidateCapabilityAsync(LobbySettings settings, CancellationToken ct) + { + var profile = await _lobbies.GetCapabilityProfileAsync(settings.GameSlug, settings.CapabilityVersion, ct); + if (profile is null) + return Result.Fail(LobbyErrors.CapabilityDisabled, "That game/capability version is not available."); + + var permits = profile.Permits(settings); + if (!permits.Allowed) + return Result.Fail(LobbyErrors.CapabilityDisabled, permits.Reason!); + + var game = await _lobbies.GetGameAsync(settings.GameSlug, ct); + if (game is null || game.Lifecycle != GameLifecycle.Available) + return Result.Fail(LobbyErrors.CapabilityDisabled, "That game is not available to play right now."); + + // Drift between M6's profile and M4's catalog is a data bug, not a user error — but it fails closed all + // the same, because a lobby it let through would be one M5's engine cannot host. + var modes = await _lobbies.GetGameModesAsync(game.Id, ct); + var drift = profile.ContradictsCatalog(game.MinPlayers, game.MaxPlayers, modes); + if (!drift.Allowed) + { + _logger.LogWarning( + "Capability profile drift for {GameSlug} v{CapabilityVersion}: {Reason}", + settings.GameSlug, settings.CapabilityVersion, drift.Reason); + return Result.Fail(LobbyErrors.CapabilityDisabled, "That game's configuration is temporarily unavailable."); + } + + return Result.Ok(); + } + + /// + /// Turns a request into validated domain settings. Every enum and allow-list value is parsed here, so an + /// unparseable value is a 400 rather than an exception from deep inside the aggregate. + /// + private Result BuildSettings( + string gameSlug, int capabilityVersion, string privacy, int maxPlayers, string timeControlId, + bool rated, string? region, string spectatorPolicy, string tieBreakRuleId, bool aiFillRequested, + User actor) + { + if (string.IsNullOrWhiteSpace(gameSlug)) + return Fail(LobbyErrors.ValidationFailed, "gameSlug is required."); + if (capabilityVersion < 1) + return Fail(LobbyErrors.ValidationFailed, "capabilityVersion must be at least 1."); + if (maxPlayers < 2 || maxPlayers > 8) + return Fail(LobbyErrors.ValidationFailed, "maxPlayers must be between 2 and 8."); + + if (!Enum.TryParse(privacy, ignoreCase: false, out var parsedPrivacy)) + return Fail(LobbyErrors.ValidationFailed, "privacy must be Public or Private."); + if (!Enum.TryParse(spectatorPolicy, ignoreCase: false, out var parsedSpectators)) + return Fail(LobbyErrors.ValidationFailed, "spectatorPolicy must be Anyone, FriendsOnly, or Disabled."); + + if (!LobbyAllowLists.TimeControls.Contains(timeControlId)) + return Fail(LobbyErrors.ValidationFailed, "Unknown time control."); + if (!LobbyAllowLists.TieBreakRules.Contains(tieBreakRuleId)) + return Fail(LobbyErrors.ValidationFailed, "Unknown tie-break rule."); + + // A client-supplied region that is not allow-listed is not an error — it falls through to the profile and + // then the deployment default. Rejecting it would leak the region allow-list; silently partitioning the + // matchmaking queue on it would be far worse. + var resolvedRegion = LobbyRegion.Resolve(region, actor.Region, _credentialOptions.DefaultRegion); + + return Result.Ok(new LobbySettings( + gameSlug, capabilityVersion, parsedPrivacy, maxPlayers, timeControlId, rated, + resolvedRegion, parsedSpectators, tieBreakRuleId, aiFillRequested)); + } + + /// + /// Mints a plaintext code + link token, hashes both, and returns the entity (digests only) alongside the + /// plaintext the caller will hand back exactly once. The plaintext is never stored, logged, or evented. + /// + private (LobbyJoinCredential Entity, LobbyCredentialDto Plaintext) IssueCredential( + Guid lobbyId, int generation, DateTime nowUtc) + { + var code = LobbyCredentialFormat.NewCode(); + var linkToken = LobbyCredentialFormat.NewLinkToken(); + + var entity = LobbyJoinCredential.Issue( + lobbyId, _hasher.HashCode(code), _hasher.HashLinkToken(linkToken), generation, nowUtc); + + return (entity, new LobbyCredentialDto(code, linkToken, generation, entity.ExpiresAtUtc)); + } + + // ── Projection ─────────────────────────────────────────────────────────── + + private async Task ProjectAsync(Lobby lobby, Guid viewerId, CancellationToken ct) + { + var nowUtc = NowUtc; + var roster = RosterOf(lobby); + var users = await _lobbies.GetUsersAsync(roster, ct); + + var seats = new List(roster.Count); + foreach (var member in lobby.JoinedMembers) + { + if (!users.TryGetValue(member.UserId, out var user)) continue; + seats.Add(new LobbySeatDto( + await ToIdentityAsync(user, ct), + IsHost: lobby.IsHost(member.UserId), + member.IsReady, + member.JoinedAtUtc)); + } + + var readiness = new DependencyReadinessDto( + Chat: await _chatRuntime.IsAvailableAsync(ct), + MatchRuntime: await _matchRuntime.IsAvailableAsync(ct), + AiParticipants: await _aiParticipants.IsAvailableAsync(ct)); + + return new LobbyDto( + lobby.Id, lobby.GameSlug, lobby.CapabilityVersion, lobby.Privacy.ToString(), lobby.MaxPlayers, + lobby.TimeControlId, lobby.Rated, lobby.ResolvedRegion, lobby.SpectatorPolicy.ToString(), + lobby.TieBreakRuleId, lobby.AiFillRequested, lobby.State.ToString(), lobby.Revision, + lobby.ExpiresAtUtc, lobby.ClosedReason?.ToString(), lobby.HostUserId, + seats, + AllowedActionsFor(lobby, viewerId, readiness, nowUtc), + readiness); + } + + /// + /// What this viewer may actually do right now. Derived server-side from the same state the commands enforce, + /// so the client cannot offer a control the server will reject — and, critically, start is absent while + /// no match runtime exists, which is what makes the disabled Start button honest rather than decorative. + /// + private static IReadOnlyList AllowedActionsFor( + Lobby lobby, Guid viewerId, DependencyReadinessDto readiness, DateTime nowUtc) + { + var actions = new List(); + if (lobby.IsTerminal || lobby.IsExpired(nowUtc)) return actions; + + var member = lobby.FindJoinedMember(viewerId); + if (member is null) return actions; + + actions.Add(LobbyActions.Leave); + + // The host is implicitly ready and cannot un-ready, so `ready` is not offered to them. + if (!lobby.IsHost(viewerId)) actions.Add(LobbyActions.Ready); + + if (lobby.IsHost(viewerId)) + { + actions.Add(LobbyActions.Settings); + actions.Add(LobbyActions.Invite); + actions.Add(LobbyActions.RotateCredential); + if (lobby.JoinedCount > 1) actions.Add(LobbyActions.Kick); + + // Start appears only when it would actually succeed: the domain preconditions hold AND M8 exists. + if (readiness.MatchRuntime && lobby.CanStart(nowUtc)) + actions.Add(LobbyActions.Start); + } + + return actions; + } + + private async Task ToIdentityAsync(User user, CancellationToken ct) => new( + user.Id, user.Username, user.DisplayName, user.Initials, user.Color, + await BuildAvatarUrlAsync(user.AvatarObjectKey, user.AvatarUrl, ct), + user.ProfileType.ToString()); + + private async Task BuildAvatarUrlAsync(string? objectKey, string? fallbackUrl, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(objectKey)) + { + var expiry = TimeSpan.FromMinutes(_storageOptions.ReadUrlExpiryMinutes); + return await _storage.CreatePresignedReadUrlAsync(objectKey, expiry, ct); + } + return fallbackUrl; + } + + private static List RosterOf(Lobby lobby) => + lobby.JoinedMembers.Select(m => m.UserId).ToList(); + + // ── Result helpers ─────────────────────────────────────────────────────── + + private static Error? CheckRevision(Lobby lobby, int expectedRevision) => + lobby.Revision == expectedRevision + ? null + : new Error(LobbyErrors.StaleRevision, + $"The lobby has changed since you loaded it (current revision {lobby.Revision})."); + + /// + /// Maps a domain outcome to the error catalogue. becomes the + /// privacy-safe not-found, never a 403 — a caller who is not a member must not learn the lobby exists. + /// + private static Result MapOutcome(LobbyOutcome outcome) => outcome switch + { + LobbyOutcome.Closed => Fail(LobbyErrors.Closed, "This lobby is closed."), + LobbyOutcome.Expired => Fail(LobbyErrors.Expired, "This lobby has expired."), + LobbyOutcome.Full => Fail(LobbyErrors.Full, "This lobby is full."), + LobbyOutcome.Forbidden => Fail(LobbyErrors.Forbidden, "Only the host can do that."), + LobbyOutcome.NotMember => NotFound(), + LobbyOutcome.AlreadyJoined => Fail(LobbyErrors.AlreadyActive, "You are already in this lobby."), + LobbyOutcome.InvalidTarget => Fail(LobbyErrors.InvalidTarget, "That is not a valid target."), + LobbyOutcome.NotStartable => Fail(LobbyErrors.NotStartable, "This lobby cannot start yet."), + _ => Fail(LobbyErrors.ValidationFailed, "That action is not allowed."), + }; + + private static Result NotFound() => + Fail(LobbyErrors.NotFound, "Lobby not found."); + + private static Result CredentialInvalid() => + Fail(LobbyErrors.CredentialInvalid, "That join code or link is not valid."); + + private static Result Fail(string code, string message) => + Result.Fail(code, message); + + private static async Task Unit(Task> inner) + { + var result = await inner; + return result.IsSuccess ? Result.Ok() : Result.Fail(result.Error!); + } +} diff --git a/src/SimPle.Application/Matchmaking/DTOs/MatchmakingDtos.cs b/src/SimPle.Application/Matchmaking/DTOs/MatchmakingDtos.cs new file mode 100644 index 0000000..ab80e7d --- /dev/null +++ b/src/SimPle.Application/Matchmaking/DTOs/MatchmakingDtos.cs @@ -0,0 +1,91 @@ +using SimPle.Application.Lobbies.DTOs; + +namespace SimPle.Application.Matchmaking.DTOs; + +/// +/// Enqueue a Quick Match ticket. +/// +/// There is deliberately no userId and no rating: the actor is always the JWT sub claim, and +/// the rating is a server-side snapshot. A client-supplied rating would let a caller pick their own opponents. +/// +/// There is also no idempotency key, and that is not an omission — the approved spec's data model gives +/// MatchmakingTicket no such column (only LobbyStartRequest has one). A retried enqueue is instead +/// made safe by the pool key itself: re-enqueueing the identical ticket returns the live one rather than minting a +/// second (see MatchmakingService.EnqueueAsync), which is the same idempotency a double-submitted join gets. +/// +public sealed record CreateTicketRequestDto( + string GameSlug, + int CapabilityVersion, + string Mode, + int PlayerCount, + string TimeControlId, + bool Rated, + string? Region); + +/// +/// A ticket as its owner sees it. Polled every 2 seconds until Module 7 supplies live delivery. +/// +/// is the rating half-width the ticket will accept right now (±100 → ±200 → +/// ±400 as it ages), so the queue UI can show a search visibly widening instead of an opaque spinner. It is null +/// once the ticket has reached its deadline — an expired ticket has no band, it has a terminal outcome. +/// +/// is the honest part. Until Module 8 registers a match runtime, the matching +/// worker does not run at all (a committed assignment with nobody to consume it would mark a ticket Matched +/// and send the player to a room that cannot exist — precisely the fabrication Risk #6 forbids). So a Phase-1 +/// ticket queues, widens, and honestly times out. The client reads that from the probe rather than hardcoding it. +/// +public sealed record TicketDto( + Guid TicketId, + string GameSlug, + int CapabilityVersion, + string Mode, + int PlayerCount, + string TimeControlId, + bool Rated, + string ResolvedRegion, + int Rating, + string RatingSourceVersion, + string State, + DateTime EnqueuedAtUtc, + DateTime DeadlineAtUtc, + int? CurrentBand, + TicketAssignmentDto? Assignment, + DependencyReadinessDto DependencyReadiness); + +/// +/// The handoff a matched ticket points at. A is a durable request — never a +/// created match (Risk #6). Nothing in Module 6 may navigate a client to a room on the strength of it; only +/// Module 8's MatchCreatedV1 can do that. +/// +public sealed record TicketAssignmentDto( + Guid MatchRequestId, + Guid GroupId); + +/// +/// Module 6's matchmaking error catalogue, in the codebase's PascalCase house style (reconciliation +/// R1). The lobby half lives in LobbyErrors; enqueue reuses it for the errors the two +/// genuinely share (capability, blocks, concurrency, validation) rather than minting parallel codes that mean the +/// same thing. +/// +public static class MatchmakingErrors +{ + /// + /// Privacy-safe. Another user's ticket id lands here, not on a 403 — a 403 would confirm the + /// id exists (OWASP API1:2023). Missing and foreign ticket ids are indistinguishable. + /// + public const string TicketNotFound = "Matchmaking.TicketNotFound"; + + /// The one-active-lobby-or-ticket invariant, from the queue's side. + public const string AlreadyQueued = "Matchmaking.AlreadyQueued"; + + public const string TicketExpired = "Matchmaking.TicketExpired"; + + /// + /// Queue execution is disabled while no match runtime is registered. + /// + /// Deliberately not returned by enqueue: the spec requires ticket create/status/cancel and + /// expiry to remain fully functional without M8, and they do. It becomes reachable when M8 exists and its + /// handoff can fail — the constant is defined now so the frontend's error mapping does not have to change then. + /// + public const string RuntimeUnavailable = "Matchmaking.RuntimeUnavailable"; +} diff --git a/src/SimPle.Application/Matchmaking/Outbox/MatchmakingOutbox.cs b/src/SimPle.Application/Matchmaking/Outbox/MatchmakingOutbox.cs new file mode 100644 index 0000000..a9d181a --- /dev/null +++ b/src/SimPle.Application/Matchmaking/Outbox/MatchmakingOutbox.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Matchmaking.Outbox; + +/// +/// Module 6's matchmaking integration events, mirroring . +/// Ids only — no rating, no region, no profile snapshot. +/// +/// +/// A matched proposal emits exactly one MatchRequestedV1 for the whole group, not one per +/// ticket. The group is the thing M8 creates a match from; N events would either make M8 build N matches for one +/// proposal or force it to de-duplicate them itself. +/// +/// +/// +/// The aggregate is the group, and AggregateDomainVersion is pinned to 1 because a group is created +/// once and never transitions. That makes the outbox's unique (AggregateId, EventType, AggregateDomainVersion) +/// index a real idempotency guard here: if a worker's transaction is retried and re-stages the same group's event, +/// the index rejects the duplicate rather than handing M8 two requests for one proposal. +/// +/// +/// +/// It is the same MatchRequestedV1 event type the lobby Start path emits, deliberately: M8 has one consumer +/// for "somebody wants a match", and the payload's source tells it whether the request came from a lobby or +/// the queue. A committed row is a durable request, never a created match (Risk #6). +/// +/// +public static class MatchmakingOutbox +{ + public const int EventVersion = 1; + + private const string GroupAggregate = "MatchmakingGroup"; + + /// The same wire name the lobby Start path uses — one M8 consumer, two producers. + public const string MatchRequested = "MatchRequestedV1"; + + /// Distinguishes a queue-originated request from a lobby-originated one, for M8's benefit. + public const string QueueSource = "matchmaking"; + + public static OutboxMessage MatchRequestedEvent( + Guid groupId, + Guid matchRequestId, + string gameSlug, + int capabilityVersion, + IReadOnlyList ticketIds) => + OutboxMessage.Create( + GroupAggregate, groupId, MatchRequested, EventVersion, + aggregateDomainVersion: 1, requestCycleId: 1, + JsonSerializer.Serialize(new + { + source = QueueSource, + matchRequestId, + groupId, + gameSlug, + capabilityVersion, + // Ticket ids, not user ids: M8 re-reads the tickets it names rather than trusting a roster snapshot + // that could already be stale by the time the event is delivered. + ticketIds, + })); +} diff --git a/src/SimPle.Application/Matchmaking/Services/BlockedPairs.cs b/src/SimPle.Application/Matchmaking/Services/BlockedPairs.cs new file mode 100644 index 0000000..49bba4b --- /dev/null +++ b/src/SimPle.Application/Matchmaking/Services/BlockedPairs.cs @@ -0,0 +1,35 @@ +namespace SimPle.Application.Matchmaking.Services; + +/// +/// The block relationships (M3) among one worker batch's ticket owners, as an unordered-pair lookup. +/// +/// +/// Blocks are checked before queue assignment, not after a proposal is formed. That distinction +/// matters: rejecting a formed group would leave the same two tickets to be re-proposed on every subsequent cycle, +/// so a single blocked pair at the head of the queue could stall matching for both of them until they timed out. +/// Excluding the pair from the candidate pool instead lets each ticket match with somebody else immediately. +/// +/// +/// +/// A block is symmetric in its effect here — it does not matter who blocked whom, the two must not be placed in a +/// match proposal together — so the key is order-independent. +/// +/// +public sealed class BlockedPairs +{ + public static readonly BlockedPairs None = new(Array.Empty<(Guid, Guid)>()); + + private readonly HashSet<(Guid, Guid)> _pairs; + + public BlockedPairs(IEnumerable<(Guid A, Guid B)> pairs) + { + _pairs = pairs.Select(p => Normalize(p.A, p.B)).ToHashSet(); + } + + public bool AreBlocked(Guid a, Guid b) => _pairs.Contains(Normalize(a, b)); + + public int Count => _pairs.Count; + + private static (Guid, Guid) Normalize(Guid a, Guid b) => + a.CompareTo(b) <= 0 ? (a, b) : (b, a); +} diff --git a/src/SimPle.Application/Matchmaking/Services/IMatchmakingCoordinator.cs b/src/SimPle.Application/Matchmaking/Services/IMatchmakingCoordinator.cs new file mode 100644 index 0000000..5cf31fb --- /dev/null +++ b/src/SimPle.Application/Matchmaking/Services/IMatchmakingCoordinator.cs @@ -0,0 +1,45 @@ +namespace SimPle.Application.Matchmaking.Services; + +/// +/// What one matching cycle did. Every field is a count or a duration — never an identifier — so it can be logged +/// and turned into the module's matchmaking-queue-age / matchmaking-claim-conflict / +/// matchmaking-worker-failure signals without leaking who is in the queue. +/// +public sealed record MatchmakingCycleResult( + bool Executed, + int TicketsClaimed, + int ProposalsFormed, + int TicketsMatched, + TimeSpan? OldestQueuedAge) +{ + /// + /// The cycle did not run because no match runtime is registered. This is the normal state before Module 8 — + /// not a failure, and not something to alert on. + /// + public static MatchmakingCycleResult Disabled(TimeSpan? oldestQueuedAge = null) => + new(Executed: false, 0, 0, 0, oldestQueuedAge); + + public static readonly MatchmakingCycleResult Idle = + new(Executed: true, 0, 0, 0, null); +} + +/// +/// One iteration of the matching loop, as an application service rather than as code buried in a +/// BackgroundService. +/// +/// That split is what makes the module's hardest guarantee testable: "two competing workers produce zero duplicate +/// assignment" is asserted by running two coordinators concurrently against real PostgreSQL, which is only +/// possible because a cycle is a callable method rather than a timer tick inside a hosted service. +/// +public interface IMatchmakingCoordinator +{ + /// + /// Claims a bounded batch, forms proposals, and commits each one's assignments plus its single + /// MatchRequestedV1 — all in one transaction. + /// + /// is recorded on every ticket it claims. It survives onto terminal rows on + /// purpose: that attribution is what backs the matchmaking-worker-failure signal, so a worker that + /// consistently loses its handoffs can be identified without correlating logs. + /// + Task RunCycleAsync(string workerId, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Matchmaking/Services/IMatchmakingService.cs b/src/SimPle.Application/Matchmaking/Services/IMatchmakingService.cs new file mode 100644 index 0000000..8608707 --- /dev/null +++ b/src/SimPle.Application/Matchmaking/Services/IMatchmakingService.cs @@ -0,0 +1,36 @@ +using SimPle.Application.Matchmaking.DTOs; +using SimPle.Shared.Common; + +namespace SimPle.Application.Matchmaking.Services; + +/// +/// The Quick Match ticket surface (slice 6C). The matching worker, the expiry sweep, and the outbox dispatcher are +/// separate — a user never drives them, and they never run inside a request. +/// +/// Every method takes the actor as its first argument and it is always the JWT sub claim. A ticket id +/// belonging to somebody else is a privacy-safe not-found, never a 403. +/// +public interface IMatchmakingService +{ + /// + /// Enqueue. Idempotent by pool key: re-sending the identical ticket returns the live one rather than minting a + /// second (the ticket entity has no idempotency-key column by design — see ). + /// A different ticket while one is live is Matchmaking.AlreadyQueued. + /// + /// Succeeds even though no match runtime exists: the spec requires create/status/cancel and expiry to stay + /// fully functional before M8. The returned ticket's dependencyReadiness is what tells the truth about + /// what will happen next. + /// + Task> EnqueueAsync( + Guid actorUserId, CreateTicketRequestDto request, CancellationToken ct = default); + + /// Polled every 2 seconds by the queue modal until M7 supplies live delivery. + Task> GetTicketAsync(Guid actorUserId, Guid ticketId, CancellationToken ct = default); + + /// + /// Cancel. Commits only while Queued; a cancel that arrives after a worker claim is not an + /// error — it returns the ticket's current status with a 200, because the user did nothing wrong and + /// the queue genuinely did get there first. + /// + Task> CancelAsync(Guid actorUserId, Guid ticketId, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Matchmaking/Services/MatchProposalBuilder.cs b/src/SimPle.Application/Matchmaking/Services/MatchProposalBuilder.cs new file mode 100644 index 0000000..5f08e76 --- /dev/null +++ b/src/SimPle.Application/Matchmaking/Services/MatchProposalBuilder.cs @@ -0,0 +1,275 @@ +using SimPle.Domain.Matchmaking; + +namespace SimPle.Application.Matchmaking.Services; + +/// +/// One proposed match: the tickets that will share a group id, a single match request, and one +/// MatchRequestedV1. The anchor is first. +/// +public sealed record MatchProposal(IReadOnlyList Tickets) +{ + public MatchmakingTicket Anchor => Tickets[0]; +} + +/// +/// The Phase-1 matchmaking algorithm, as a pure function of a ticket batch and the current time. +/// +/// +/// It is deliberately free of the database, the clock, and the worker: every rule the brief cares about — band +/// widening, group compatibility, the four-level tie-break, anti-starvation — is decided here and is therefore +/// provable by a unit test with an injected time, rather than only observable as an emergent property of a +/// concurrent worker run. +/// +/// +/// +/// Anchoring. The oldest queued ticket anchors a proposal. Together with monotonically widening +/// bands (±100 → ±200 → ±400) that is the whole anti-starvation guarantee: a waiting ticket's band only grows, and +/// it is always considered before newer tickets, so it is never indefinitely skipped while the queue serves easier +/// matches. The 60-second deadline bounds the worst case, which makes expiry — not silent starvation — the +/// terminal outcome. +/// +/// +/// +/// Compatibility is mutual, not anchor-centric. A group is compatible only when its rating range +/// fits every member's current window, not just the anchor's. Checking only the anchor would let a +/// 60-second-old ±400 anchor drag a 1-second-old ±100 ticket into a match 350 points away from it — the young +/// ticket never agreed to that spread, and its own band is what says so. +/// +/// +public static class MatchProposalBuilder +{ + /// + /// How many of the anchor's nearest eligible partners are considered. Sorted by rating distance from the + /// anchor first, so the window keeps the most promising candidates, and a pool larger than this cannot make + /// one cycle's work unbounded (OWASP API4:2023 — algorithmic exhaustion is an availability risk, and the queue + /// is the one place in this module where an attacker controls the input size). + /// + public const int MaxCandidateWindow = 32; + + /// + /// A hard ceiling on group combinations examined per anchor. Reached only for large groups: at the two-player + /// size every reachable capability profile pins today, need = 1 and the search is exhaustive over the + /// window (at most evaluations), so the result is exactly optimal. + /// + /// For a larger supported group the enumeration order is deterministic and starts with the anchor's closest + /// partners, so a truncated search still returns the same answer on every run — it is bounded, not random. + /// + public const int MaxCombinationsPerAnchor = 20_000; + + /// + /// Forms as many non-overlapping proposals as the batch supports. A ticket appears in at most one proposal. + /// + /// An anchor that cannot be matched is dropped from this cycle only — its row stays Queued, and + /// the next cycle re-anchors it with a band that has widened in the meantime. That is why an unmatched oldest + /// ticket does not block the tickets behind it, and also why it does not lose its place. + /// + public static IReadOnlyList BuildProposals( + IReadOnlyList candidates, DateTime nowUtc, BlockedPairs blocked) + { + var remaining = candidates + .Where(t => t.State == MatchmakingTicketState.Queued && !t.IsExpired(nowUtc)) + .OrderBy(t => t.EnqueuedAtUtc).ThenBy(t => t.Id) + .ToList(); + + var proposals = new List(); + + while (remaining.Count > 0) + { + var anchor = remaining[0]; // oldest — the list is kept in anchor order + remaining.RemoveAt(0); + + var pool = remaining.Where(t => SharesPoolKey(anchor, t)).ToList(); + var proposal = TrySelectGroup(anchor, pool, nowUtc, blocked); + if (proposal is null) continue; + + proposals.Add(proposal); + + var taken = proposal.Tickets.Select(t => t.Id).ToHashSet(); + remaining.RemoveAll(t => taken.Contains(t.Id)); + } + + return proposals; + } + + /// + /// The best group the anchor can form right now, or null if none is compatible. + /// + public static MatchProposal? TrySelectGroup( + MatchmakingTicket anchor, IReadOnlyList pool, DateTime nowUtc, BlockedPairs blocked) + { + var need = anchor.PlayerCount - 1; + if (need < 1) return null; // defensive: enqueue rejects playerCount < 2 + if (anchor.RatingWindow(nowUtc) is null) return null; // anchor has expired; the sweep owns it + + // Mutual acceptance is a *necessary* condition for any group containing both tickets: the group's range + // spans both ratings, so each one's window must already reach the other. Filtering on it first is what + // keeps the combination search small. + // + // The block filter sits here, before selection, rather than as a veto on a finished group — see BlockedPairs. + var eligible = pool + .Where(t => SharesPoolKey(anchor, t) + && !blocked.AreBlocked(anchor.UserId, t.UserId) + && MutuallyAcceptable(anchor, t, nowUtc)) + .OrderBy(t => Math.Abs(t.Rating - anchor.Rating)) + .ThenBy(t => t.EnqueuedAtUtc) + .ThenBy(t => t.Id) + .Take(MaxCandidateWindow) + .ToList(); + + if (eligible.Count < need) return null; + + MatchmakingTicket[]? best = null; + var examined = 0; + + foreach (var combination in Combinations(eligible, need)) + { + if (++examined > MaxCombinationsPerAnchor) break; + + var group = new MatchmakingTicket[need + 1]; + group[0] = anchor; + combination.CopyTo(group, 1); + + // Pairwise-with-the-anchor is already excluded above, but a group of three or more must also be free of + // blocks *among the non-anchor members* — two players who blocked each other must not be seated together + // merely because they each get along with the anchor. + if (HasBlockedPair(group, blocked)) continue; + + if (!IsCompatible(group, nowUtc)) continue; + if (best is null || Compare(group, best, anchor) < 0) best = group; + } + + return best is null ? null : new MatchProposal(best); + } + + private static bool HasBlockedPair(IReadOnlyList group, BlockedPairs blocked) + { + for (var i = 0; i < group.Count; i++) + { + for (var j = i + 1; j < group.Count; j++) + { + if (blocked.AreBlocked(group[i].UserId, group[j].UserId)) return true; + } + } + + return false; + } + + /// + /// Exact-match candidate pool key. Phase 1 is same-region, and every field is a ticket snapshot, so a + /// profile or setting that changes mid-queue can never silently re-pool a waiting ticket. + /// + private static bool SharesPoolKey(MatchmakingTicket a, MatchmakingTicket b) => + string.Equals(a.GameSlug, b.GameSlug, StringComparison.Ordinal) + && a.CapabilityVersion == b.CapabilityVersion + && string.Equals(a.Mode, b.Mode, StringComparison.Ordinal) + && a.PlayerCount == b.PlayerCount + && string.Equals(a.TimeControlId, b.TimeControlId, StringComparison.Ordinal) + && a.Rated == b.Rated + && string.Equals(a.ResolvedRegion, b.ResolvedRegion, StringComparison.Ordinal); + + /// Each ticket's current band reaches the other's rating. Necessary, not sufficient. + private static bool MutuallyAcceptable(MatchmakingTicket a, MatchmakingTicket b, DateTime nowUtc) + { + var wa = a.RatingWindow(nowUtc); + var wb = b.RatingWindow(nowUtc); + if (wa is null || wb is null) return false; + + return b.Rating >= wa.Value.Low && b.Rating <= wa.Value.High + && a.Rating >= wb.Value.Low && a.Rating <= wb.Value.High; + } + + /// + /// The group's rating range fits inside every member's current window. This is the sufficient + /// condition, and it is strictly stronger than pairwise mutual acceptance once a group exceeds two members: + /// three tickets can each accept the other two individually while the outer two still straddle the middle + /// one's band. + /// + private static bool IsCompatible(IReadOnlyList group, DateTime nowUtc) + { + var low = int.MaxValue; + var high = int.MinValue; + foreach (var t in group) + { + if (t.Rating < low) low = t.Rating; + if (t.Rating > high) high = t.Rating; + } + + foreach (var t in group) + { + var window = t.RatingWindow(nowUtc); + if (window is null) return false; + if (low < window.Value.Low || high > window.Value.High) return false; + } + + return true; + } + + /// + /// The brief's four-level tie-break, in order: smallest rating range, then lowest total distance from the + /// anchor, then earliest creation time, then lowest ticket id. Negative means wins. + /// + /// Levels 3 and 4 compare the members' creation times and ids as ascending sequences. Two distinct groups + /// always differ in at least one member id, so level 4 is a total order — the selection is fully deterministic + /// and can never depend on enumeration order or wall-clock luck. + /// + private static int Compare( + IReadOnlyList x, IReadOnlyList y, MatchmakingTicket anchor) + { + var byRange = RatingRange(x).CompareTo(RatingRange(y)); + if (byRange != 0) return byRange; + + var byDistance = DistanceFrom(x, anchor).CompareTo(DistanceFrom(y, anchor)); + if (byDistance != 0) return byDistance; + + var xTimes = x.Select(t => t.EnqueuedAtUtc).OrderBy(t => t).ToArray(); + var yTimes = y.Select(t => t.EnqueuedAtUtc).OrderBy(t => t).ToArray(); + for (var i = 0; i < xTimes.Length; i++) + { + var byTime = xTimes[i].CompareTo(yTimes[i]); + if (byTime != 0) return byTime; + } + + var xIds = x.Select(t => t.Id).OrderBy(t => t).ToArray(); + var yIds = y.Select(t => t.Id).OrderBy(t => t).ToArray(); + for (var i = 0; i < xIds.Length; i++) + { + var byId = xIds[i].CompareTo(yIds[i]); + if (byId != 0) return byId; + } + + return 0; + } + + private static int RatingRange(IReadOnlyList group) => + group.Max(t => t.Rating) - group.Min(t => t.Rating); + + private static long DistanceFrom(IReadOnlyList group, MatchmakingTicket anchor) => + group.Sum(t => (long)Math.Abs(t.Rating - anchor.Rating)); + + /// + /// Every -sized combination of , in ascending index order. + /// Because arrives sorted by distance from the anchor, the enumeration visits the + /// closest partners first — so truncating it at the combination budget drops the least promising groups, and + /// drops the same ones every run. + /// + private static IEnumerable Combinations(IReadOnlyList source, int need) + { + var indices = new int[need]; + for (var i = 0; i < need; i++) indices[i] = i; + + while (true) + { + var combination = new MatchmakingTicket[need]; + for (var i = 0; i < need; i++) combination[i] = source[indices[i]]; + yield return combination; + + // Advance the rightmost index that still has room, then repack everything after it. + var pivot = need - 1; + while (pivot >= 0 && indices[pivot] == source.Count - need + pivot) pivot--; + if (pivot < 0) yield break; + + indices[pivot]++; + for (var i = pivot + 1; i < need; i++) indices[i] = indices[i - 1] + 1; + } + } +} diff --git a/src/SimPle.Application/Matchmaking/Services/MatchmakingCoordinator.cs b/src/SimPle.Application/Matchmaking/Services/MatchmakingCoordinator.cs new file mode 100644 index 0000000..5b1e8e9 --- /dev/null +++ b/src/SimPle.Application/Matchmaking/Services/MatchmakingCoordinator.cs @@ -0,0 +1,170 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.Lobbies.Services; +using SimPle.Application.Matchmaking.Outbox; +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Matchmaking.Services; + +/// +/// One matching cycle: claim a bounded batch, form proposals, commit assignments and their single match request. +/// +/// +/// The cycle does not run while no match runtime is registered, and that gate is the most important +/// line in this file. A cycle that ran without Module 8 would mark tickets Matched and emit match requests +/// nobody consumes — the queue UI would show a found opponent and a room the player could never enter. That is +/// exactly the fabrication the brief forbids (Risk #6), so before M8 the queue honestly does nothing but widen and +/// time out. The code below is real, tested, and dormant; M8 replaces the probe and it turns on unchanged. +/// +/// +/// +/// SKIP LOCKED is not exclusivity (Risk #1). It stops two workers contending on one +/// row — it does not stop a requeued ticket or a serialization retry from attempting a second assignment. The +/// partial unique index UNIQUE (TicketId) WHERE State = 'Active' is what makes double assignment impossible; +/// when it fires, re-runs the whole cycle, which re-reads and simply finds the +/// ticket already taken. +/// +/// +public sealed class MatchmakingCoordinator : IMatchmakingCoordinator +{ + private readonly IMatchmakingRepository _tickets; + private readonly IWorkerTransaction _transaction; + private readonly IMatchRuntimeProbe _matchRuntime; + private readonly MatchmakingOptions _options; + private readonly TimeProvider _clock; + private readonly ILogger _logger; + + public MatchmakingCoordinator( + IMatchmakingRepository tickets, + IWorkerTransaction transaction, + IMatchRuntimeProbe matchRuntime, + IOptions options, + TimeProvider clock, + ILogger logger) + { + _tickets = tickets; + _transaction = transaction; + _matchRuntime = matchRuntime; + _options = options.Value; + _clock = clock; + _logger = logger; + } + + private DateTime NowUtc => _clock.GetUtcNow().UtcDateTime; + + public async Task RunCycleAsync(string workerId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(workerId)) + throw new ArgumentException("WorkerId must not be empty.", nameof(workerId)); + + // The M8 gate. Queue *execution* is disabled without a match runtime; ticket create/status/cancel and the + // expiry sweep stay fully functional, which is what lets a player queue, watch the band widen, and time out + // honestly rather than be told a lie. + if (!await _matchRuntime.IsAvailableAsync(ct)) + { + var age = await _tickets.GetOldestQueuedAgeAsync(NowUtc, ct); + return MatchmakingCycleResult.Disabled(age); + } + + return await _transaction.RunAsync(async token => + { + var nowUtc = NowUtc; + + var claimed = await _tickets.ClaimQueuedTicketsAsync(_options.BatchSize, nowUtc, token); + if (claimed.Count == 0) return MatchmakingCycleResult.Idle; + + var candidates = await ExcludeUsersInLiveMatchesAsync(claimed, token); + + var userIds = candidates.Select(t => t.UserId).Distinct().ToList(); + var blocked = new BlockedPairs(await _tickets.GetBlockedPairsAsync(userIds, token)); + + var proposals = MatchProposalBuilder.BuildProposals(candidates, nowUtc, blocked); + + var assignments = new List(); + var events = new List(); + var matched = 0; + + foreach (var proposal in proposals) + { + // Guard before mutating anything: a proposal is committed whole or not at all, so a ticket that has + // slipped out from under the builder (expired on the boundary, say) must not leave the rest of its + // group half-claimed. + if (!CanCommit(proposal, nowUtc)) continue; + + var groupId = Guid.NewGuid(); + var matchRequestId = Guid.NewGuid(); + + foreach (var ticket in proposal.Tickets) + { + ticket.Claim(workerId, nowUtc); + ticket.MarkMatched(nowUtc); + assignments.Add(MatchmakingAssignment.Create(ticket.Id, matchRequestId, groupId, nowUtc)); + matched++; + } + + // Exactly one event for the whole group — the group is what M8 creates a match from. + events.Add(MatchmakingOutbox.MatchRequestedEvent( + groupId, + matchRequestId, + proposal.Anchor.GameSlug, + proposal.Anchor.CapabilityVersion, + proposal.Tickets.Select(t => t.Id).ToList())); + } + + if (assignments.Count > 0) + { + // The state changes, the assignments, and the events commit together or not at all. + await _tickets.AddAssignmentsAsync(assignments, events, token); + + _logger.LogInformation( + "Matchmaking cycle matched tickets. Worker={Worker} Claimed={Claimed} Proposals={Proposals} Matched={Matched}", + workerId, claimed.Count, events.Count, matched); + } + + var oldest = await _tickets.GetOldestQueuedAgeAsync(nowUtc, token); + + return new MatchmakingCycleResult( + Executed: true, + TicketsClaimed: claimed.Count, + ProposalsFormed: events.Count, + TicketsMatched: matched, + OldestQueuedAge: oldest); + }, ct); + } + + /// + /// The cross-cutting active-participation rule, re-asked at assignment. + /// + /// It is checked here as well as at enqueue on purpose: a player can enter a live match in the sixty seconds + /// their ticket is queued, and a single up-front check would happily assign them to a second one. With no + /// runtime this is trivially a no-op — which is a true answer, not a stub: a user genuinely cannot be in a live + /// match when no match can exist. + /// + private async Task> ExcludeUsersInLiveMatchesAsync( + IReadOnlyList tickets, CancellationToken ct) + { + var eligible = new List(tickets.Count); + + foreach (var ticket in tickets) + { + if (await _matchRuntime.IsInActiveMatchAsync(ticket.UserId, ct)) + { + // Left Queued deliberately. The player is busy, not wrong: their ticket stays in the queue and is + // reconsidered next cycle, and if they are still in a match when it runs out, it times out honestly. + _logger.LogInformation( + "Matchmaking skipped a ticket whose owner is in a live match. TicketId={TicketId}", ticket.Id); + continue; + } + + eligible.Add(ticket); + } + + return eligible; + } + + private static bool CanCommit(MatchProposal proposal, DateTime nowUtc) => + proposal.Tickets.All(t => t.State == MatchmakingTicketState.Queued && !t.IsExpired(nowUtc)); +} diff --git a/src/SimPle.Application/Matchmaking/Services/MatchmakingService.cs b/src/SimPle.Application/Matchmaking/Services/MatchmakingService.cs new file mode 100644 index 0000000..2830a93 --- /dev/null +++ b/src/SimPle.Application/Matchmaking/Services/MatchmakingService.cs @@ -0,0 +1,274 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.Lobbies.DTOs; +using SimPle.Application.Lobbies.Services; +using SimPle.Application.Matchmaking.DTOs; +using SimPle.Domain.Games; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Outbox; +using SimPle.Shared.Common; + +namespace SimPle.Application.Matchmaking.Services; + +/// +/// The Quick Match ticket command surface. +/// +/// Enqueue and cancel run through — the same runner the lobby commands +/// use, and deliberately so. It holds a transaction-scoped advisory lock keyed on the actor, which is the only +/// thing that makes the cross-table "one active lobby or one active ticket" invariant real: the +/// filtered unique index on lobby_members and the one on matchmaking_tickets live on different tables +/// and cannot see each other, so without serializing an actor's seat-acquiring commands against each other, a +/// concurrent join and enqueue would both read "nothing active" and both commit (brief Risk #2). +/// +public sealed class MatchmakingService : IMatchmakingService +{ + private readonly IMatchmakingRepository _tickets; + private readonly ILobbyRepository _lobbies; + private readonly ILobbyCommandRunner _runner; + private readonly IMatchRuntimeProbe _matchRuntime; + private readonly IChatRuntimeProbe _chatRuntime; + private readonly IAiParticipantProbe _aiParticipants; + private readonly IUserRepository _users; + private readonly LobbyCredentialOptions _options; + private readonly TimeProvider _clock; + private readonly ILogger _logger; + + public MatchmakingService( + IMatchmakingRepository tickets, + ILobbyRepository lobbies, + ILobbyCommandRunner runner, + IMatchRuntimeProbe matchRuntime, + IChatRuntimeProbe chatRuntime, + IAiParticipantProbe aiParticipants, + IUserRepository users, + IOptions options, + TimeProvider clock, + ILogger logger) + { + _tickets = tickets; + _lobbies = lobbies; + _runner = runner; + _matchRuntime = matchRuntime; + _chatRuntime = chatRuntime; + _aiParticipants = aiParticipants; + _users = users; + _options = options.Value; + _clock = clock; + _logger = logger; + } + + private DateTime NowUtc => _clock.GetUtcNow().UtcDateTime; + + // ── Enqueue ────────────────────────────────────────────────────────────── + + public Task> EnqueueAsync( + Guid actorUserId, CreateTicketRequestDto request, CancellationToken ct = default) => + _runner.RunAsync(actorUserId, async token => + { + var actor = await _users.GetByIdAsync(actorUserId, token); + if (actor is null || actor.IsAccountSuspended()) + return Fail(LobbyErrors.Forbidden, "Your account cannot join the Quick Match queue."); + + var validation = ValidateRequest(request); + if (!validation.IsSuccess) return Result.Fail(validation.Error!); + + var resolvedRegion = LobbyRegion.Resolve(request.Region, actor.Region, _options.DefaultRegion); + + // Idempotency without a key: an identical live ticket *is* the caller's previous attempt. This is the + // same shape a double-submitted join gets (SeatMemberAsync returns the lobby rather than an error), and + // it is why a flaky network cannot leave a user unable to queue because "they are already queued". + var existing = await _tickets.GetActiveTicketForUserAsync(actorUserId, token); + if (existing is not null) + { + if (IsSameTicket(existing, request, resolvedRegion)) + return Result.Ok(await ProjectAsync(existing, token)); + + return Fail( + MatchmakingErrors.AlreadyQueued, "You are already in the Quick Match queue."); + } + + // The other half of the cross-table invariant. Checked inside the transaction that inserts, under the + // runner's advisory lock — a pre-flight check outside it would be a race, not a guarantee. + var activeLobby = await _lobbies.GetActiveLobbyForUserAsync(actorUserId, token); + if (activeLobby is not null) + return Fail(LobbyErrors.AlreadyActive, "You are already in a lobby."); + + // Re-asked here, not trusted from an earlier step: a user can enter a live match between two requests. + if (await _matchRuntime.IsInActiveMatchAsync(actorUserId, token)) + return Fail(LobbyErrors.AlreadyActive, "You are already in a live match."); + + var capability = await ValidateCapabilityAsync(request, token); + if (!capability.IsSuccess) return Result.Fail(capability.Error!); + + var ticket = MatchmakingTicket.Enqueue( + actorUserId, + request.GameSlug, + request.CapabilityVersion, + request.Mode, + request.PlayerCount, + request.TimeControlId, + request.Rated, + resolvedRegion, + // Provisional until M10. 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 — and + // one that silently decides who people play against. + MatchmakingTicket.ProvisionalRating, + MatchmakingTicket.ProvisionalRatingSource, + correlationId: Guid.NewGuid(), + NowUtc); + + await _tickets.AddTicketAsync(ticket, token); + + return Result.Ok(await ProjectAsync(ticket, token)); + }, ct); + + // ── Status ─────────────────────────────────────────────────────────────── + + public async Task> GetTicketAsync( + Guid actorUserId, Guid ticketId, CancellationToken ct = default) + { + var ticket = await _tickets.GetTicketAsync(ticketId, ct); + + // Another user's ticket id is indistinguishable from one that never existed. A 403 here would confirm it + // exists, which is exactly the BOLA leak (OWASP API1:2023) the 404 closes. + if (ticket is null || ticket.UserId != actorUserId) return TicketNotFound(); + + return Result.Ok(await ProjectAsync(ticket, ct)); + } + + // ── Cancel ─────────────────────────────────────────────────────────────── + + public Task> CancelAsync( + Guid actorUserId, Guid ticketId, CancellationToken ct = default) => + _runner.RunAsync(actorUserId, async token => + { + var ticket = await _tickets.GetTicketForUpdateAsync(ticketId, token); + if (ticket is null || ticket.UserId != actorUserId) return TicketNotFound(); + + var outcome = ticket.Cancel(NowUtc); + + // A cancel that lost to a worker claim is not an error and must not be reported as one — the user + // pressed cancel in good faith and the queue simply got there first. They get a 200 and the ticket's + // real current state, which is what the polling UI needs anyway. + if (outcome is MatchmakingOutcome.AlreadyClaimed or MatchmakingOutcome.Terminal) + return Result.Ok(await ProjectAsync(ticket, token)); + + if (outcome != MatchmakingOutcome.Ok) return MapOutcome(outcome); + + await _tickets.SaveAsync(Array.Empty(), token); + return Result.Ok(await ProjectAsync(ticket, token)); + }, ct); + + // ── Validation ─────────────────────────────────────────────────────────── + + private static Result ValidateRequest(CreateTicketRequestDto request) + { + if (string.IsNullOrWhiteSpace(request.GameSlug)) + return Result.Fail(LobbyErrors.ValidationFailed, "gameSlug is required."); + if (request.CapabilityVersion < 1) + return Result.Fail(LobbyErrors.ValidationFailed, "capabilityVersion must be at least 1."); + if (string.IsNullOrWhiteSpace(request.Mode)) + return Result.Fail(LobbyErrors.ValidationFailed, "mode is required."); + if (!GameCatalogAllowLists.Modes.Contains(request.Mode)) + return Result.Fail(LobbyErrors.ValidationFailed, "Unknown mode."); + + // Quick Match is a multiplayer queue by definition — a one-player "match" has nobody to find. + if (request.PlayerCount < 2 || request.PlayerCount > 8) + return Result.Fail(LobbyErrors.ValidationFailed, "playerCount must be between 2 and 8."); + + if (!LobbyAllowLists.TimeControls.Contains(request.TimeControlId)) + return Result.Fail(LobbyErrors.ValidationFailed, "Unknown time control."); + + return Result.Ok(); + } + + /// + /// Validates the ticket against its pinned capability profile, M4's catalog, and the platform allow-lists — + /// before persistence, so a ticket that could never be satisfied is never created. + /// + private async Task ValidateCapabilityAsync(CreateTicketRequestDto request, CancellationToken ct) + { + var profile = await _lobbies.GetCapabilityProfileAsync(request.GameSlug, request.CapabilityVersion, ct); + if (profile is null) + return Result.Fail(LobbyErrors.CapabilityDisabled, "That game/capability version is not available."); + + var permits = profile.PermitsTicket( + request.GameSlug, request.CapabilityVersion, request.Mode, + request.PlayerCount, request.TimeControlId, request.Rated); + if (!permits.Allowed) + return Result.Fail(LobbyErrors.CapabilityDisabled, permits.Reason!); + + var game = await _lobbies.GetGameAsync(request.GameSlug, ct); + if (game is null || game.Lifecycle != GameLifecycle.Available) + return Result.Fail(LobbyErrors.CapabilityDisabled, "That game is not available to play right now."); + + // Drift between M6's profile and M4's catalog is a data bug, not a user error — but it fails closed all the + // same, because a ticket it let through would produce a match M5's engine cannot host. + var modes = await _lobbies.GetGameModesAsync(game.Id, ct); + var drift = profile.ContradictsCatalog(game.MinPlayers, game.MaxPlayers, modes); + if (!drift.Allowed) + { + _logger.LogWarning( + "Capability profile drift for {GameSlug} v{CapabilityVersion}: {Reason}", + request.GameSlug, request.CapabilityVersion, drift.Reason); + return Result.Fail(LobbyErrors.CapabilityDisabled, "That game's configuration is temporarily unavailable."); + } + + return Result.Ok(); + } + + /// The full candidate-pool key. Anything less would treat two genuinely different searches as one. + private static bool IsSameTicket(MatchmakingTicket ticket, CreateTicketRequestDto request, string resolvedRegion) => + string.Equals(ticket.GameSlug, request.GameSlug, StringComparison.Ordinal) + && ticket.CapabilityVersion == request.CapabilityVersion + && string.Equals(ticket.Mode, request.Mode, StringComparison.Ordinal) + && ticket.PlayerCount == request.PlayerCount + && string.Equals(ticket.TimeControlId, request.TimeControlId, StringComparison.Ordinal) + && ticket.Rated == request.Rated + && string.Equals(ticket.ResolvedRegion, resolvedRegion, StringComparison.Ordinal); + + // ── Projection ─────────────────────────────────────────────────────────── + + private async Task ProjectAsync(MatchmakingTicket ticket, CancellationToken ct) + { + var nowUtc = NowUtc; + + // Only a matched ticket has a handoff to point at. A MatchRequestId is a durable *request* — the client may + // not navigate to a room on the strength of it, and dependencyReadiness.MatchRuntime is what says so. + TicketAssignmentDto? assignment = null; + if (ticket.State == MatchmakingTicketState.Matched) + { + var row = await _tickets.GetActiveAssignmentAsync(ticket.Id, ct); + if (row is not null) + assignment = new TicketAssignmentDto(row.MatchRequestId, row.GroupId); + } + + var readiness = new DependencyReadinessDto( + Chat: await _chatRuntime.IsAvailableAsync(ct), + MatchRuntime: await _matchRuntime.IsAvailableAsync(ct), + AiParticipants: await _aiParticipants.IsAvailableAsync(ct)); + + return new TicketDto( + ticket.Id, ticket.GameSlug, ticket.CapabilityVersion, ticket.Mode, ticket.PlayerCount, + ticket.TimeControlId, ticket.Rated, ticket.ResolvedRegion, ticket.Rating, ticket.RatingSourceVersion, + ticket.State.ToString(), ticket.EnqueuedAtUtc, ticket.DeadlineAtUtc, + ticket.CurrentBand(nowUtc), assignment, readiness); + } + + // ── Result helpers ─────────────────────────────────────────────────────── + + private static Result MapOutcome(MatchmakingOutcome outcome) => outcome switch + { + MatchmakingOutcome.Expired => Fail(MatchmakingErrors.TicketExpired, "This ticket has expired."), + MatchmakingOutcome.Terminal => Fail(MatchmakingErrors.TicketExpired, "This ticket is no longer active."), + _ => Fail(LobbyErrors.ValidationFailed, "That action is not allowed."), + }; + + private static Result TicketNotFound() => + Fail(MatchmakingErrors.TicketNotFound, "Ticket not found."); + + private static Result Fail(string code, string message) => Result.Fail(code, message); +} diff --git a/src/SimPle.Application/Outbox/Handlers/LobbyBlockHandler.cs b/src/SimPle.Application/Outbox/Handlers/LobbyBlockHandler.cs new file mode 100644 index 0000000..8da1194 --- /dev/null +++ b/src/SimPle.Application/Outbox/Handlers/LobbyBlockHandler.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Friends.Outbox; +using SimPle.Application.Lobbies.Outbox; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Outbox.Handlers; + +/// +/// Applies a new Module 3 block to any lobby the two users currently share (D3). +/// +/// +/// The rule, from the brief: on a new block in an open lobby, a non-host blocker leaves; a host blocker +/// removes the blocked member. Asymmetric on purpose — a host who blocks someone should not be evicted +/// from the lobby they own, and a member who blocks the host has no authority to remove them, so the only thing +/// they can do is leave. +/// +/// +/// +/// Idempotent by construction, not by bookkeeping. It decides from the users' current +/// lobby membership, never from the event's age or its own delivery history. A duplicate delivery finds the pair +/// already separated and does nothing; a historical UserBlockedV1 replayed on a fresh deployment finds no +/// shared lobby and does nothing. That is why this handler needs no activation watermark — the same property that +/// makes at-least-once delivery safe also makes a backfill safe, and a watermark would have been a second +/// mechanism to get wrong. +/// +/// +/// +/// It runs through like every other lobby mutation, so it takes the same advisory +/// lock and the same bounded whole-command retry. A block landing at the same moment as a join is contention, not a +/// special case. +/// +/// +public sealed class LobbyBlockHandler : IOutboxHandler +{ + private readonly ILobbyRepository _lobbies; + private readonly ILobbyCommandRunner _runner; + private readonly TimeProvider _clock; + private readonly ILogger _logger; + + public LobbyBlockHandler( + ILobbyRepository lobbies, + ILobbyCommandRunner runner, + TimeProvider clock, + ILogger logger) + { + _lobbies = lobbies; + _runner = runner; + _clock = clock; + _logger = logger; + } + + /// Persisted in every delivery row. Renaming it replays the entire block history — see the interface. + public string HandlerName => "lobby-block"; + + public IReadOnlyList EventTypes { get; } = new[] { FriendOutbox.UserBlocked }; + + /// + /// FriendOutbox serializes its payloads from anonymous objects, so the JSON is camelCase + /// (blockerId) while this record's properties are PascalCase. System.Text.Json is case-sensitive + /// by default: without this, every id would deserialize to Guid.Empty and the handler would + /// quietly treat every block as unreadable — a bug with no exception and no failing write to notice it by. + /// + private static readonly JsonSerializerOptions PayloadOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public async Task HandleAsync(OutboxMessage message, CancellationToken ct = default) + { + var payload = JsonSerializer.Deserialize(message.Payload, PayloadOptions); + if (payload is null || payload.BlockerId == Guid.Empty || payload.BlockedId == Guid.Empty) + { + // A malformed payload is not retryable — replaying it will produce the same nothing. Swallowing it here + // (rather than throwing) lets the dispatcher mark it processed instead of burning the retry budget and + // dead-lettering a row that no amount of retrying can fix. + _logger.LogWarning( + "Outbox: UserBlockedV1 payload could not be read. EventId={EventId}", message.Id); + return; + } + + var result = await _runner.RunAsync(payload.BlockerId, async token => + { + var nowUtc = _clock.GetUtcNow().UtcDateTime; + + // The blocker's lobby is the only one that can contain both of them: a user has at most one joined + // nonterminal lobby, which is the invariant the whole module is built on. + var lobby = await _lobbies.GetActiveLobbyForUserAsync(payload.BlockerId, token); + if (lobby is null) return Ok(false); + + // Re-read tracked; the query above is untracked (it renders responses elsewhere). + var tracked = await _lobbies.GetForUpdateAsync(lobby.Id, token); + if (tracked is null || tracked.IsTerminal || tracked.IsExpired(nowUtc)) return Ok(false); + + if (tracked.FindJoinedMember(payload.BlockerId) is null) return Ok(false); + if (tracked.FindJoinedMember(payload.BlockedId) is null) return Ok(false); + + var events = new List(); + + if (tracked.IsHost(payload.BlockerId)) + { + var kick = tracked.Kick(payload.BlockerId, payload.BlockedId, nowUtc); + if (kick != LobbyOutcome.Ok) return Ok(false); + + events.Add(LobbyOutbox.MemberKickedEvent(tracked, payload.BlockedId, payload.BlockerId)); + } + else + { + var leave = tracked.Leave(payload.BlockerId, nowUtc); + if (leave.Outcome != LobbyOutcome.Ok) return Ok(false); + + events.Add(LobbyOutbox.MemberLeftEvent(tracked, payload.BlockerId)); + + // A blocker who happened to be the last eligible human still triggers the ordinary host-transfer / + // close rules — a block is not a special exit path, it is an exit. + if (leave.NewHostUserId is Guid newHost) + events.Add(LobbyOutbox.HostTransferredEvent(tracked, newHost)); + if (leave.ClosedReason is not null) + events.Add(LobbyOutbox.LobbyClosedEvent(tracked)); + } + + await _lobbies.SaveAsync(events, token); + return Ok(true); + }, ct); + + // A typed failure from the runner (a spent retry budget) is a real failure: throwing hands it back to the + // dispatcher, which will retry the delivery on a later cycle rather than silently dropping the block. + if (!result.IsSuccess) + { + throw new InvalidOperationException( + $"Applying a block to its shared lobby failed: {result.Error!.Code}"); + } + + if (result.Value) + { + _logger.LogInformation( + "Security: Block applied to a shared lobby. Action={Action} Result={Result} EventId={EventId}", + "LobbyBlockEnforcement", "Separated", message.Id); + } + } + + private static Shared.Common.Result Ok(bool value) => Shared.Common.Result.Ok(value); + + /// + /// Matches FriendOutbox.BlockEvent's payload. Ids only — the block reason is deliberately not in the + /// event, and this handler has no use for one. + /// + private sealed record BlockPayload(Guid BlockId, Guid BlockerId, Guid BlockedId); +} diff --git a/src/SimPle.Application/Outbox/IOutboxHandler.cs b/src/SimPle.Application/Outbox/IOutboxHandler.cs new file mode 100644 index 0000000..a21ecfc --- /dev/null +++ b/src/SimPle.Application/Outbox/IOutboxHandler.cs @@ -0,0 +1,47 @@ +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Outbox; + +/// +/// One consumer of one or more integration-event types (D3). +/// +/// +/// Module 6 builds the codebase's first outbox consumer side. Module 3 has emitted OutboxMessage +/// rows since it shipped, and has always modelled per-(eventId, handlerName) +/// delivery state — but nothing has ever read them. M7, M8, and M11 inherit this machinery. +/// +/// +/// +/// A handler must be idempotent. Delivery is at-least-once and always will be: the dispatcher +/// hands the event to the handler and then records the delivery, and no ordering of those two steps can be atomic +/// across a process crash. Recording first would lose events; recording after (as here) can repeat one. Repeating a +/// no-op is the only failure mode a correct handler can have, so that is the one this design chooses. +/// +/// +public interface IOutboxHandler +{ + /// + /// Stable identity, persisted in . Renaming it replays every + /// event this handler has ever processed, because the delivery rows keyed to the old name no longer + /// match — so it is a wire identifier, not a class name to be refactored freely. + /// + string HandlerName { get; } + + /// + /// The values this handler consumes. + /// + /// Declared as a list rather than a Handles(string) predicate for one concrete reason: the dispatcher has + /// to turn it into a WHERE event_type IN (…). A predicate cannot be translated to SQL, so the dispatcher + /// would have to either keep a second, hand-maintained list of every event type in the system — which the next + /// handler's author would forget to update, silently receiving nothing — or scan the entire outbox every cycle + /// and filter in memory. + /// + IReadOnlyList EventTypes { get; } + + /// + /// Applies the event. Throwing means "retry me": the dispatcher records the attempt, releases the lease, and + /// tries again on a later cycle until the retry budget is spent, after which the delivery is dead-lettered + /// rather than retried forever. + /// + Task HandleAsync(OutboxMessage message, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Outbox/OutboxProcessor.cs b/src/SimPle.Application/Outbox/OutboxProcessor.cs new file mode 100644 index 0000000..e8dd053 --- /dev/null +++ b/src/SimPle.Application/Outbox/OutboxProcessor.cs @@ -0,0 +1,141 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; + +namespace SimPle.Application.Outbox; + +/// What one dispatcher pass did, per handler. Counts and durations only — never an event body. +public sealed record OutboxDispatchResult( + int Leased, + int Processed, + int Failed, + int DeadLettered, + TimeSpan? OldestPendingAge) +{ + public static readonly OutboxDispatchResult Idle = new(0, 0, 0, 0, null); +} + +/// +/// The transactional outbox's dispatcher (D3) — the codebase's first. +/// +/// +/// Leased, bounded, and idempotent, in that order of importance: +/// +/// +/// Leased — a delivery row is claimed with FOR UPDATE SKIP LOCKED and stamped with +/// an expiry, so two dispatcher instances never work the same event, and a dispatcher that dies mid-handler +/// releases its work by letting the lease lapse rather than by holding a lock forever. +/// Bounded — a fixed batch per pass, and a retry budget per delivery. A handler that fails +/// permanently is dead-lettered instead of being retried until the end of time, which is what keeps one broken +/// consumer from consuming the whole dispatcher. +/// Idempotent — delivery is at-least-once. The handler runs, and only then is the delivery +/// recorded; a crash in between replays the event. Every handler is written to make that a no-op, because no +/// ordering of "act" and "record" is atomic across a process boundary and pretending otherwise is how outboxes +/// lose events. +/// +/// +/// +/// Each handler is dispatched in its own transaction. Batching them would mean one handler's failure rolls +/// back another's recorded success and replays it — the precise starvation the per-(eventId, handlerName) +/// delivery row exists to prevent. +/// +/// +public interface IOutboxProcessor +{ + Task DispatchAsync(IOutboxHandler handler, CancellationToken ct = default); +} + +/// +public sealed class OutboxProcessor : IOutboxProcessor +{ + private readonly IOutboxRepository _outbox; + private readonly IWorkerTransaction _transaction; + private readonly OutboxOptions _options; + private readonly TimeProvider _clock; + private readonly ILogger _logger; + + public OutboxProcessor( + IOutboxRepository outbox, + IWorkerTransaction transaction, + IOptions options, + TimeProvider clock, + ILogger logger) + { + _outbox = outbox; + _transaction = transaction; + _options = options.Value; + _clock = clock; + _logger = logger; + } + + public async Task DispatchAsync(IOutboxHandler handler, CancellationToken ct = default) + { + var eventTypes = handler.EventTypes; + if (eventTypes.Count == 0) return OutboxDispatchResult.Idle; + + var nowUtc = _clock.GetUtcNow().UtcDateTime; + + // The lease is taken in its own transaction and committed before any handler runs. If it were held open + // across the handler call, a slow handler would keep the delivery rows locked, and the second dispatcher's + // SKIP LOCKED would skip them — which is correct — but a crash would then roll the *lease* back too, and + // the row would be retried immediately with its attempt count reset. Committing the lease first is what + // makes the retry budget real. + var leased = await _transaction.RunAsync( + token => _outbox.LeaseAsync( + handler.HandlerName, eventTypes, _options.BatchSize, + nowUtc, nowUtc + _options.LeaseDuration, token), + ct); + + if (leased.Count == 0) + { + var idleAge = await _outbox.GetOldestPendingAgeAsync(handler.HandlerName, eventTypes, nowUtc, ct); + return OutboxDispatchResult.Idle with { OldestPendingAge = idleAge }; + } + + var processed = 0; + var failed = 0; + var deadLettered = 0; + + foreach (var (message, delivery) in leased) + { + try + { + await handler.HandleAsync(message, ct); + delivery.MarkProcessed(); + processed++; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The budget is spent once this attempt is the last one. AttemptCount was already incremented by + // AcquireLease, so it counts attempts *made*, not attempts remaining. + var isFinalAttempt = delivery.AttemptCount >= _options.MaxAttempts; + + // Never the exception message: an event body or a user id could reach it, and a dead-letter row is + // long-lived, widely read, and exactly the kind of place PII quietly accumulates. + delivery.MarkFailed($"{ex.GetType().Name} after {delivery.AttemptCount} attempt(s).", isFinalAttempt); + + failed++; + if (isFinalAttempt) deadLettered++; + + _logger.Log( + isFinalAttempt ? LogLevel.Error : LogLevel.Warning, + ex, + "Outbox delivery failed. Handler={Handler} EventType={EventType} Attempt={Attempt} DeadLettered={DeadLettered}", + handler.HandlerName, message.EventType, delivery.AttemptCount, isFinalAttempt); + } + } + + // Saved directly, NOT through IWorkerTransaction. Its retry clears the change tracker between attempts, which + // is right for a delegate that re-reads — and catastrophic here: the delivery rows this pass mutated live + // only in the tracker, so a retry would detach them and then dutifully save nothing, silently losing every + // MarkProcessed and MarkFailed in the batch. If this save fails, the leases simply lapse and the batch is + // retried on a later cycle, which is what leases are for. + await _outbox.SaveAsync(ct); + + var oldestPending = await _outbox.GetOldestPendingAgeAsync( + handler.HandlerName, eventTypes, _clock.GetUtcNow().UtcDateTime, ct); + + return new OutboxDispatchResult(leased.Count, processed, failed, deadLettered, oldestPending); + } +} 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/Capabilities/CapabilitySeedManifest.cs b/src/SimPle.Infrastructure/Capabilities/CapabilitySeedManifest.cs new file mode 100644 index 0000000..15902b1 --- /dev/null +++ b/src/SimPle.Infrastructure/Capabilities/CapabilitySeedManifest.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; + +namespace SimPle.Infrastructure.Capabilities; + +/// Deserialization shape of the embedded capability.seed.v1.json manifest. +public sealed class CapabilitySeedManifest +{ + [JsonPropertyName("manifestVersion")] + public string ManifestVersion { get; set; } = default!; + + [JsonPropertyName("profiles")] + public List Profiles { get; set; } = new(); +} + +public sealed class CapabilitySeedProfileEntry +{ + [JsonPropertyName("gameSlug")] + public string GameSlug { get; set; } = default!; + + [JsonPropertyName("capabilityVersion")] + public int CapabilityVersion { get; set; } + + [JsonPropertyName("minPlayers")] + public int MinPlayers { get; set; } + + [JsonPropertyName("maxPlayers")] + public int MaxPlayers { get; set; } + + [JsonPropertyName("allowedModes")] + public List AllowedModes { get; set; } = new(); + + [JsonPropertyName("timeControls")] + public List TimeControls { get; set; } = new(); + + [JsonPropertyName("tieBreakRules")] + public List TieBreakRules { get; set; } = new(); + + [JsonPropertyName("spectatorPolicies")] + public List SpectatorPolicies { get; set; } = new(); + + [JsonPropertyName("ratedEligible")] + public bool RatedEligible { get; set; } + + [JsonPropertyName("aiFillEligible")] + public bool AiFillEligible { get; set; } + + [JsonPropertyName("isActive")] + public bool IsActive { get; set; } +} diff --git a/src/SimPle.Infrastructure/Capabilities/GameCapabilitySeeder.cs b/src/SimPle.Infrastructure/Capabilities/GameCapabilitySeeder.cs new file mode 100644 index 0000000..ad6a456 --- /dev/null +++ b/src/SimPle.Infrastructure/Capabilities/GameCapabilitySeeder.cs @@ -0,0 +1,283 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SimPle.Domain.Capabilities; +using SimPle.Infrastructure.Persistence; + +namespace SimPle.Infrastructure.Capabilities; + +public sealed record CapabilitySeedResult(bool Success, string Message, int ProfilesCreated, int ProfilesUpdated); + +/// +/// Loads the embedded capability manifest, validates it against domain invariants and against Module 4's +/// live catalog before touching the database, then upserts the profiles inside a transaction guarded by a Postgres +/// advisory lock so concurrent seeder runs converge safely. +/// +/// Mirrors deliberately — same embedded-resource + +/// checksum + advisory-lock + fail-closed-on-mismatch shape — with two differences that matter: +/// +/// 1. A distinct advisory-lock key (44004001 belongs to the catalog seeder). Sharing it would serialize two +/// unrelated seeders against each other for no reason, and would deadlock if either ever took the other's lock. +/// 2. A cross-catalog check: every profile must be a subset of the M4 catalog row for the same slug. A +/// profile that permits seat counts or modes the catalog does not would let a lobby be created that M5's engine +/// cannot host, so the seeder refuses to write it rather than deferring the failure to a player's Start click. +/// +public sealed class GameCapabilitySeeder +{ + /// + /// Module-6-specific advisory lock key. Must never collide with another module's — the Module 4 catalog seeder + /// holds 44004001. + /// + private const long CapabilitySeedAdvisoryLockKey = 44006001; + + private static readonly string ResourceName = + typeof(GameCapabilitySeeder).Assembly.GetManifestResourceNames() + .First(n => n.EndsWith("capability.seed.v1.json", StringComparison.Ordinal)); + + private readonly AppDbContext _db; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public GameCapabilitySeeder(AppDbContext db, TimeProvider timeProvider, ILogger logger) + { + _db = db; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task SeedAsync(CancellationToken ct = default) + { + byte[] manifestBytes; + await using (var stream = typeof(GameCapabilitySeeder).Assembly.GetManifestResourceStream(ResourceName)) + { + if (stream is null) + return Fail("Embedded capability seed manifest resource not found."); + + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer, ct); + manifestBytes = buffer.ToArray(); + } + + CapabilitySeedManifest manifest; + try + { + manifest = JsonSerializer.Deserialize(manifestBytes, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new JsonException("Manifest deserialized to null."); + } + catch (JsonException ex) + { + return Fail($"Manifest failed to parse: {ex.Message}"); + } + + var structuralError = ValidateStructure(manifest); + if (structuralError is not null) + return Fail(structuralError); + + var checksum = Convert.ToHexString(SHA256.HashData(manifestBytes)).ToLowerInvariant(); + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + await _db.Database.ExecuteSqlRawAsync( + $"SELECT pg_advisory_xact_lock({CapabilitySeedAdvisoryLockKey});", ct); + + var existingHistory = await _db.CapabilitySeedHistory + .FirstOrDefaultAsync(h => h.ManifestVersion == manifest.ManifestVersion, ct); + + if (existingHistory is not null) + { + if (existingHistory.Checksum != checksum) + { + await transaction.RollbackAsync(ct); + var message = + $"Checksum mismatch for capability manifest version '{manifest.ManifestVersion}', refusing to overwrite."; + _logger.LogError("Capability seed failed: {Message}", message); + return new CapabilitySeedResult(false, message, 0, 0); + } + + await transaction.CommitAsync(ct); + var noopMessage = $"Capability manifest version '{manifest.ManifestVersion}' already applied; no-op."; + _logger.LogInformation("Capability seed no-op: {Message}", noopMessage); + return new CapabilitySeedResult(true, noopMessage, 0, 0); + } + + // Cross-check every profile against the live M4 catalog before writing anything. Done inside the + // transaction so a concurrent catalog change cannot slip between the check and the write. + var catalogError = await ValidateAgainstCatalogAsync(manifest, ct); + if (catalogError is not null) + { + await transaction.RollbackAsync(ct); + return Fail(catalogError); + } + + var created = 0; + var updated = 0; + var appliedAtUtc = _timeProvider.GetUtcNow().UtcDateTime; + + try + { + foreach (var entry in manifest.Profiles) + { + var existing = await _db.GameCapabilityProfiles.FirstOrDefaultAsync( + p => p.GameSlug == entry.GameSlug && p.CapabilityVersion == entry.CapabilityVersion, ct); + + if (existing is null) + { + _db.GameCapabilityProfiles.Add(GameCapabilityProfile.Create( + entry.GameSlug, + entry.CapabilityVersion, + entry.MinPlayers, + entry.MaxPlayers, + entry.AllowedModes, + entry.TimeControls, + entry.TieBreakRules, + entry.SpectatorPolicies, + entry.RatedEligible, + entry.AiFillEligible, + manifest.ManifestVersion)); + created++; + } + else + { + existing.ApplyManifestUpdate( + entry.MinPlayers, + entry.MaxPlayers, + entry.AllowedModes, + entry.TimeControls, + entry.TieBreakRules, + entry.SpectatorPolicies, + entry.RatedEligible, + entry.AiFillEligible, + entry.IsActive, + manifest.ManifestVersion); + updated++; + } + } + + _db.CapabilitySeedHistory.Add( + CapabilitySeedHistory.Record(manifest.ManifestVersion, checksum, appliedAtUtc)); + + await _db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + } + catch (DbUpdateException ex) + { + await transaction.RollbackAsync(ct); + var message = $"Failed to apply capability manifest version '{manifest.ManifestVersion}': {ex.Message}"; + _logger.LogError(ex, "Capability seed failed: {Message}", message); + return new CapabilitySeedResult(false, message, 0, 0); + } + catch (ArgumentException ex) + { + // A domain invariant the structural validator did not cover. Fail closed rather than write a profile + // the domain would reject on read. + await transaction.RollbackAsync(ct); + var message = $"Capability manifest version '{manifest.ManifestVersion}' violates a domain invariant: {ex.Message}"; + _logger.LogError(ex, "Capability seed failed: {Message}", message); + return new CapabilitySeedResult(false, message, 0, 0); + } + + var successMessage = + $"Applied capability manifest version '{manifest.ManifestVersion}': {created} created, {updated} updated."; + _logger.LogInformation("Capability seed succeeded: {Message}", successMessage); + return new CapabilitySeedResult(true, successMessage, created, updated); + } + + private CapabilitySeedResult Fail(string message) + { + _logger.LogError("Capability seed failed: {Message}", message); + return new CapabilitySeedResult(false, message, 0, 0); + } + + /// + /// Structural validation of the whole manifest before any database access. Domain-invariant validation + /// (allow-lists, rated/AI mode implications) is enforced by itself, + /// so it is deliberately not duplicated here — only the constraints the domain cannot see are checked. + /// Returns null when valid. + /// + private static string? ValidateStructure(CapabilitySeedManifest manifest) + { + if (string.IsNullOrWhiteSpace(manifest.ManifestVersion)) + return "manifestVersion must not be empty."; + + if (manifest.Profiles is null || manifest.Profiles.Count == 0) + return "profiles must contain at least one entry."; + + var seenPins = new HashSet(StringComparer.Ordinal); + var activeBySlug = new HashSet(StringComparer.Ordinal); + + foreach (var entry in manifest.Profiles) + { + if (string.IsNullOrWhiteSpace(entry.GameSlug)) + return "A profile entry is missing a gameSlug."; + + var pin = $"{entry.GameSlug}@{entry.CapabilityVersion}"; + if (!seenPins.Add(pin)) + return $"Duplicate pin '{pin}' in manifest."; + + // At most one active profile per game — the same rule the partial unique index enforces. Catching it + // here turns a confusing 23505 at write time into a clear manifest error. + if (entry.IsActive && !activeBySlug.Add(entry.GameSlug)) + return $"[{entry.GameSlug}] has more than one active capability profile in the manifest."; + } + + return null; + } + + /// + /// Every profile must name a real catalog game and be a subset of it. Uses the domain's own + /// so the seeder and the runtime command path can never + /// disagree about what "contradicts the catalog" means. + /// + private async Task ValidateAgainstCatalogAsync(CapabilitySeedManifest manifest, CancellationToken ct) + { + var slugs = manifest.Profiles.Select(p => p.GameSlug).Distinct(StringComparer.Ordinal).ToList(); + + var games = await _db.Games + .Where(g => slugs.Contains(g.Slug)) + .Include(g => g.Capabilities) + .ToListAsync(ct); + + var bySlug = games.ToDictionary(g => g.Slug, StringComparer.Ordinal); + + foreach (var entry in manifest.Profiles) + { + if (!bySlug.TryGetValue(entry.GameSlug, out var game)) + { + return $"[{entry.GameSlug}] names a game that is not in the Module 4 catalog. " + + "Seed the game catalog first (--seed-game-catalog)."; + } + + GameCapabilityProfile candidate; + try + { + candidate = GameCapabilityProfile.Create( + entry.GameSlug, + entry.CapabilityVersion, + entry.MinPlayers, + entry.MaxPlayers, + entry.AllowedModes, + entry.TimeControls, + entry.TieBreakRules, + entry.SpectatorPolicies, + entry.RatedEligible, + entry.AiFillEligible, + manifest.ManifestVersion); + } + catch (ArgumentException ex) + { + return $"[{entry.GameSlug}@{entry.CapabilityVersion}] {ex.Message}"; + } + + var drift = candidate.ContradictsCatalog( + game.MinPlayers, + game.MaxPlayers, + game.Capabilities.Select(c => c.Mode)); + + if (!drift.Allowed) + return $"[{entry.GameSlug}@{entry.CapabilityVersion}] {drift.Reason}"; + } + + return null; + } +} diff --git a/src/SimPle.Infrastructure/Capabilities/capability.seed.schema.json b/src/SimPle.Infrastructure/Capabilities/capability.seed.schema.json new file mode 100644 index 0000000..529c468 --- /dev/null +++ b/src/SimPle.Infrastructure/Capabilities/capability.seed.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://simple.gg/schemas/capability.seed.v1.json", + "title": "Module 6 game capability seed manifest", + "description": "Declares what a lobby or ticket may configure for each game, at a pinned (gameSlug, capabilityVersion). Additive to Module 4's catalog: it never mutates or re-seeds games. Structural validation only — the seeder additionally enforces every domain invariant (allow-list membership, subset-of-catalog, rated/AI mode implications) before any database write.", + "type": "object", + "required": ["manifestVersion", "profiles"], + "additionalProperties": true, + "properties": { + "$comment": { "type": "string" }, + "manifestVersion": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "description": "Checksummed and recorded in capability_seed_history. Re-applying the same version with different content fails closed." + }, + "profiles": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/profile" } + } + }, + "$defs": { + "profile": { + "type": "object", + "additionalProperties": false, + "required": [ + "gameSlug", + "capabilityVersion", + "minPlayers", + "maxPlayers", + "allowedModes", + "timeControls", + "tieBreakRules", + "spectatorPolicies", + "ratedEligible", + "aiFillEligible", + "isActive" + ], + "properties": { + "gameSlug": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Foreign key to games.slug. The game must already exist in Module 4's catalog." + }, + "capabilityVersion": { + "type": "integer", + "minimum": 1, + "description": "The pin. Publishing different capabilities means publishing a NEW version row — never editing an existing one, because a lobby that pinned v1 must keep meaning what it meant at creation." + }, + "minPlayers": { + "type": "integer", + "minimum": 2, + "maximum": 8, + "description": "At least 2: a lobby is a multiplayer surface. A game's solo mode is reachable from the library, not from a lobby." + }, + "maxPlayers": { "type": "integer", "minimum": 2, "maximum": 8 }, + "allowedModes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["solo", "cooperative", "multiplayer", "ai", "ranked", "quick-match"] + }, + "description": "Must be a subset of the Module 4 catalog row's modes for the same slug." + }, + "timeControls": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["untimed", "bullet-1-0", "blitz-3-2", "blitz-5-0", "rapid-10-0", "classical-30-0"] + } + }, + "tieBreakRules": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["none", "sudden-death", "fastest-finish", "highest-score", "fewest-moves"] + } + }, + "spectatorPolicies": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "enum": ["Anyone", "FriendsOnly", "Disabled"] } + }, + "ratedEligible": { + "type": "boolean", + "description": "Requires 'ranked' in allowedModes. Rating itself is provisional until Module 10." + }, + "aiFillEligible": { + "type": "boolean", + "description": "Requires 'ai' in allowedModes. Storing the request is all Module 6 does — no AI participant can be created before Module 9." + }, + "isActive": { + "type": "boolean", + "description": "At most one active profile per game. Deactivating one is what makes 'capability disabled after create' a real, testable path for lobbies already pinned to it." + } + } + } + } +} diff --git a/src/SimPle.Infrastructure/Capabilities/capability.seed.v1.json b/src/SimPle.Infrastructure/Capabilities/capability.seed.v1.json new file mode 100644 index 0000000..c0aa987 --- /dev/null +++ b/src/SimPle.Infrastructure/Capabilities/capability.seed.v1.json @@ -0,0 +1,110 @@ +{ + "$comment": "Module 6 capability profiles (D2). Every profile is a STRICT SUBSET of the Module 4 catalog row for the same slug: player bounds sit inside the catalog's, and allowedModes is a subset of the catalog's modes. GameCapabilityProfile.ContradictsCatalog() enforces exactly that, and the seeder refuses to write a manifest that violates it. Profiles pin at minPlayers >= 2 because a lobby is a multiplayer surface: a game's solo mode is reachable from the library, not from a lobby.", + "manifestVersion": "2026.1", + "profiles": [ + { + "gameSlug": "online-sudoku", + "capabilityVersion": 1, + "minPlayers": 2, + "maxPlayers": 2, + "allowedModes": ["cooperative"], + "timeControls": ["untimed", "rapid-10-0", "classical-30-0"], + "tieBreakRules": ["none", "fastest-finish"], + "spectatorPolicies": ["Anyone", "FriendsOnly", "Disabled"], + "ratedEligible": false, + "aiFillEligible": false, + "isActive": true + }, + { + "gameSlug": "falling-blocks-arena", + "capabilityVersion": 1, + "minPlayers": 2, + "maxPlayers": 4, + "allowedModes": ["multiplayer", "quick-match"], + "timeControls": ["blitz-3-2", "blitz-5-0"], + "tieBreakRules": ["none", "highest-score"], + "spectatorPolicies": ["Anyone", "FriendsOnly", "Disabled"], + "ratedEligible": false, + "aiFillEligible": false, + "isActive": true + }, + { + "gameSlug": "four-in-a-row", + "capabilityVersion": 1, + "minPlayers": 2, + "maxPlayers": 2, + "allowedModes": ["multiplayer", "ranked", "ai"], + "timeControls": ["untimed", "blitz-3-2", "blitz-5-0", "rapid-10-0"], + "tieBreakRules": ["none", "sudden-death"], + "spectatorPolicies": ["Anyone", "FriendsOnly", "Disabled"], + "ratedEligible": true, + "aiFillEligible": true, + "isActive": true + }, + { + "gameSlug": "chess-lite", + "capabilityVersion": 1, + "minPlayers": 2, + "maxPlayers": 2, + "allowedModes": ["multiplayer", "ranked", "ai"], + "timeControls": ["bullet-1-0", "blitz-3-2", "blitz-5-0", "rapid-10-0", "classical-30-0"], + "tieBreakRules": ["none", "sudden-death", "fewest-moves"], + "spectatorPolicies": ["Anyone", "FriendsOnly", "Disabled"], + "ratedEligible": true, + "aiFillEligible": true, + "isActive": true + }, + { + "gameSlug": "checkers", + "capabilityVersion": 1, + "minPlayers": 2, + "maxPlayers": 2, + "allowedModes": ["multiplayer", "ranked", "ai"], + "timeControls": ["blitz-3-2", "blitz-5-0", "rapid-10-0", "classical-30-0"], + "tieBreakRules": ["none", "sudden-death"], + "spectatorPolicies": ["Anyone", "FriendsOnly", "Disabled"], + "ratedEligible": true, + "aiFillEligible": true, + "isActive": true + }, + { + "gameSlug": "five-letter-duel", + "capabilityVersion": 1, + "minPlayers": 2, + "maxPlayers": 2, + "allowedModes": ["multiplayer"], + "timeControls": ["untimed", "blitz-3-2", "rapid-10-0"], + "tieBreakRules": ["none", "fewest-moves", "fastest-finish"], + "spectatorPolicies": ["Anyone", "FriendsOnly", "Disabled"], + "ratedEligible": false, + "aiFillEligible": false, + "isActive": true + }, + { + "gameSlug": "memory-grid", + "capabilityVersion": 1, + "minPlayers": 2, + "maxPlayers": 4, + "allowedModes": ["cooperative", "multiplayer"], + "timeControls": ["untimed", "blitz-3-2", "rapid-10-0"], + "tieBreakRules": ["none", "fastest-finish", "highest-score"], + "spectatorPolicies": ["Anyone", "FriendsOnly", "Disabled"], + "ratedEligible": false, + "aiFillEligible": false, + "isActive": true + }, + { + "gameSlug": "snake-rush", + "capabilityVersion": 1, + "minPlayers": 2, + "maxPlayers": 8, + "allowedModes": ["multiplayer", "quick-match"], + "timeControls": ["blitz-3-2", "blitz-5-0"], + "tieBreakRules": ["none", "highest-score"], + "spectatorPolicies": ["Anyone", "FriendsOnly", "Disabled"], + "ratedEligible": false, + "aiFillEligible": false, + "isActive": true + } + ] +} diff --git a/src/SimPle.Infrastructure/DependencyInjection.cs b/src/SimPle.Infrastructure/DependencyInjection.cs index a9e297f..4b8229b 100644 --- a/src/SimPle.Infrastructure/DependencyInjection.cs +++ b/src/SimPle.Infrastructure/DependencyInjection.cs @@ -4,8 +4,12 @@ using Microsoft.Extensions.Hosting; using SimPle.Application.Common.Interfaces; using SimPle.Application.Common.Options; +using SimPle.Application.Lobbies.Services; using SimPle.Infrastructure.Auth; using SimPle.Infrastructure.Email; +using SimPle.Infrastructure.Lobbies; +using SimPle.Infrastructure.Matchmaking; +using SimPle.Infrastructure.Outbox; using SimPle.Infrastructure.Persistence; using SimPle.Infrastructure.Persistence.Repositories; using SimPle.Infrastructure.Storage; @@ -42,6 +46,28 @@ public static IServiceCollection AddInfrastructureServices( services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // Module 6 — injected clock (R4). Scoped to M6-owned code only: the 66 pre-existing raw DateTime.UtcNow + // call sites across the codebase are deliberately NOT refactored, because a half-done cross-cutting clock + // change would be worse than none. M6's own code takes TimeProvider so the mandatory fake-clock tests of + // the 15/30/60-second bands and the 2h/30min expiries (brief Risk #8) are actually provable. + services.AddSingleton(TimeProvider.System); + + services.AddScoped(); + services.AddScoped(); + + // R3 — reruns a whole lobby command (read + decide + write) on contention and surfaces a typed conflict. + // Scoped, because it clears the change tracker of the same scoped AppDbContext the command reads through. + services.AddScoped(); + + // Honest dependency probes. Every one reports "not available" because M7/M8/M9 do not exist — which is + // what makes Start a 503, `allowedActions` omit `start`, and the UI's disabled controls truthful rather + // than decorative. Each is replaced, not rewritten, when its module lands. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); services.Configure(configuration.GetSection(StorageOptions.SectionName)); services.PostConfigure(options => { @@ -58,6 +84,25 @@ public static IServiceCollection AddInfrastructureServices( configuration.GetSection(DismissedSuggestionCleanupOptions.SectionName)); services.AddHostedService(); + // ── Module 6, slice 6C — matchmaking, expiry, and the outbox dispatcher ── + + services.AddScoped(); + services.AddScoped(); + + // ILobbyCommandRunner's sibling for background work: same transaction and contention semantics, no advisory + // lock (a worker has no actor to serialize). + services.AddScoped(); + + services.Configure(configuration.GetSection(MatchmakingOptions.SectionName)); + services.Configure(configuration.GetSection(ExpiryOptions.SectionName)); + services.Configure(configuration.GetSection(OutboxOptions.SectionName)); + + // All three honour a WorkerEnabled flag and simply do not start when it is false — that is how the rollback + // plan disables the workers while preserving every lobby and ticket record. + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + return services; } diff --git a/src/SimPle.Infrastructure/Lobbies/DependencyProbes.cs b/src/SimPle.Infrastructure/Lobbies/DependencyProbes.cs new file mode 100644 index 0000000..628b540 --- /dev/null +++ b/src/SimPle.Infrastructure/Lobbies/DependencyProbes.cs @@ -0,0 +1,41 @@ +using SimPle.Application.Lobbies.Services; + +namespace SimPle.Infrastructure.Lobbies; + +/// +/// Module 8 is not built. This reports that truthfully rather than pretending, and it is the single switch that +/// keeps every one of the brief's honesty promises enforceable: +/// Start returns Lobbies.MatchRuntimeUnavailable, the lobby stays Open, allowedActions omits +/// start, and dependencyReadiness.matchRuntime is false — so no client can invent a room +/// (Risk #6). +/// +/// When M8 lands it replaces this registration. Nothing in the lobby commands changes. +/// +public sealed class NoMatchRuntimeProbe : IMatchRuntimeProbe +{ + public Task IsAvailableAsync(CancellationToken ct = default) => Task.FromResult(false); + + /// + /// Always false — and this is a true answer, not a stub returning a placeholder. No match runtime + /// exists, therefore no match exists, therefore no user is in one. The call site is real so that M8 has only + /// to implement the probe, not to go find every place the question should have been asked. + /// + public Task IsInActiveMatchAsync(Guid userId, CancellationToken ct = default) => + Task.FromResult(false); +} + +/// Module 7 owns chat and live delivery. The lobby polls; there is no push here. +public sealed class NoChatRuntimeProbe : IChatRuntimeProbe +{ + public Task IsAvailableAsync(CancellationToken ct = default) => Task.FromResult(false); +} + +/// +/// Module 9 owns AI participants. aiFillRequested is stored and displayed, but no AI seat can be created, +/// which is why a rated start is refused while it is set — a "ranked" match with an unfillable seat would either +/// hang or silently become unranked. +/// +public sealed class NoAiParticipantProbe : IAiParticipantProbe +{ + public Task IsAvailableAsync(CancellationToken ct = default) => Task.FromResult(false); +} diff --git a/src/SimPle.Infrastructure/Lobbies/HmacLobbyCredentialHasher.cs b/src/SimPle.Infrastructure/Lobbies/HmacLobbyCredentialHasher.cs new file mode 100644 index 0000000..492e120 --- /dev/null +++ b/src/SimPle.Infrastructure/Lobbies/HmacLobbyCredentialHasher.cs @@ -0,0 +1,77 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using SimPle.Application.Common.Options; +using SimPle.Application.Lobbies.Services; +using SimPle.Domain.Lobbies; + +namespace SimPle.Infrastructure.Lobbies; + +/// +/// HMAC-SHA256 keyed digests for lobby join credentials, compared with +/// . +/// +/// The key is held here and never leaves: the domain entity stores only digests, so no plaintext credential exists +/// anywhere it could reach a log line, an outbox payload, or a DTO (Risk #7). +/// +public sealed class HmacLobbyCredentialHasher : ILobbyCredentialHasher +{ + private readonly byte[] _key; + + public HmacLobbyCredentialHasher(IOptions options) + { + var key = options.Value.Key; + if (string.IsNullOrWhiteSpace(key)) + { + throw new InvalidOperationException( + "LobbyCredential:Key is not configured. Set it outside committed appsettings " + + "(environment variable LobbyCredential__Key)."); + } + + _key = Encoding.UTF8.GetBytes(key); + } + + public string HashCode(string plaintextCode) + { + ArgumentNullException.ThrowIfNull(plaintextCode); + return Digest(LobbyCredentialFormat.NormalizeCode(plaintextCode)); + } + + public string HashLinkToken(string plaintextLinkToken) + { + ArgumentNullException.ThrowIfNull(plaintextLinkToken); + return Digest(plaintextLinkToken); + } + + public bool DigestsMatch(string storedDigest, string candidateDigest) + { + if (storedDigest is null || candidateDigest is null) return false; + + // Compare the decoded bytes, not the strings: FixedTimeEquals needs equal-length spans, and two valid + // digests are always the same length. A malformed stored value fails closed rather than throwing. + if (!TryDecode(storedDigest, out var stored) || !TryDecode(candidateDigest, out var candidate)) + return false; + + return CryptographicOperations.FixedTimeEquals(stored, candidate); + } + + private string Digest(string plaintext) + { + var mac = HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes(plaintext)); + return Convert.ToHexString(mac).ToLowerInvariant(); + } + + private static bool TryDecode(string digest, out byte[] bytes) + { + try + { + bytes = Convert.FromHexString(digest); + return bytes.Length == SHA256.HashSizeInBytes; + } + catch (FormatException) + { + bytes = Array.Empty(); + return false; + } + } +} diff --git a/src/SimPle.Infrastructure/Lobbies/MemoryCacheLobbyJoinThrottle.cs b/src/SimPle.Infrastructure/Lobbies/MemoryCacheLobbyJoinThrottle.cs new file mode 100644 index 0000000..0184289 --- /dev/null +++ b/src/SimPle.Infrastructure/Lobbies/MemoryCacheLobbyJoinThrottle.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.Caching.Memory; +using SimPle.Application.Lobbies.Services; + +namespace SimPle.Infrastructure.Lobbies; + +/// +/// Counts failed join-credential attempts per account and locks the actor out of the join endpoint once +/// they cross the threshold (OWASP API4:2023). +/// +/// +/// This is not an ASP.NET rate-limit policy, and the difference is the point. A rate limiter spends a permit on +/// every request, so a window tight enough to make guessing a 60-bit code hopeless would equally punish a member +/// legitimately joining lobbies they were invited to. Only failures are counted here, so a caller who supplies +/// correct credentials is never throttled by this at all, no matter how often they join. +/// +/// +/// +/// Backed by , the same in-process store the revoked-JTI cache already uses. That means +/// it is per-instance: an attacker spraying codes across N app instances gets N times the budget. +/// The pre-existing per-account rate-limit policies have exactly this property, so this adds no new class of gap — +/// but it is recorded as a known limitation rather than papered over, and a distributed store is the fix when the +/// platform runs more than one instance. +/// +/// +public sealed class MemoryCacheLobbyJoinThrottle : ILobbyJoinThrottle +{ + private readonly IMemoryCache _cache; + private readonly TimeProvider _clock; + + /// + /// Ten wrong codes buys a five-minute lockout. Against a 60-bit space, ten guesses per five minutes is not a + /// meaningful search — the entropy does the real work, and this only removes the free unlimited retries that + /// would let a bot grind the space over weeks. + /// + private const int MaxFailures = 10; + + private static readonly TimeSpan FailureWindow = TimeSpan.FromMinutes(5); + + public MemoryCacheLobbyJoinThrottle(IMemoryCache cache, TimeProvider clock) + { + _cache = cache; + _clock = clock; + } + + public Task GetRetryAfterUtcAsync(Guid actorUserId, CancellationToken ct = default) + { + if (_cache.TryGetValue(Key(actorUserId), out var streak) + && streak is not null + && streak.Count >= MaxFailures) + { + return Task.FromResult(streak.WindowEndsAtUtc); + } + + return Task.FromResult(null); + } + + public Task RecordFailureAsync(Guid actorUserId, CancellationToken ct = default) + { + var key = Key(actorUserId); + var nowUtc = _clock.GetUtcNow().UtcDateTime; + + var streak = _cache.TryGetValue(key, out var existing) && existing is not null + ? existing with { Count = existing.Count + 1 } + // The window is anchored at the *first* failure and does not slide. A sliding window would let a + // patient attacker sit just under the threshold forever, refreshing it with every guess. + : new FailureStreak(1, nowUtc + FailureWindow); + + _cache.Set(key, streak, streak.WindowEndsAtUtc); + return Task.CompletedTask; + } + + public Task ClearAsync(Guid actorUserId, CancellationToken ct = default) + { + _cache.Remove(Key(actorUserId)); + return Task.CompletedTask; + } + + private static string Key(Guid actorUserId) => $"lobby-join-failures:{actorUserId:N}"; + + private sealed record FailureStreak(int Count, DateTime WindowEndsAtUtc); +} diff --git a/src/SimPle.Infrastructure/Matchmaking/LobbyExpiryWorker.cs b/src/SimPle.Infrastructure/Matchmaking/LobbyExpiryWorker.cs new file mode 100644 index 0000000..7449244 --- /dev/null +++ b/src/SimPle.Infrastructure/Matchmaking/LobbyExpiryWorker.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimPle.Application.Common.Options; +using SimPle.Application.Expiry; + +namespace SimPle.Infrastructure.Matchmaking; + +/// +/// Hosts the expiry sweep: tickets past 60 seconds, lobbies past 2 hours, invites past 30 minutes. +/// +/// +/// Unlike this runs with or without Module 8, and that asymmetry is +/// the point. Matching without a match runtime would fabricate an opponent; expiring without one fabricates nothing. +/// It is what lets a Phase-1 player enqueue, watch their band widen, and receive an honest TimedOut — rather +/// than a ticket that sits Queued forever because the only thing that could ever have resolved it does not +/// exist yet. +/// +/// +public sealed class LobbyExpiryWorker : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ExpiryOptions _options; + private readonly ILogger _logger; + + public LobbyExpiryWorker( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.WorkerEnabled) + { + _logger.LogInformation("Lobby expiry worker is disabled by configuration; not starting."); + return; + } + + _logger.LogInformation( + "Lobby expiry worker started. Interval={Interval} BatchSize={BatchSize}", + _options.Interval, _options.BatchSize); + + while (!stoppingToken.IsCancellationRequested) + { + await SweepAsync(stoppingToken); + + try + { + await Task.Delay(_options.Interval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task SweepAsync(CancellationToken ct) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var sweeper = scope.ServiceProvider.GetRequiredService(); + + var result = await sweeper.SweepAsync(ct); + + // The sweeper already logs the detail when it does work; a line per empty tick would drown it. + if (result.Total > 0 && result.MaxTicketLag > TimeSpan.FromSeconds(5)) + { + // The benchmark's budget. Crossing it does not break correctness — an expired ticket is already + // unusable on read — but it means players are watching a dead ticket, so it is worth saying loudly. + _logger.LogWarning( + "Expiry lag exceeded the 5s budget. MaxTicketLagMs={LagMs} Tickets={Tickets}", + (long)result.MaxTicketLag.TotalMilliseconds, result.TicketsExpired); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Idempotent by construction: every transition it drives is a TryExpire/TryTimeOut that returns false + // rather than transitioning twice, so a failed sweep leaves nothing half-done and the next tick simply + // sees the same overdue rows. + _logger.LogError(ex, "Expiry sweep failed. Will retry in {Interval}.", _options.Interval); + } + } +} diff --git a/src/SimPle.Infrastructure/Matchmaking/MatchmakingWorker.cs b/src/SimPle.Infrastructure/Matchmaking/MatchmakingWorker.cs new file mode 100644 index 0000000..aeaa08b --- /dev/null +++ b/src/SimPle.Infrastructure/Matchmaking/MatchmakingWorker.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimPle.Application.Common.Options; +using SimPle.Application.Matchmaking.Services; + +namespace SimPle.Infrastructure.Matchmaking; + +/// +/// Hosts the matching loop. Deliberately thin: every decision lives in , and +/// this class only decides when to ask for one. +/// +/// +/// That split is what makes the module's central guarantee provable. "Two competing workers never double-assign a +/// ticket" is asserted by running two coordinators concurrently against real PostgreSQL — which is possible only +/// because a cycle is a callable method, not a timer tick buried in a hosted service. +/// +/// +/// +/// The worker identity is per-process and stable for the process's life. It is recorded on every ticket it +/// claims and survives onto terminal rows, which is what lets a worker that consistently loses its handoffs be +/// identified from the data alone (the matchmaking-worker-failure signal) instead of by correlating logs. +/// +/// +public sealed class MatchmakingWorker : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly MatchmakingOptions _options; + private readonly ILogger _logger; + private readonly string _workerId; + + public MatchmakingWorker( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory; + _options = options.Value; + _logger = logger; + + // Machine name plus a random suffix: two instances on one host must not share an id, or their claims become + // indistinguishable in exactly the situation the id exists to disambiguate. Budgeted to fit + // matchmaking_tickets."ClaimedByWorker" (varchar(64)) exactly: 31 + 1 + 32. + var host = Environment.MachineName; + if (host.Length > 31) host = host[..31]; + _workerId = $"{host}-{Guid.NewGuid():N}"; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.WorkerEnabled) + { + // The rollback plan calls for disabling the workers while preserving every lobby and ticket record. + // Not hosting the loop is how that is done; nothing else changes. + _logger.LogInformation("Matchmaking worker is disabled by configuration; not starting."); + return; + } + + _logger.LogInformation( + "Matchmaking worker started. WorkerId={WorkerId} Interval={Interval} BatchSize={BatchSize}", + _workerId, _options.Interval, _options.BatchSize); + + while (!stoppingToken.IsCancellationRequested) + { + await RunCycleAsync(stoppingToken); + + try + { + await Task.Delay(_options.Interval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task RunCycleAsync(CancellationToken ct) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var coordinator = scope.ServiceProvider.GetRequiredService(); + + var result = await coordinator.RunCycleAsync(_workerId, ct); + + // Only log a cycle that did something. Before Module 8 the coordinator returns Disabled on every tick, + // and a line for each would bury every other signal in the log within minutes. + if (result.TicketsMatched > 0) + { + _logger.LogInformation( + "Matchmaking cycle. Worker={WorkerId} Claimed={Claimed} Proposals={Proposals} Matched={Matched} OldestQueuedMs={OldestMs}", + _workerId, result.TicketsClaimed, result.ProposalsFormed, result.TicketsMatched, + (long?)result.OldestQueuedAge?.TotalMilliseconds); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A failed cycle is survivable by construction: the transaction rolled back, which released the + // FOR UPDATE SKIP LOCKED row locks, which returned every claimed ticket to Queued. There is nothing to + // compensate and nothing to clean up — the next cycle simply sees them again. + _logger.LogError( + ex, "Matchmaking cycle failed; claimed tickets were rolled back to Queued. Worker={WorkerId}", _workerId); + } + } +} 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("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("profile_external_links", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "NormalizedName") + .IsUnique(); + + b.ToTable("profile_interest_tags", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.RetiredUsername", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PriorOwnerUserId") + .HasColumnType("uuid"); + + b.Property("RetiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("PriorOwnerUserId"); + + b.ToTable("retired_usernames", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedRequestedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RejectionReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RequestMonth") + .HasColumnType("integer"); + + b.Property("RequestYear") + .HasColumnType("integer"); + + b.Property("RequestedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedBy") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Status"); + + b.HasIndex("UserId", "RequestYear", "RequestMonth"); + + b.ToTable("username_change_requests", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("email_verification_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarObjectKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BannerFallbackColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("BannerObjectKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("BannerUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Elo") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("GoogleId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Initials") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character varying(4)"); + + b.Property("IsEmailVerified") + .HasColumnType("boolean"); + + b.Property("IsSuspended") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsernameAdminRequestMonth") + .HasColumnType("integer"); + + b.Property("LastUsernameAdminRequestYear") + .HasColumnType("integer"); + + b.Property("LastUsernameImmediateChangeMonth") + .HasColumnType("integer"); + + b.Property("LastUsernameImmediateChangeYear") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfileType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Player"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubscriptionTier") + .IsRequired() + .HasColumnType("text"); + + b.Property("SuspendedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Visibility") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Public"); + + b.Property("Xp") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GoogleId") + .IsUnique() + .HasFilter("\"GoogleId\" IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .IsUnique(); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Capabilities.GameCapabilityProfile", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany() + .HasForeignKey("GameSlug") + .HasPrincipalKey("Slug") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("BlockedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("BlockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.DismissedFriendSuggestion", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("SuggestedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Friendship", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.UserFriendSettings", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Capabilities") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameTag", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Tags") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.Lobby", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("HostUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyInvite", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("InviteeUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("InviterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyJoinCredential", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyMember", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany("Members") + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyStartRequest", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingAssignment", b => + { + b.HasOne("SimPle.Domain.Matchmaking.MatchmakingTicket", null) + .WithMany() + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingTicket", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => + { + b.HasOne("SimPle.Domain.Outbox.OutboxMessage", null) + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileExternalLink", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.Game", b => + { + b.Navigation("Capabilities"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.Lobby", b => + { + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SimPle.Infrastructure/Migrations/20260711195731_AddLobbyMatchmakingAndCapabilities.cs b/src/SimPle.Infrastructure/Migrations/20260711195731_AddLobbyMatchmakingAndCapabilities.cs new file mode 100644 index 0000000..f2b6858 --- /dev/null +++ b/src/SimPle.Infrastructure/Migrations/20260711195731_AddLobbyMatchmakingAndCapabilities.cs @@ -0,0 +1,497 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SimPle.Infrastructure.Migrations +{ + /// + public partial class AddLobbyMatchmakingAndCapabilities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddUniqueConstraint( + name: "AK_games_Slug", + table: "games", + column: "Slug"); + + migrationBuilder.CreateTable( + name: "capability_seed_history", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ManifestVersion = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Checksum = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + AppliedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_capability_seed_history", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "game_capability_profiles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GameSlug = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CapabilityVersion = table.Column(type: "integer", nullable: false), + MinPlayers = table.Column(type: "integer", nullable: false), + MaxPlayers = table.Column(type: "integer", nullable: false), + AllowedModes = table.Column>(type: "text[]", nullable: false), + TimeControls = table.Column>(type: "text[]", nullable: false), + TieBreakRules = table.Column>(type: "text[]", nullable: false), + SpectatorPolicies = table.Column>(type: "text[]", nullable: false), + RatedEligible = table.Column(type: "boolean", nullable: false), + AiFillEligible = table.Column(type: "boolean", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + ManifestVersion = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_game_capability_profiles", x => x.Id); + table.CheckConstraint("ck_game_capability_profiles_modes_nonempty", "cardinality(\"AllowedModes\") > 0"); + table.CheckConstraint("ck_game_capability_profiles_players", "\"MinPlayers\" >= 2 AND \"MinPlayers\" <= \"MaxPlayers\" AND \"MaxPlayers\" <= 8"); + table.CheckConstraint("ck_game_capability_profiles_spectators_nonempty", "cardinality(\"SpectatorPolicies\") > 0"); + table.CheckConstraint("ck_game_capability_profiles_tie_breaks_nonempty", "cardinality(\"TieBreakRules\") > 0"); + table.CheckConstraint("ck_game_capability_profiles_time_controls_nonempty", "cardinality(\"TimeControls\") > 0"); + table.CheckConstraint("ck_game_capability_profiles_version", "\"CapabilityVersion\" >= 1"); + table.ForeignKey( + name: "FK_game_capability_profiles_games_GameSlug", + column: x => x.GameSlug, + principalTable: "games", + principalColumn: "Slug", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "lobbies", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GameSlug = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CapabilityVersion = table.Column(type: "integer", nullable: false), + HostUserId = table.Column(type: "uuid", nullable: false), + Privacy = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + MaxPlayers = table.Column(type: "integer", nullable: false), + TimeControlId = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Rated = table.Column(type: "boolean", nullable: false), + ResolvedRegion = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + SpectatorPolicy = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + TieBreakRuleId = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + AiFillRequested = table.Column(type: "boolean", nullable: false), + State = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Revision = table.Column(type: "integer", nullable: false, defaultValue: 1), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ClosedReason = table.Column(type: "character varying(24)", maxLength: 24, nullable: true), + CorrelationId = table.Column(type: "uuid", nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_lobbies", x => x.Id); + table.CheckConstraint("ck_lobbies_capability_version", "\"CapabilityVersion\" >= 1"); + table.CheckConstraint("ck_lobbies_closed_reason_iff_terminal", "(\"State\" IN ('Closed', 'Expired')) = (\"ClosedReason\" IS NOT NULL)"); + table.CheckConstraint("ck_lobbies_max_players", "\"MaxPlayers\" >= 2 AND \"MaxPlayers\" <= 8"); + table.CheckConstraint("ck_lobbies_revision", "\"Revision\" >= 1"); + table.ForeignKey( + name: "FK_lobbies_users_HostUserId", + column: x => x.HostUserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "matchmaking_tickets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + GameSlug = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CapabilityVersion = table.Column(type: "integer", nullable: false), + Mode = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + PlayerCount = table.Column(type: "integer", nullable: false), + TimeControlId = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Rated = table.Column(type: "boolean", nullable: false), + ResolvedRegion = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Rating = table.Column(type: "integer", nullable: false), + RatingSourceVersion = table.Column(type: "character varying(48)", maxLength: 48, nullable: false), + State = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + EnqueuedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + DeadlineAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RetryBudget = table.Column(type: "integer", nullable: false), + ClaimedByWorker = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + ClaimedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + ResolvedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CorrelationId = table.Column(type: "uuid", nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_matchmaking_tickets", x => x.Id); + table.CheckConstraint("ck_matchmaking_tickets_capability_version", "\"CapabilityVersion\" >= 1"); + table.CheckConstraint("ck_matchmaking_tickets_deadline_after_enqueue", "\"DeadlineAtUtc\" > \"EnqueuedAtUtc\""); + table.CheckConstraint("ck_matchmaking_tickets_no_worker_while_queued", "\"State\" <> 'Queued' OR \"ClaimedByWorker\" IS NULL"); + table.CheckConstraint("ck_matchmaking_tickets_player_count", "\"PlayerCount\" >= 2 AND \"PlayerCount\" <= 8"); + table.CheckConstraint("ck_matchmaking_tickets_retry_budget", "\"RetryBudget\" >= 0"); + table.ForeignKey( + name: "FK_matchmaking_tickets_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "lobby_invites", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + LobbyId = table.Column(type: "uuid", nullable: false), + InviterUserId = table.Column(type: "uuid", nullable: false), + InviteeUserId = table.Column(type: "uuid", nullable: false), + State = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RespondedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_lobby_invites", x => x.Id); + table.CheckConstraint("ck_lobby_invites_no_self_invite", "\"InviterUserId\" <> \"InviteeUserId\""); + table.CheckConstraint("ck_lobby_invites_responded_iff_terminal", "(\"State\" <> 'Pending') = (\"RespondedAtUtc\" IS NOT NULL)"); + table.ForeignKey( + name: "FK_lobby_invites_lobbies_LobbyId", + column: x => x.LobbyId, + principalTable: "lobbies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_lobby_invites_users_InviteeUserId", + column: x => x.InviteeUserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_lobby_invites_users_InviterUserId", + column: x => x.InviterUserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "lobby_join_credentials", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + LobbyId = table.Column(type: "uuid", nullable: false), + CodeDigest = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + LinkTokenDigest = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Generation = table.Column(type: "integer", nullable: false), + State = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + SupersededAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_lobby_join_credentials", x => x.Id); + table.CheckConstraint("ck_lobby_join_credentials_generation", "\"Generation\" >= 1"); + table.CheckConstraint("ck_lobby_join_credentials_superseded_iff_terminal", "(\"State\" <> 'Active') = (\"SupersededAtUtc\" IS NOT NULL)"); + table.ForeignKey( + name: "FK_lobby_join_credentials_lobbies_LobbyId", + column: x => x.LobbyId, + principalTable: "lobbies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "lobby_members", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + LobbyId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + State = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + IsReady = table.Column(type: "boolean", nullable: false), + JoinedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + LeftAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + RemovedByUserId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_lobby_members", x => x.Id); + table.CheckConstraint("ck_lobby_members_left_at_iff_terminal", "(\"State\" IN ('Left', 'Kicked')) = (\"LeftAtUtc\" IS NOT NULL)"); + table.CheckConstraint("ck_lobby_members_removed_by_only_on_kick", "\"RemovedByUserId\" IS NULL OR \"State\" = 'Kicked'"); + table.ForeignKey( + name: "FK_lobby_members_lobbies_LobbyId", + column: x => x.LobbyId, + principalTable: "lobbies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_lobby_members_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "lobby_start_requests", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + LobbyId = table.Column(type: "uuid", nullable: false), + LobbyRevision = table.Column(type: "integer", nullable: false), + MatchRequestId = table.Column(type: "uuid", nullable: false), + State = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + IdempotencyKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + CorrelationId = table.Column(type: "uuid", nullable: false), + FailureReason = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ResolvedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_lobby_start_requests", x => x.Id); + table.CheckConstraint("ck_lobby_start_requests_failure_reason_only_on_failed", "\"FailureReason\" IS NULL OR \"State\" = 'Failed'"); + table.CheckConstraint("ck_lobby_start_requests_resolved_iff_terminal", "(\"State\" <> 'Open') = (\"ResolvedAtUtc\" IS NOT NULL)"); + table.CheckConstraint("ck_lobby_start_requests_revision", "\"LobbyRevision\" >= 1"); + table.ForeignKey( + name: "FK_lobby_start_requests_lobbies_LobbyId", + column: x => x.LobbyId, + principalTable: "lobbies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "matchmaking_assignments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TicketId = table.Column(type: "uuid", nullable: false), + MatchRequestId = table.Column(type: "uuid", nullable: false), + GroupId = table.Column(type: "uuid", nullable: false), + State = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ResolvedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_matchmaking_assignments", x => x.Id); + table.CheckConstraint("ck_matchmaking_assignments_resolved_iff_terminal", "(\"State\" <> 'Active') = (\"ResolvedAtUtc\" IS NOT NULL)"); + table.ForeignKey( + name: "FK_matchmaking_assignments_matchmaking_tickets_TicketId", + column: x => x.TicketId, + principalTable: "matchmaking_tickets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_capability_seed_history_ManifestVersion", + table: "capability_seed_history", + column: "ManifestVersion", + unique: true); + + migrationBuilder.CreateIndex( + name: "ux_game_capability_profiles_one_active_per_game", + table: "game_capability_profiles", + column: "GameSlug", + unique: true, + filter: "\"IsActive\" = true"); + + migrationBuilder.CreateIndex( + name: "ux_game_capability_profiles_pin", + table: "game_capability_profiles", + columns: new[] { "GameSlug", "CapabilityVersion" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_lobbies_expiry_sweep", + table: "lobbies", + column: "ExpiresAtUtc", + filter: "\"State\" IN ('Open', 'Starting')"); + + migrationBuilder.CreateIndex( + name: "ix_lobbies_host", + table: "lobbies", + column: "HostUserId"); + + migrationBuilder.CreateIndex( + name: "ix_lobbies_public_discovery", + table: "lobbies", + columns: new[] { "CreatedAt", "Id" }, + filter: "\"State\" = 'Open' AND \"Privacy\" = 'Public'"); + + migrationBuilder.CreateIndex( + name: "ix_lobby_invites_expiry_sweep", + table: "lobby_invites", + column: "ExpiresAtUtc", + filter: "\"State\" = 'Pending'"); + + migrationBuilder.CreateIndex( + name: "ix_lobby_invites_invitee_pending", + table: "lobby_invites", + columns: new[] { "InviteeUserId", "CreatedAt", "Id" }, + filter: "\"State\" = 'Pending'"); + + migrationBuilder.CreateIndex( + name: "IX_lobby_invites_InviterUserId", + table: "lobby_invites", + column: "InviterUserId"); + + migrationBuilder.CreateIndex( + name: "ux_lobby_invites_one_pending_per_invitee", + table: "lobby_invites", + columns: new[] { "LobbyId", "InviteeUserId" }, + unique: true, + filter: "\"State\" = 'Pending'"); + + migrationBuilder.CreateIndex( + name: "ux_lobby_join_credentials_active_code", + table: "lobby_join_credentials", + column: "CodeDigest", + unique: true, + filter: "\"State\" = 'Active'"); + + migrationBuilder.CreateIndex( + name: "ux_lobby_join_credentials_active_link_token", + table: "lobby_join_credentials", + column: "LinkTokenDigest", + unique: true, + filter: "\"State\" = 'Active'"); + + migrationBuilder.CreateIndex( + name: "ux_lobby_join_credentials_one_active_per_lobby", + table: "lobby_join_credentials", + column: "LobbyId", + unique: true, + filter: "\"State\" = 'Active'"); + + migrationBuilder.CreateIndex( + name: "ix_lobby_members_lobby_tenure", + table: "lobby_members", + columns: new[] { "LobbyId", "JoinedAtUtc", "UserId" }); + + migrationBuilder.CreateIndex( + name: "ux_lobby_members_one_joined_per_user", + table: "lobby_members", + column: "UserId", + unique: true, + filter: "\"State\" = 'Joined'"); + + migrationBuilder.CreateIndex( + name: "ux_lobby_start_requests_idempotency", + table: "lobby_start_requests", + columns: new[] { "LobbyId", "IdempotencyKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ux_lobby_start_requests_match_request", + table: "lobby_start_requests", + column: "MatchRequestId", + unique: true); + + migrationBuilder.CreateIndex( + name: "ux_lobby_start_requests_one_open_per_revision", + table: "lobby_start_requests", + columns: new[] { "LobbyId", "LobbyRevision" }, + unique: true, + filter: "\"State\" = 'Open'"); + + migrationBuilder.CreateIndex( + name: "ix_matchmaking_assignments_group", + table: "matchmaking_assignments", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "ix_matchmaking_assignments_match_request", + table: "matchmaking_assignments", + column: "MatchRequestId"); + + migrationBuilder.CreateIndex( + name: "ux_matchmaking_assignments_one_active_per_ticket", + table: "matchmaking_assignments", + column: "TicketId", + unique: true, + filter: "\"State\" = 'Active'"); + + migrationBuilder.CreateIndex( + name: "ix_matchmaking_tickets_candidate_pool", + table: "matchmaking_tickets", + columns: new[] { "GameSlug", "CapabilityVersion", "Mode", "PlayerCount", "TimeControlId", "Rated", "ResolvedRegion", "EnqueuedAtUtc", "Id" }, + filter: "\"State\" = 'Queued'"); + + migrationBuilder.CreateIndex( + name: "ix_matchmaking_tickets_expiry_sweep", + table: "matchmaking_tickets", + column: "DeadlineAtUtc", + filter: "\"State\" IN ('Queued', 'Claimed', 'Requeued')"); + + migrationBuilder.CreateIndex( + name: "ux_matchmaking_tickets_one_nonterminal_per_user", + table: "matchmaking_tickets", + column: "UserId", + unique: true, + filter: "\"State\" IN ('Queued', 'Claimed', 'Requeued')"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "capability_seed_history"); + + migrationBuilder.DropTable( + name: "game_capability_profiles"); + + migrationBuilder.DropTable( + name: "lobby_invites"); + + migrationBuilder.DropTable( + name: "lobby_join_credentials"); + + migrationBuilder.DropTable( + name: "lobby_members"); + + migrationBuilder.DropTable( + name: "lobby_start_requests"); + + migrationBuilder.DropTable( + name: "matchmaking_assignments"); + + migrationBuilder.DropTable( + name: "lobbies"); + + migrationBuilder.DropTable( + name: "matchmaking_tickets"); + + migrationBuilder.DropUniqueConstraint( + name: "AK_games_Slug", + table: "games"); + } + } +} diff --git a/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index d6cfdd3..1b14a24 100644 --- a/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -22,6 +23,122 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -281,97 +398,532 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); - b.Property("CreatedAt") + 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("Difficulty") + b.Property("LobbyId") + .HasColumnType("uuid"); + + b.Property("RemovedByUserId") + .HasColumnType("uuid"); + + b.Property("State") .IsRequired() .HasMaxLength(16) .HasColumnType("character varying(16)"); - b.Property("EstimatedDurationMaxMinutes") - .HasColumnType("integer"); + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); - b.Property("EstimatedDurationMinMinutes") - .HasColumnType("integer"); + b.Property("UserId") + .HasColumnType("uuid"); - b.Property("FeaturedRank") - .HasColumnType("integer"); + b.HasKey("Id"); - b.Property("Lifecycle") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("character varying(16)"); + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_lobby_members_one_joined_per_user") + .HasFilter("\"State\" = 'Joined'"); - b.Property("LifecycleVersion") - .HasColumnType("integer"); + b.HasIndex("LobbyId", "JoinedAtUtc", "UserId") + .HasDatabaseName("ix_lobby_members_lobby_tenure"); - b.Property("ManifestVersion") - .IsRequired() - .HasColumnType("text"); + b.ToTable("lobby_members", null, t => + { + t.HasCheckConstraint("ck_lobby_members_left_at_iff_terminal", "(\"State\" IN ('Left', 'Kicked')) = (\"LeftAtUtc\" IS NOT NULL)"); - b.Property("MaxPlayers") - .HasColumnType("integer"); + t.HasCheckConstraint("ck_lobby_members_removed_by_only_on_kick", "\"RemovedByUserId\" IS NULL OR \"State\" = 'Kicked'"); + }); + }); - b.Property("MinPlayers") - .HasColumnType("integer"); + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyStartRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); - b.Property("Name") - .IsRequired() - .HasColumnType("text"); + b.Property("CorrelationId") + .HasColumnType("uuid"); - b.Property("RulesSummary") - .IsRequired() - .HasColumnType("text"); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); - b.Property("Slug") + b.Property("FailureReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IdempotencyKey") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); - b.Property("SortOrder") + b.Property("LobbyId") + .HasColumnType("uuid"); + + b.Property("LobbyRevision") .HasColumnType("integer"); - b.Property("Summary") + b.Property("MatchRequestId") + .HasColumnType("uuid"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("State") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(16) + .HasColumnType("character varying(16)"); 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("MatchRequestId") + .IsUnique() + .HasDatabaseName("ux_lobby_start_requests_match_request"); - b.HasIndex("Name", "Slug") - .HasDatabaseName("ix_games_name_slug"); + b.HasIndex("LobbyId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ux_lobby_start_requests_idempotency"); - b.HasIndex("FeaturedRank", "SortOrder", "Slug") - .HasDatabaseName("ix_games_default_order"); + b.HasIndex("LobbyId", "LobbyRevision") + .IsUnique() + .HasDatabaseName("ux_lobby_start_requests_one_open_per_revision") + .HasFilter("\"State\" = 'Open'"); - b.ToTable("games", null, t => + b.ToTable("lobby_start_requests", null, t => { - t.HasCheckConstraint("ck_games_draft_retired_not_featured", "(\"Lifecycle\" <> 'Draft' AND \"Lifecycle\" <> 'Retired') OR \"FeaturedRank\" IS NULL"); + t.HasCheckConstraint("ck_lobby_start_requests_failure_reason_only_on_failed", "\"FailureReason\" IS NULL OR \"State\" = 'Failed'"); - t.HasCheckConstraint("ck_games_duration_bounds", "\"EstimatedDurationMinMinutes\" <= \"EstimatedDurationMaxMinutes\""); + t.HasCheckConstraint("ck_lobby_start_requests_resolved_iff_terminal", "(\"State\" <> 'Open') = (\"ResolvedAtUtc\" IS NOT NULL)"); - t.HasCheckConstraint("ck_games_min_players", "\"MinPlayers\" >= 1 AND \"MinPlayers\" <= \"MaxPlayers\""); + t.HasCheckConstraint("ck_lobby_start_requests_revision", "\"LobbyRevision\" >= 1"); }); }); - modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingAssignment", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -380,68 +932,120 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("GameId") + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") .HasColumnType("uuid"); - b.Property("Mode") + b.Property("MatchRequestId") + .HasColumnType("uuid"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("State") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TicketId") + .HasColumnType("uuid"); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); - b.HasIndex("GameId", "Mode") - .IsUnique(); + b.HasIndex("GroupId") + .HasDatabaseName("ix_matchmaking_assignments_group"); - b.ToTable("game_mode_capabilities", (string)null); + 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.Games.GameTag", b => + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingTicket", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("CreatedAt") + b.Property("CapabilityVersion") + .HasColumnType("integer"); + + b.Property("ClaimedAtUtc") .HasColumnType("timestamp with time zone"); - b.Property("GameId") + b.Property("ClaimedByWorker") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CorrelationId") .HasColumnType("uuid"); - b.Property("UpdatedAt") + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("Value") + b.Property("DeadlineAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GameSlug") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(64) + .HasColumnType("character varying(64)"); - b.HasKey("Id"); + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); - b.HasIndex("GameId", "Value") - .IsUnique(); + b.Property("PlayerCount") + .HasColumnType("integer"); - b.ToTable("game_tags", (string)null); - }); + b.Property("Rated") + .HasColumnType("boolean"); - modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); + b.Property("Rating") + .HasColumnType("integer"); - b.Property("CreatedAt") + b.Property("RatingSourceVersion") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ResolvedAtUtc") .HasColumnType("timestamp with time zone"); - b.Property("CycleId") + b.Property("ResolvedRegion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RetryBudget") .HasColumnType("integer"); - b.Property("GameId") - .HasColumnType("uuid"); + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); - b.Property("IsActive") - .HasColumnType("boolean"); + b.Property("TimeControlId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); @@ -449,14 +1053,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UserId") .HasColumnType("uuid"); + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + b.HasKey("Id"); - b.HasIndex("GameId"); + b.HasIndex("DeadlineAtUtc") + .HasDatabaseName("ix_matchmaking_tickets_expiry_sweep") + .HasFilter("\"State\" IN ('Queued', 'Claimed', 'Requeued')"); - b.HasIndex("UserId", "GameId") - .IsUnique(); + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_matchmaking_tickets_one_nonterminal_per_user") + .HasFilter("\"State\" IN ('Queued', 'Claimed', 'Requeued')"); - b.ToTable("user_favorite_games", (string)null); + 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 => @@ -1022,6 +1651,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("users", (string)null); }); + modelBuilder.Entity("SimPle.Domain.Capabilities.GameCapabilityProfile", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany() + .HasForeignKey("GameSlug") + .HasPrincipalKey("Slug") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => { b.HasOne("SimPle.Domain.Users.User", null) @@ -1109,6 +1748,87 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("SimPle.Domain.Lobbies.Lobby", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("HostUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyInvite", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("InviteeUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("InviterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyJoinCredential", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyMember", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany("Members") + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyStartRequest", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingAssignment", b => + { + b.HasOne("SimPle.Domain.Matchmaking.MatchmakingTicket", null) + .WithMany() + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingTicket", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => { b.HasOne("SimPle.Domain.Outbox.OutboxMessage", null) @@ -1178,6 +1898,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Tags"); }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.Lobby", b => + { + b.Navigation("Members"); + }); #pragma warning restore 612, 618 } } diff --git a/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs b/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs new file mode 100644 index 0000000..92fe2ff --- /dev/null +++ b/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimPle.Application.Common.Options; +using SimPle.Application.Outbox; + +namespace SimPle.Infrastructure.Outbox; + +/// +/// Hosts the transactional-outbox dispatcher (D3) — the codebase's first. +/// +/// +/// Module 3 has emitted outbox rows since it shipped and has always +/// modelled per-(eventId, handlerName) delivery state, but nothing has ever read them. This loop is what turns +/// that table from a design into a delivery guarantee, and M7/M8/M11 inherit it by registering an +/// — they do not each build their own consumer. +/// +/// +/// +/// Each registered handler is dispatched independently. One handler failing, dead-lettering, or being slow must not +/// hold up another: that is exactly the starvation the per-handler delivery row exists to prevent, and batching them +/// into a shared pass would quietly reintroduce it. +/// +/// +public sealed class OutboxDispatcherWorker : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly OutboxOptions _options; + private readonly ILogger _logger; + + public OutboxDispatcherWorker( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.WorkerEnabled) + { + _logger.LogInformation("Outbox dispatcher is disabled by configuration; not starting."); + return; + } + + _logger.LogInformation( + "Outbox dispatcher started. Interval={Interval} BatchSize={BatchSize} MaxAttempts={MaxAttempts}", + _options.Interval, _options.BatchSize, _options.MaxAttempts); + + while (!stoppingToken.IsCancellationRequested) + { + await DispatchAllAsync(stoppingToken); + + try + { + await Task.Delay(_options.Interval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task DispatchAllAsync(CancellationToken ct) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + + var handlers = scope.ServiceProvider.GetServices().ToList(); + if (handlers.Count == 0) return; + + foreach (var handler in handlers) + { + if (ct.IsCancellationRequested) break; + + try + { + // A fresh scope per handler: the processor and the handler share a scoped AppDbContext, and one + // handler's failed save must not leave a dirty change tracker for the next one to trip over. + await using var handlerScope = _scopeFactory.CreateAsyncScope(); + var processor = handlerScope.ServiceProvider.GetRequiredService(); + var scopedHandler = handlerScope.ServiceProvider + .GetServices() + .First(h => h.HandlerName == handler.HandlerName); + + var result = await processor.DispatchAsync(scopedHandler, ct); + + if (result.Processed > 0 || result.Failed > 0) + { + _logger.LogInformation( + "Outbox pass. Handler={Handler} Leased={Leased} Processed={Processed} Failed={Failed} DeadLettered={DeadLettered} OldestPendingMs={OldestMs}", + handler.HandlerName, result.Leased, result.Processed, result.Failed, result.DeadLettered, + (long?)result.OldestPendingAge?.TotalMilliseconds); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Nothing is lost: any delivery this pass leased keeps its lease only until it lapses, after which + // another pass reclaims it. That is precisely why the lease is a timestamp and not a boolean. + _logger.LogError( + ex, "Outbox dispatch pass failed. Handler={Handler}. Leases will lapse and be retried.", + handler.HandlerName); + } + } + } +} diff --git a/src/SimPle.Infrastructure/Persistence/AppDbContext.cs b/src/SimPle.Infrastructure/Persistence/AppDbContext.cs index 8d82167..94b0df2 100644 --- a/src/SimPle.Infrastructure/Persistence/AppDbContext.cs +++ b/src/SimPle.Infrastructure/Persistence/AppDbContext.cs @@ -1,6 +1,9 @@ using Microsoft.EntityFrameworkCore; +using SimPle.Domain.Capabilities; using SimPle.Domain.Friends; using SimPle.Domain.Games; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Matchmaking; using SimPle.Domain.Outbox; using SimPle.Domain.Profiles; using SimPle.Domain.Users; @@ -39,6 +42,20 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet UserFavoriteGames => Set(); public DbSet CatalogSeedHistory => Set(); + // Module 6 — lobby & matchmaking system + public DbSet Lobbies => Set(); + public DbSet LobbyMembers => Set(); + public DbSet LobbyInvites => Set(); + public DbSet LobbyJoinCredentials => Set(); + public DbSet LobbyStartRequests => Set(); + public DbSet MatchmakingTickets => Set(); + public DbSet MatchmakingAssignments => Set(); + + // Module 6 — capability profiles (D2): what a lobby may configure, keyed by (GameSlug, CapabilityVersion). + // Additive; Module 4's catalog tables are not mutated. + public DbSet GameCapabilityProfiles => Set(); + public DbSet CapabilitySeedHistory => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/GameCapabilityProfileConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/GameCapabilityProfileConfiguration.cs new file mode 100644 index 0000000..d60147e --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/GameCapabilityProfileConfiguration.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Capabilities; +using SimPle.Domain.Games; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class GameCapabilityProfileConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("game_capability_profiles", t => + { + t.HasCheckConstraint("ck_game_capability_profiles_version", "\"CapabilityVersion\" >= 1"); + t.HasCheckConstraint( + "ck_game_capability_profiles_players", + "\"MinPlayers\" >= 2 AND \"MinPlayers\" <= \"MaxPlayers\" AND \"MaxPlayers\" <= 8"); + t.HasCheckConstraint("ck_game_capability_profiles_modes_nonempty", "cardinality(\"AllowedModes\") > 0"); + t.HasCheckConstraint("ck_game_capability_profiles_time_controls_nonempty", "cardinality(\"TimeControls\") > 0"); + t.HasCheckConstraint("ck_game_capability_profiles_tie_breaks_nonempty", "cardinality(\"TieBreakRules\") > 0"); + t.HasCheckConstraint("ck_game_capability_profiles_spectators_nonempty", "cardinality(\"SpectatorPolicies\") > 0"); + }); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.GameSlug).IsRequired().HasMaxLength(64); + builder.Property(p => p.CapabilityVersion).IsRequired(); + builder.Property(p => p.MinPlayers).IsRequired(); + builder.Property(p => p.MaxPlayers).IsRequired(); + + // Npgsql maps List to text[] natively — no junction tables needed for these small, read-mostly + // allow-lists, and the whole profile stays a single row read. + builder.Property(p => p.AllowedModes).IsRequired(); + builder.Property(p => p.TimeControls).IsRequired(); + builder.Property(p => p.TieBreakRules).IsRequired(); + builder.Property(p => p.SpectatorPolicies).IsRequired(); + + builder.Property(p => p.RatedEligible).IsRequired(); + builder.Property(p => p.AiFillEligible).IsRequired(); + builder.Property(p => p.IsActive).IsRequired(); + builder.Property(p => p.ManifestVersion).IsRequired().HasMaxLength(32); + + // The pin. A lobby/ticket stores (GameSlug, CapabilityVersion) and resolves it here. + builder.HasIndex(p => new { p.GameSlug, p.CapabilityVersion }) + .IsUnique() + .HasDatabaseName("ux_game_capability_profiles_pin"); + + // "The current profile for this game" — at most one active version per game, so a create command never has + // to choose between two live profiles. + builder.HasIndex(p => p.GameSlug) + .IsUnique() + .HasFilter("\"IsActive\" = true") + .HasDatabaseName("ux_game_capability_profiles_one_active_per_game"); + + // FK to M4's games.Slug (its alternate key). Restrict, not cascade: retiring a game must not silently + // delete the capability profile that pinned lobbies still resolve through. + builder.HasOne() + .WithMany() + .HasForeignKey(p => p.GameSlug) + .HasPrincipalKey(g => g.Slug) + .OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class CapabilitySeedHistoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("capability_seed_history"); + + builder.HasKey(h => h.Id); + + builder.Property(h => h.ManifestVersion).IsRequired().HasMaxLength(32); + builder.Property(h => h.Checksum).IsRequired().HasMaxLength(64); + builder.Property(h => h.AppliedAtUtc).IsRequired(); + + builder.HasIndex(h => h.ManifestVersion).IsUnique(); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/LobbyConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/LobbyConfiguration.cs new file mode 100644 index 0000000..a9b00e3 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/LobbyConfiguration.cs @@ -0,0 +1,131 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Users; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class LobbyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("lobbies", t => + { + t.HasCheckConstraint("ck_lobbies_max_players", "\"MaxPlayers\" >= 2 AND \"MaxPlayers\" <= 8"); + t.HasCheckConstraint("ck_lobbies_capability_version", "\"CapabilityVersion\" >= 1"); + t.HasCheckConstraint("ck_lobbies_revision", "\"Revision\" >= 1"); + + // A terminal lobby must carry a reason, and a live one must not — the reason is the audit trail for + // why a lobby stopped existing, so an unexplained Closed row is a bug, not a valid state. + t.HasCheckConstraint( + "ck_lobbies_closed_reason_iff_terminal", + "(\"State\" IN ('Closed', 'Expired')) = (\"ClosedReason\" IS NOT NULL)"); + }); + + builder.HasKey(l => l.Id); + + // Computed projections over the aggregate, not persisted state. JoinedMembers in particular MUST be + // ignored: EF's relationship-discovery convention sees an IEnumerable property and creates a + // *second* Lobby->LobbyMember relationship behind a shadow FK ("LobbyId1"), which would ship a dead, + // always-null duplicate foreign key column alongside the real one. + builder.Ignore(l => l.JoinedMembers); + builder.Ignore(l => l.CurrentSettings); + + builder.Property(l => l.GameSlug).IsRequired().HasMaxLength(64); + builder.Property(l => l.CapabilityVersion).IsRequired(); + builder.Property(l => l.HostUserId).IsRequired(); + builder.Property(l => l.Privacy).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(l => l.MaxPlayers).IsRequired(); + builder.Property(l => l.TimeControlId).IsRequired().HasMaxLength(32); + builder.Property(l => l.Rated).IsRequired(); + builder.Property(l => l.ResolvedRegion).IsRequired().HasMaxLength(32); + builder.Property(l => l.SpectatorPolicy).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(l => l.TieBreakRuleId).IsRequired().HasMaxLength(32); + builder.Property(l => l.AiFillRequested).IsRequired(); + builder.Property(l => l.State).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(l => l.Revision).IsRequired().HasDefaultValue(1); + builder.Property(l => l.ExpiresAtUtc).IsRequired(); + builder.Property(l => l.ClosedReason).HasConversion().HasMaxLength(24); + builder.Property(l => l.CorrelationId).IsRequired(); + + // Npgsql row-version pattern: uint property mapped to xmin (optimistic concurrency token). + builder.Property(l => l.Version).IsRowVersion(); + + // Public-discovery keyset index. Partial, so it stays small and — more importantly — so a private lobby is + // not even present in the structure discovery pages over. + builder.HasIndex(l => new { l.CreatedAt, l.Id }) + .HasFilter("\"State\" = 'Open' AND \"Privacy\" = 'Public'") + .HasDatabaseName("ix_lobbies_public_discovery"); + + // Expiry sweep: find open lobbies past their deadline. + builder.HasIndex(l => l.ExpiresAtUtc) + .HasFilter("\"State\" IN ('Open', 'Starting')") + .HasDatabaseName("ix_lobbies_expiry_sweep"); + + builder.HasIndex(l => l.HostUserId).HasDatabaseName("ix_lobbies_host"); + + builder.HasMany(l => l.Members) + .WithOne() + .HasForeignKey(m => m.LobbyId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(l => l.Members).UsePropertyAccessMode(PropertyAccessMode.Field); + + builder.HasOne().WithMany().HasForeignKey(l => l.HostUserId).OnDelete(DeleteBehavior.Cascade); + } +} + +public sealed class LobbyMemberConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("lobby_members", t => + { + // A departed member must have a departure time; a seated one must not. + t.HasCheckConstraint( + "ck_lobby_members_left_at_iff_terminal", + "(\"State\" IN ('Left', 'Kicked')) = (\"LeftAtUtc\" IS NOT NULL)"); + + // Only a kick records who did it. + t.HasCheckConstraint( + "ck_lobby_members_removed_by_only_on_kick", + "\"RemovedByUserId\" IS NULL OR \"State\" = 'Kicked'"); + }); + + builder.HasKey(m => m.Id); + + // Ids come from Entity's field initializer (Guid.NewGuid()), never from the store. EF must be told this: + // by convention it treats a Guid key as store-generated, and then infers the state of an entity added to a + // *tracked* parent's collection from its key — a non-default Guid reads as "this row already exists", so a + // brand-new member is emitted as an UPDATE against a row that is not there (0 rows affected -> a spurious + // DbUpdateConcurrencyException) instead of an INSERT. + // + // Lobby is the first aggregate in this codebase with a *mutable* child collection; Module 4's GameTags are + // only ever written while the parent itself is Added, which is why nothing hit this before. + builder.Property(m => m.Id).ValueGeneratedNever(); + + builder.Property(m => m.LobbyId).IsRequired(); + builder.Property(m => m.UserId).IsRequired(); + builder.Property(m => m.State).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(m => m.IsReady).IsRequired(); + builder.Property(m => m.JoinedAtUtc).IsRequired(); + builder.Property(m => m.LeftAtUtc); + builder.Property(m => m.RemovedByUserId); + + // THE cross-lobby invariant: one joined seat per user across the whole platform. An application-level check + // loses this race; only the filtered unique index actually enforces it, and it is what the concurrent + // last-seat-join test asserts against. + // + // It cannot see the tickets table, so "one active lobby OR one active ticket" is only half-enforced here — + // the other half must run inside the same transaction that inserts (brief Risk #2). + builder.HasIndex(m => m.UserId) + .IsUnique() + .HasFilter("\"State\" = 'Joined'") + .HasDatabaseName("ux_lobby_members_one_joined_per_user"); + + // Roster reads, and the tenure order that drives host transfer. + builder.HasIndex(m => new { m.LobbyId, m.JoinedAtUtc, m.UserId }) + .HasDatabaseName("ix_lobby_members_lobby_tenure"); + + builder.HasOne().WithMany().HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/LobbyInviteConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/LobbyInviteConfiguration.cs new file mode 100644 index 0000000..d5990d0 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/LobbyInviteConfiguration.cs @@ -0,0 +1,144 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Users; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class LobbyInviteConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("lobby_invites", 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)"); + }); + + builder.HasKey(i => i.Id); + + builder.Property(i => i.LobbyId).IsRequired(); + builder.Property(i => i.InviterUserId).IsRequired(); + builder.Property(i => i.InviteeUserId).IsRequired(); + builder.Property(i => i.State).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(i => i.ExpiresAtUtc).IsRequired(); + builder.Property(i => i.RespondedAtUtc); + + // At most one live invite per (lobby, invitee): re-inviting someone who already has a pending invite must + // not mint a second one, or revoking would leave a live duplicate behind. + builder.HasIndex(i => new { i.LobbyId, i.InviteeUserId }) + .IsUnique() + .HasFilter("\"State\" = 'Pending'") + .HasDatabaseName("ux_lobby_invites_one_pending_per_invitee"); + + // "My pending invites" (dashboard) — the badge count and the list are this same bounded query. + builder.HasIndex(i => new { i.InviteeUserId, i.CreatedAt, i.Id }) + .HasFilter("\"State\" = 'Pending'") + .HasDatabaseName("ix_lobby_invites_invitee_pending"); + + // Expiry sweep. + builder.HasIndex(i => i.ExpiresAtUtc) + .HasFilter("\"State\" = 'Pending'") + .HasDatabaseName("ix_lobby_invites_expiry_sweep"); + + builder.HasOne().WithMany().HasForeignKey(i => i.LobbyId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(i => i.InviterUserId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(i => i.InviteeUserId).OnDelete(DeleteBehavior.Cascade); + } +} + +public sealed class LobbyJoinCredentialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("lobby_join_credentials", 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)"); + }); + + builder.HasKey(c => c.Id); + + builder.Property(c => c.LobbyId).IsRequired(); + + // Hex-encoded HMAC-SHA256 => exactly 64 characters. No plaintext column exists on this table by design. + builder.Property(c => c.CodeDigest).IsRequired().HasMaxLength(64); + builder.Property(c => c.LinkTokenDigest).IsRequired().HasMaxLength(64); + + builder.Property(c => c.Generation).IsRequired(); + builder.Property(c => c.State).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(c => c.ExpiresAtUtc).IsRequired(); + builder.Property(c => c.SupersededAtUtc); + + // Global code uniqueness among live credentials — join-by-code looks up by digest alone, so two active + // lobbies sharing a code would make the lookup ambiguous. The generator's bounded collision retry catches + // the 23505 this raises. + builder.HasIndex(c => c.CodeDigest) + .IsUnique() + .HasFilter("\"State\" = 'Active'") + .HasDatabaseName("ux_lobby_join_credentials_active_code"); + + builder.HasIndex(c => c.LinkTokenDigest) + .IsUnique() + .HasFilter("\"State\" = 'Active'") + .HasDatabaseName("ux_lobby_join_credentials_active_link_token"); + + // One active credential per lobby: rotation must supersede, never accumulate. + builder.HasIndex(c => c.LobbyId) + .IsUnique() + .HasFilter("\"State\" = 'Active'") + .HasDatabaseName("ux_lobby_join_credentials_one_active_per_lobby"); + + builder.HasOne().WithMany().HasForeignKey(c => c.LobbyId).OnDelete(DeleteBehavior.Cascade); + } +} + +public sealed class LobbyStartRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("lobby_start_requests", t => + { + t.HasCheckConstraint("ck_lobby_start_requests_revision", "\"LobbyRevision\" >= 1"); + t.HasCheckConstraint( + "ck_lobby_start_requests_resolved_iff_terminal", + "(\"State\" <> 'Open') = (\"ResolvedAtUtc\" IS NOT NULL)"); + t.HasCheckConstraint( + "ck_lobby_start_requests_failure_reason_only_on_failed", + "\"FailureReason\" IS NULL OR \"State\" = 'Failed'"); + }); + + builder.HasKey(r => r.Id); + + builder.Property(r => r.LobbyId).IsRequired(); + builder.Property(r => r.LobbyRevision).IsRequired(); + builder.Property(r => r.MatchRequestId).IsRequired(); + builder.Property(r => r.State).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(r => r.IdempotencyKey).IsRequired().HasMaxLength(128); + builder.Property(r => r.CorrelationId).IsRequired(); + builder.Property(r => r.FailureReason).HasMaxLength(256); + builder.Property(r => r.ResolvedAtUtc); + + // One open start-request per lobby revision. This is what makes a retried Start idempotent: the second + // attempt at the same revision loses here rather than minting a second match request. + builder.HasIndex(r => new { r.LobbyId, r.LobbyRevision }) + .IsUnique() + .HasFilter("\"State\" = 'Open'") + .HasDatabaseName("ux_lobby_start_requests_one_open_per_revision"); + + // A client replaying the same idempotency key must replay, not re-run. + builder.HasIndex(r => new { r.LobbyId, r.IdempotencyKey }) + .IsUnique() + .HasDatabaseName("ux_lobby_start_requests_idempotency"); + + builder.HasIndex(r => r.MatchRequestId) + .IsUnique() + .HasDatabaseName("ux_lobby_start_requests_match_request"); + + builder.HasOne().WithMany().HasForeignKey(r => r.LobbyId).OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/MatchmakingConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/MatchmakingConfiguration.cs new file mode 100644 index 0000000..d6eed59 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/MatchmakingConfiguration.cs @@ -0,0 +1,119 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Users; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class MatchmakingTicketConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("matchmaking_tickets", t => + { + t.HasCheckConstraint("ck_matchmaking_tickets_player_count", "\"PlayerCount\" >= 2 AND \"PlayerCount\" <= 8"); + t.HasCheckConstraint("ck_matchmaking_tickets_capability_version", "\"CapabilityVersion\" >= 1"); + t.HasCheckConstraint("ck_matchmaking_tickets_retry_budget", "\"RetryBudget\" >= 0"); + t.HasCheckConstraint("ck_matchmaking_tickets_deadline_after_enqueue", "\"DeadlineAtUtc\" > \"EnqueuedAtUtc\""); + + // A *queued* ticket must not name a worker: a stale worker id on a queued row means a claim leaked and + // two workers could believe they hold it. Terminal states deliberately KEEP the worker id — it is the + // attribution behind the matchmaking-worker-failure observability signal, and clearing it would throw + // away the only record of which worker resolved the ticket. + t.HasCheckConstraint( + "ck_matchmaking_tickets_no_worker_while_queued", + "\"State\" <> 'Queued' OR \"ClaimedByWorker\" IS NULL"); + }); + + builder.HasKey(t => t.Id); + + builder.Property(t => t.UserId).IsRequired(); + builder.Property(t => t.GameSlug).IsRequired().HasMaxLength(64); + builder.Property(t => t.CapabilityVersion).IsRequired(); + builder.Property(t => t.Mode).IsRequired().HasMaxLength(32); + builder.Property(t => t.PlayerCount).IsRequired(); + builder.Property(t => t.TimeControlId).IsRequired().HasMaxLength(32); + builder.Property(t => t.Rated).IsRequired(); + builder.Property(t => t.ResolvedRegion).IsRequired().HasMaxLength(32); + builder.Property(t => t.Rating).IsRequired(); + builder.Property(t => t.RatingSourceVersion).IsRequired().HasMaxLength(48); + builder.Property(t => t.State).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(t => t.EnqueuedAtUtc).IsRequired(); + builder.Property(t => t.DeadlineAtUtc).IsRequired(); + builder.Property(t => t.RetryBudget).IsRequired(); + builder.Property(t => t.ClaimedByWorker).HasMaxLength(64); + builder.Property(t => t.ClaimedAtUtc); + builder.Property(t => t.ResolvedAtUtc); + builder.Property(t => t.CorrelationId).IsRequired(); + + builder.Property(t => t.Version).IsRowVersion(); + + // One nonterminal ticket per user. 'Requeued' is included defensively: the domain's Requeue() returns the + // ticket straight to 'Queued' and never persists 'Requeued' today, but if that ever changed, a ticket in + // that state must still occupy the user's single slot rather than silently escaping this index. + builder.HasIndex(t => t.UserId) + .IsUnique() + .HasFilter("\"State\" IN ('Queued', 'Claimed', 'Requeued')") + .HasDatabaseName("ux_matchmaking_tickets_one_nonterminal_per_user"); + + // The matching worker's candidate-pool scan: exact-match pool key, then oldest-first within it (the anchor + // order). Partial so the index holds only what the worker actually scans. + builder.HasIndex(t => new + { + t.GameSlug, + t.CapabilityVersion, + t.Mode, + t.PlayerCount, + t.TimeControlId, + t.Rated, + t.ResolvedRegion, + t.EnqueuedAtUtc, + t.Id, + }) + .HasFilter("\"State\" = 'Queued'") + .HasDatabaseName("ix_matchmaking_tickets_candidate_pool"); + + // Expiry sweep. + builder.HasIndex(t => t.DeadlineAtUtc) + .HasFilter("\"State\" IN ('Queued', 'Claimed', 'Requeued')") + .HasDatabaseName("ix_matchmaking_tickets_expiry_sweep"); + + builder.HasOne().WithMany().HasForeignKey(t => t.UserId).OnDelete(DeleteBehavior.Cascade); + } +} + +public sealed class MatchmakingAssignmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("matchmaking_assignments", t => + { + t.HasCheckConstraint( + "ck_matchmaking_assignments_resolved_iff_terminal", + "(\"State\" <> 'Active') = (\"ResolvedAtUtc\" IS NOT NULL)"); + }); + + builder.HasKey(a => a.Id); + + builder.Property(a => a.TicketId).IsRequired(); + builder.Property(a => a.MatchRequestId).IsRequired(); + builder.Property(a => a.GroupId).IsRequired(); + builder.Property(a => a.State).HasConversion().HasMaxLength(16).IsRequired(); + builder.Property(a => a.CreatedAtUtc).IsRequired(); + builder.Property(a => a.ResolvedAtUtc); + + // THE index that makes double-assignment impossible (brief Risk #1). 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 rejected here, not by the row lock. The two-worker real-Postgres test + // asserts zero duplicates against exactly this index. + builder.HasIndex(a => a.TicketId) + .IsUnique() + .HasFilter("\"State\" = 'Active'") + .HasDatabaseName("ux_matchmaking_assignments_one_active_per_ticket"); + + builder.HasIndex(a => a.GroupId).HasDatabaseName("ix_matchmaking_assignments_group"); + builder.HasIndex(a => a.MatchRequestId).HasDatabaseName("ix_matchmaking_assignments_match_request"); + + builder.HasOne().WithMany().HasForeignKey(a => a.TicketId).OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/LobbyCommandRunner.cs b/src/SimPle.Infrastructure/Persistence/LobbyCommandRunner.cs new file mode 100644 index 0000000..e4fa6b8 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/LobbyCommandRunner.cs @@ -0,0 +1,142 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Npgsql; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Lobbies.Services; +using SimPle.Shared.Common; + +namespace SimPle.Infrastructure.Persistence; + +/// +/// Reconciliation R3: reruns a whole lobby command — read, decide, and write — on +/// contention, then surfaces a typed conflict rather than a 500. +/// +/// +/// is deliberately left untouched and is not reused here. It re-issues only +/// the SaveChanges call, which is right for its existing callers (they catch 23505 themselves as a +/// meaningful domain outcome) and wrong for M6: a last-seat join that lost the race must re-read to +/// discover the lobby is now full. Replaying only the save would re-commit a decision made against state that no +/// longer holds. +/// +/// +/// +/// Between attempts the change tracker is cleared. Without that, the rerun would re-decide against the losing +/// attempt's tracked entities — it would look like a re-read and behave like a replay, which is the subtlest way +/// to get this wrong. +/// +/// +public sealed class LobbyCommandRunner : ILobbyCommandRunner +{ + private readonly AppDbContext _db; + private readonly ILogger _logger; + + /// + /// Three attempts. Contention here is between a handful of humans clicking "join" on the same last seat, not + /// a high-throughput write path — if three serialized attempts all lose, the honest answer is a typed conflict, + /// not a longer queue. + /// + private const int MaxAttempts = 3; + + public LobbyCommandRunner(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task> RunAsync( + Guid actorUserId, + Func>> command, + CancellationToken ct = default) + { + for (var attempt = 1; ; attempt++) + { + try + { + return await RunOnceAsync(actorUserId, command, ct); + } + catch (Exception ex) when (attempt < MaxAttempts && IsContention(ex)) + { + // The failed transaction is already rolled back. Discard every entity the losing attempt tracked + // so the rerun genuinely re-reads current state instead of re-deciding against stale ones. + _db.ChangeTracker.Clear(); + + _logger.LogInformation( + "Lobby command contention; rerunning whole command. ActorId={ActorId} Attempt={Attempt} Reason={Reason}", + actorUserId, attempt, ex.GetType().Name); + + // Tiny linear backoff so the winner commits before the loser re-reads; otherwise the rerun could + // read the same pre-commit snapshot and lose again for the same reason. + await Task.Delay(15 * attempt, ct); + } + catch (Exception ex) when (IsContention(ex)) + { + // Budget spent. A typed 409 — never a 500 (brief Risk #5). + _db.ChangeTracker.Clear(); + + _logger.LogWarning( + ex, + "Lobby command exhausted its retry budget. ActorId={ActorId} Attempts={Attempts}", + actorUserId, MaxAttempts); + + return Result.Fail( + LobbyErrors.ConcurrencyConflict, + "That lobby is being changed by someone else right now. Please try again."); + } + } + } + + private async Task> RunOnceAsync( + Guid actorUserId, + Func>> command, + CancellationToken ct) + { + // The EF InMemory provider (used by the HTTP-contract integration tests) has no transactions and no raw + // SQL. Those tests assert routing, auth, validation, and error mapping — none of which the transaction + // affects. The race behavior this class exists for is asserted where it can actually be proven: against + // real PostgreSQL. + if (!_db.Database.IsRelational()) + return await command(ct); + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + // Serialize this actor's seat-acquiring commands against each other for the life of the transaction. + // + // This is what makes the cross-table "one active lobby OR one active ticket" check real. The two filtered + // unique indexes live on different tables and cannot see each other (brief Risk #2), so under READ + // COMMITTED a concurrent join and enqueue would each read "nothing active" — neither seeing the other's + // uncommitted row — and both would commit. The lock closes that window without paying SERIALIZABLE's + // retry cost on every unrelated lobby write. + // + // It is transaction-scoped: PostgreSQL releases it at commit or rollback, so a crashed command cannot + // leak it. + await _db.Database.ExecuteSqlInterpolatedAsync( + $"SELECT pg_advisory_xact_lock({AdvisoryLockKey(actorUserId)})", ct); + + var result = await command(ct); + + // A Result.Fail is a *decision* ("you are already in a lobby"), not a fault. Nothing was written, so the + // commit is a no-op — but it must still happen, to release the advisory lock promptly rather than holding + // it until disposal. + await transaction.CommitAsync(ct); + return result; + } + + /// + /// A stable 64-bit lock key from the actor's id. Distinct users colliding on the same key is harmless — the + /// only consequence is that two unrelated actors briefly serialize — so the first 8 bytes are enough and no + /// cryptographic hash is warranted. + /// + private static long AdvisoryLockKey(Guid userId) + { + Span bytes = stackalloc byte[16]; + userId.TryWriteBytes(bytes); + return BitConverter.ToInt64(bytes[..8]); + } + + /// + /// Shared with — see for why the predicate + /// lives in one place. The xmin row-version case is the one that catches concurrent last-seat joins; + /// omitting it would leave the exact race this module is built around surfacing as a 500. + /// + private static bool IsContention(Exception ex) => PostgresContention.IsContention(ex); +} diff --git a/src/SimPle.Infrastructure/Persistence/PostgresContention.cs b/src/SimPle.Infrastructure/Persistence/PostgresContention.cs new file mode 100644 index 0000000..33ecae0 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/PostgresContention.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace SimPle.Infrastructure.Persistence; + +/// +/// The three ways PostgreSQL says "someone else got there first". All of them mean the same thing to a caller: +/// your read is stale, look again. +/// +/// +/// Extracted so (request path) and (background +/// path) cannot drift apart on it. They retry for different reasons but must agree on what counts as +/// contention: if one of them silently stopped treating a row-version conflict as retryable, the symptom would +/// be a 500 or a lost ticket under concurrency — the exact class of bug this module is built around, and one that +/// a duplicated predicate is uniquely good at hiding. +/// +/// +internal static class PostgresContention +{ + /// + /// is the one that actually catches concurrent last-seat joins. + /// Every join bumps the lobby's Revision, so both racers issue UPDATE … WHERE xmin = @loaded and + /// the loser affects zero rows. No unique index does this work — + /// ux_lobby_members_one_joined_per_user is keyed on UserId, so it cheerfully admits two + /// different users into the same final seat. + /// + public static bool IsContention(Exception ex) => ex switch + { + DbUpdateConcurrencyException => true, + DbUpdateException due => due.InnerException is PostgresException pg && IsContentionSqlState(pg.SqlState), + PostgresException pg => IsContentionSqlState(pg.SqlState), + _ => false, + }; + + private static bool IsContentionSqlState(string sqlState) => + sqlState is PostgresErrorCodes.UniqueViolation // 23505 — a filtered unique index rejected the write + or PostgresErrorCodes.SerializationFailure // 40001 + or PostgresErrorCodes.DeadlockDetected; // 40P01 +} diff --git a/src/SimPle.Infrastructure/Persistence/Repositories/LobbyRepository.cs b/src/SimPle.Infrastructure/Persistence/Repositories/LobbyRepository.cs new file mode 100644 index 0000000..7b26b9b --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Repositories/LobbyRepository.cs @@ -0,0 +1,307 @@ +using Microsoft.EntityFrameworkCore; +using SimPle.Application.Common.Interfaces; +using SimPle.Domain.Capabilities; +using SimPle.Domain.Friends; +using SimPle.Domain.Games; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Outbox; +using SimPle.Domain.Users; + +namespace SimPle.Infrastructure.Persistence.Repositories; + +/// +/// Lobby data access. +/// +/// Nothing here catches a unique-violation or row-version exception. That is deliberate and is the whole point of +/// R3: a repository that swallowed 23505 would have to decide the outcome without re-reading, which is the +/// bug the bounded whole-command retry exists to prevent. Contention is allowed to propagate to +/// , which reruns the command so it can re-read and answer truthfully. +/// +public sealed class LobbyRepository : ILobbyRepository +{ + private readonly AppDbContext _db; + + public LobbyRepository(AppDbContext db) => _db = db; + + private static readonly MatchmakingTicketState[] NonTerminalTicketStates = + { + MatchmakingTicketState.Queued, + MatchmakingTicketState.Claimed, + MatchmakingTicketState.Requeued, + }; + + // ── Lobby reads ────────────────────────────────────────────────────────── + + public Task GetForUpdateAsync(Guid lobbyId, CancellationToken ct = default) => + _db.Lobbies + .Include(l => l.Members) + .FirstOrDefaultAsync(l => l.Id == lobbyId, ct); + + public Task GetByIdAsync(Guid lobbyId, CancellationToken ct = default) => + _db.Lobbies + .AsNoTracking() + .Include(l => l.Members) + .FirstOrDefaultAsync(l => l.Id == lobbyId, ct); + + public async Task> GetPublicPageAsync( + int limit, DateTime? afterCreatedAt, Guid? afterId, CancellationToken ct = default) + { + // Open + Public only, matching ix_lobbies_public_discovery exactly. A private lobby is not filtered out of + // a wider result set — it never enters the query, so it cannot influence page length or the cursor. + var query = _db.Lobbies + .AsNoTracking() + .Include(l => l.Members) + .Where(l => l.State == LobbyState.Open && l.Privacy == LobbyPrivacy.Public); + + if (afterCreatedAt is DateTime after && afterId is Guid afterGuid) + { + query = query.Where(l => + l.CreatedAt > after || (l.CreatedAt == after && l.Id.CompareTo(afterGuid) > 0)); + } + + return await query + .OrderBy(l => l.CreatedAt).ThenBy(l => l.Id) + .Take(limit) + .ToListAsync(ct); + } + + public async Task GetActiveLobbyForUserAsync(Guid userId, CancellationToken ct = default) + { + var lobbyId = await _db.LobbyMembers + .AsNoTracking() + .Where(m => m.UserId == userId && m.State == LobbyMemberState.Joined) + .Select(m => (Guid?)m.LobbyId) + .FirstOrDefaultAsync(ct); + + if (lobbyId is null) return null; + + // A seat in a terminal lobby is not an active lobby. The member row survives a close/expiry (it is the + // audit trail of who was there), so filtering on member state alone would report a finished lobby as live. + return await _db.Lobbies + .AsNoTracking() + .Include(l => l.Members) + .FirstOrDefaultAsync( + l => l.Id == lobbyId + && (l.State == LobbyState.Open || l.State == LobbyState.Starting), + ct); + } + + public async Task GetActiveTicketIdForUserAsync(Guid userId, CancellationToken ct = default) => + await _db.MatchmakingTickets + .AsNoTracking() + .Where(t => t.UserId == userId && NonTerminalTicketStates.Contains(t.State)) + .Select(t => (Guid?)t.Id) + .FirstOrDefaultAsync(ct); + + // ── Credentials ────────────────────────────────────────────────────────── + + public Task GetActiveCredentialAsync(Guid lobbyId, CancellationToken ct = default) => + _db.LobbyJoinCredentials + .FirstOrDefaultAsync( + c => c.LobbyId == lobbyId && c.State == LobbyCredentialState.Active, ct); + + // Both lookups match on the *digest*, never the plaintext, and return null for every failure mode alike. The + // caller cannot distinguish "no such code" from "rotated" from "expired" — and neither can an attacker. + public Task FindActiveByCodeDigestAsync( + string codeDigest, CancellationToken ct = default) => + _db.LobbyJoinCredentials + .FirstOrDefaultAsync( + c => c.CodeDigest == codeDigest && c.State == LobbyCredentialState.Active, ct); + + public Task FindActiveByLinkTokenDigestAsync( + string linkTokenDigest, CancellationToken ct = default) => + _db.LobbyJoinCredentials + .FirstOrDefaultAsync( + c => c.LinkTokenDigest == linkTokenDigest && c.State == LobbyCredentialState.Active, ct); + + // ── Invites ────────────────────────────────────────────────────────────── + + public Task GetInviteForUpdateAsync(Guid inviteId, CancellationToken ct = default) => + _db.LobbyInvites.FirstOrDefaultAsync(i => i.Id == inviteId, ct); + + public Task GetPendingInviteAsync( + Guid lobbyId, Guid inviteeUserId, CancellationToken ct = default) => + _db.LobbyInvites.FirstOrDefaultAsync( + i => i.LobbyId == lobbyId + && i.InviteeUserId == inviteeUserId + && i.State == LobbyInviteState.Pending, + ct); + + public async Task> GetPendingInvitesForUserAsync( + Guid inviteeUserId, DateTime nowUtc, int limit, CancellationToken ct = default) + { + // Bounded and joined in one round trip. The dashboard's "N active" badge is exactly this query's count — + // a count is never rendered without the authorized list it summarizes. + var rows = await _db.LobbyInvites + .AsNoTracking() + .Where(i => i.InviteeUserId == inviteeUserId + && i.State == LobbyInviteState.Pending + && i.ExpiresAtUtc > nowUtc) + .Join(_db.Lobbies.AsNoTracking(), i => i.LobbyId, l => l.Id, (i, l) => new { i, l }) + // An invite into a lobby that has since closed, started, or expired is dead — it must not appear as + // actionable, even though its own row is still Pending until the expiry sweep reaches it. + .Where(x => x.l.State == LobbyState.Open && x.l.ExpiresAtUtc > nowUtc) + .Join(_db.Users.AsNoTracking(), x => x.i.InviterUserId, u => u.Id, (x, u) => new { x.i, x.l, u }) + .OrderByDescending(x => x.i.CreatedAt).ThenByDescending(x => x.i.Id) + .Take(limit) + .ToListAsync(ct); + + return rows.Select(x => (x.i, x.l, x.u)).ToList(); + } + + // ── Expiry sweep (slice 6C) ────────────────────────────────────────────── + + public async Task> GetExpiredLobbiesAsync( + DateTime nowUtc, int batchSize, CancellationToken ct = default) => + await _db.Lobbies + .Include(l => l.Members) + .Where(l => (l.State == LobbyState.Open || l.State == LobbyState.Starting) + && l.ExpiresAtUtc <= nowUtc) + .OrderBy(l => l.ExpiresAtUtc).ThenBy(l => l.Id) + .Take(batchSize) + .ToListAsync(ct); + + public async Task> GetExpiredInvitesAsync( + DateTime nowUtc, int batchSize, CancellationToken ct = default) => + await _db.LobbyInvites + .Where(i => i.State == LobbyInviteState.Pending && i.ExpiresAtUtc <= nowUtc) + .OrderBy(i => i.ExpiresAtUtc).ThenBy(i => i.Id) + .Take(batchSize) + .ToListAsync(ct); + + // ── Start requests ─────────────────────────────────────────────────────── + + public Task GetOpenStartRequestAsync( + Guid lobbyId, int lobbyRevision, CancellationToken ct = default) => + _db.LobbyStartRequests.FirstOrDefaultAsync( + r => r.LobbyId == lobbyId + && r.LobbyRevision == lobbyRevision + && r.State == LobbyStartRequestState.Open, + ct); + + public Task GetStartRequestByIdempotencyKeyAsync( + Guid lobbyId, string idempotencyKey, CancellationToken ct = default) => + _db.LobbyStartRequests + .AsNoTracking() + .FirstOrDefaultAsync(r => r.LobbyId == lobbyId && r.IdempotencyKey == idempotencyKey, ct); + + // ── Cross-module reads ─────────────────────────────────────────────────── + + public Task GetCapabilityProfileAsync( + string gameSlug, int capabilityVersion, CancellationToken ct = default) => + _db.GameCapabilityProfiles + .AsNoTracking() + .FirstOrDefaultAsync( + p => p.GameSlug == gameSlug && p.CapabilityVersion == capabilityVersion, ct); + + public Task GetActiveCapabilityProfileAsync( + string gameSlug, CancellationToken ct = default) => + _db.GameCapabilityProfiles + .AsNoTracking() + .Where(p => p.GameSlug == gameSlug && p.IsActive) + .OrderByDescending(p => p.CapabilityVersion) + .FirstOrDefaultAsync(ct); + + public Task GetGameAsync(string gameSlug, CancellationToken ct = default) => + _db.Games.AsNoTracking().FirstOrDefaultAsync(g => g.Slug == gameSlug, ct); + + public async Task> GetGameModesAsync(Guid gameId, CancellationToken ct = default) => + await _db.GameModeCapabilities + .AsNoTracking() + .Where(c => c.GameId == gameId) + .Select(c => c.Mode) + .ToListAsync(ct); + + public async Task> GetBlockedCounterpartsAsync( + Guid userId, IReadOnlyList candidateUserIds, CancellationToken ct = default) + { + if (candidateUserIds.Count == 0) return Array.Empty(); + + // Either direction. A block is symmetric in its effect on a lobby: it does not matter who blocked whom, + // the two must not end up seated together. + var blocked = await _db.Blocks + .AsNoTracking() + .Where(b => + (b.BlockerId == userId && candidateUserIds.Contains(b.BlockedId)) || + (b.BlockedId == userId && candidateUserIds.Contains(b.BlockerId))) + .Select(b => b.BlockerId == userId ? b.BlockedId : b.BlockerId) + .Distinct() + .ToListAsync(ct); + + // The actor is trivially "not blocked with themselves"; a self-entry would make every roster look blocked. + return blocked.Where(id => id != userId).ToList(); + } + + public async Task> GetUsersAsync( + IReadOnlyList userIds, CancellationToken ct = default) + { + if (userIds.Count == 0) return new Dictionary(); + + return await _db.Users + .AsNoTracking() + .Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, ct); + } + + public Task AreFriendsAsync(Guid userA, Guid userB, CancellationToken ct = default) => + _db.Friendships.AsNoTracking().AnyAsync( + f => f.Status == FriendshipStatus.Accepted + && ((f.RequesterId == userA && f.AddresseeId == userB) + || (f.RequesterId == userB && f.AddresseeId == userA)), + ct); + + // ── Writes ─────────────────────────────────────────────────────────────── + + public async Task AddLobbyAsync( + Lobby lobby, LobbyJoinCredential credential, IReadOnlyList events, + CancellationToken ct = default) + { + await _db.Lobbies.AddAsync(lobby, ct); + await _db.LobbyJoinCredentials.AddAsync(credential, ct); + await StageAsync(events, ct); + await _db.SaveChangesAsync(ct); + } + + public async Task AddInviteAsync( + LobbyInvite invite, IReadOnlyList events, CancellationToken ct = default) + { + await _db.LobbyInvites.AddAsync(invite, ct); + await StageAsync(events, ct); + await _db.SaveChangesAsync(ct); + } + + public async Task AddStartRequestAsync( + LobbyStartRequest request, IReadOnlyList events, CancellationToken ct = default) + { + await _db.LobbyStartRequests.AddAsync(request, ct); + await StageAsync(events, ct); + // The lobby's Open -> Starting transition is already tracked from the command's read, so this single + // SaveChanges commits the state change and the MatchRequestedV1 row together or not at all. + await _db.SaveChangesAsync(ct); + } + + public async Task RotateCredentialAsync( + LobbyJoinCredential outgoing, LobbyJoinCredential incoming, IReadOnlyList events, + CancellationToken ct = default) + { + // Both rows in one SaveChanges: there is no instant at which the old code is dead but the new one does not + // exist, nor one at which both are redeemable. + _db.LobbyJoinCredentials.Update(outgoing); + await _db.LobbyJoinCredentials.AddAsync(incoming, ct); + await StageAsync(events, ct); + await _db.SaveChangesAsync(ct); + } + + public async Task SaveAsync(IReadOnlyList events, CancellationToken ct = default) + { + await StageAsync(events, ct); + await _db.SaveChangesAsync(ct); + } + + private async Task StageAsync(IReadOnlyList events, CancellationToken ct) + { + if (events.Count == 0) return; + await _db.OutboxMessages.AddRangeAsync(events, ct); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Repositories/MatchmakingRepository.cs b/src/SimPle.Infrastructure/Persistence/Repositories/MatchmakingRepository.cs new file mode 100644 index 0000000..f4d9d64 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Repositories/MatchmakingRepository.cs @@ -0,0 +1,172 @@ +using Microsoft.EntityFrameworkCore; +using SimPle.Application.Common.Interfaces; +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Outbox; + +namespace SimPle.Infrastructure.Persistence.Repositories; + +/// +/// Matchmaking data access. +/// +/// Like , nothing here catches a unique-violation or row-version exception — +/// contention propagates to / , which re-run the +/// whole unit of work so it can re-read and answer truthfully rather than deciding against stale state (R3). +/// +public sealed class MatchmakingRepository : IMatchmakingRepository +{ + private readonly AppDbContext _db; + + public MatchmakingRepository(AppDbContext db) => _db = db; + + private static readonly MatchmakingTicketState[] NonTerminalStates = + { + MatchmakingTicketState.Queued, + MatchmakingTicketState.Claimed, + MatchmakingTicketState.Requeued, + }; + + // ── Ticket reads ───────────────────────────────────────────────────────── + + public Task GetTicketAsync(Guid ticketId, CancellationToken ct = default) => + _db.MatchmakingTickets.AsNoTracking().FirstOrDefaultAsync(t => t.Id == ticketId, ct); + + public Task GetTicketForUpdateAsync(Guid ticketId, CancellationToken ct = default) => + _db.MatchmakingTickets.FirstOrDefaultAsync(t => t.Id == ticketId, ct); + + public Task GetActiveTicketForUserAsync(Guid userId, CancellationToken ct = default) => + _db.MatchmakingTickets.FirstOrDefaultAsync( + t => t.UserId == userId && NonTerminalStates.Contains(t.State), ct); + + public Task GetActiveAssignmentAsync(Guid ticketId, CancellationToken ct = default) => + _db.MatchmakingAssignments.AsNoTracking().FirstOrDefaultAsync( + a => a.TicketId == ticketId && a.State == MatchmakingAssignmentState.Active, ct); + + // ── Worker claim ───────────────────────────────────────────────────────── + + public async Task> ClaimQueuedTicketsAsync( + int batchSize, DateTime nowUtc, CancellationToken ct = default) + { + // EF cannot express FOR UPDATE SKIP LOCKED, so the *selection* is raw SQL and the load is not: we take the + // row locks on exactly the ids we want, then materialize those ids through the normal tracked query so the + // entities behave like any other aggregate the command layer mutates. + // + // On InMemory (the HTTP-contract tests) there is no row locking to take. Those tests assert routing, auth, + // and error mapping; the claim races this method exists for are asserted where they can actually be + // proven — against real PostgreSQL. + if (!_db.Database.IsRelational()) + { + return await _db.MatchmakingTickets + .Where(t => t.State == MatchmakingTicketState.Queued && t.DeadlineAtUtc > nowUtc) + .OrderBy(t => t.EnqueuedAtUtc).ThenBy(t => t.Id) + .Take(batchSize) + .ToListAsync(ct); + } + + // Oldest first: this is what anchors proposals on the longest-waiting ticket, which is half the + // anti-starvation guarantee (the widening bands are the other half). + // + // SKIP LOCKED means a second worker steps over rows this one holds instead of blocking behind them — that + // is a throughput property, NOT exclusivity (Risk #1). The partial unique index on active assignment is + // what makes double assignment impossible. + // + // "State" is stored as text, not an int: MatchmakingConfiguration declares HasConversion(), and the + // partial indexes' own filters are written against the string ('Queued'). Comparing it to an enum ordinal + // here would be a type error at best and, if it had coerced, would have silently matched nothing — a worker + // that claims no tickets and reports a healthy empty cycle. + var queued = nameof(MatchmakingTicketState.Queued); + + var ids = await _db.Database + .SqlQuery($""" + SELECT "Id" + FROM matchmaking_tickets + WHERE "State" = {queued} + AND "DeadlineAtUtc" > {nowUtc} + ORDER BY "EnqueuedAtUtc", "Id" + LIMIT {batchSize} + FOR UPDATE SKIP LOCKED + """) + .ToListAsync(ct); + + if (ids.Count == 0) return Array.Empty(); + + var tickets = await _db.MatchmakingTickets + .Where(t => ids.Contains(t.Id)) + .ToListAsync(ct); + + // Preserve the locked order — the caller anchors on the first one. + return tickets + .OrderBy(t => t.EnqueuedAtUtc).ThenBy(t => t.Id) + .ToList(); + } + + // ── Expiry sweep ───────────────────────────────────────────────────────── + + public async Task> GetExpiredTicketsAsync( + DateTime nowUtc, int batchSize, CancellationToken ct = default) => + await _db.MatchmakingTickets + .Where(t => NonTerminalStates.Contains(t.State) && t.DeadlineAtUtc <= nowUtc) + .OrderBy(t => t.DeadlineAtUtc).ThenBy(t => t.Id) + .Take(batchSize) + .ToListAsync(ct); + + // ── Observability ──────────────────────────────────────────────────────── + + public async Task GetOldestQueuedAgeAsync(DateTime nowUtc, CancellationToken ct = default) + { + var oldest = await _db.MatchmakingTickets + .AsNoTracking() + .Where(t => t.State == MatchmakingTicketState.Queued) + .OrderBy(t => t.EnqueuedAtUtc) + .Select(t => (DateTime?)t.EnqueuedAtUtc) + .FirstOrDefaultAsync(ct); + + return oldest is null ? null : nowUtc - oldest.Value; + } + + // ── Cross-module reads ─────────────────────────────────────────────────── + + public async Task> GetBlockedPairsAsync( + IReadOnlyList userIds, CancellationToken ct = default) + { + if (userIds.Count < 2) return Array.Empty<(Guid, Guid)>(); + + // Both endpoints must be in the batch: a block against somebody who is not in this queue cycle cannot + // affect any proposal it could form, and loading it would be work with no consequence. + var rows = await _db.Blocks + .AsNoTracking() + .Where(b => userIds.Contains(b.BlockerId) && userIds.Contains(b.BlockedId)) + .Select(b => new { b.BlockerId, b.BlockedId }) + .ToListAsync(ct); + + return rows.Select(r => (r.BlockerId, r.BlockedId)).ToList(); + } + + // ── Writes ─────────────────────────────────────────────────────────────── + + public async Task AddTicketAsync(MatchmakingTicket ticket, CancellationToken ct = default) + { + await _db.MatchmakingTickets.AddAsync(ticket, ct); + await _db.SaveChangesAsync(ct); + } + + public async Task AddAssignmentsAsync( + IReadOnlyList assignments, + IReadOnlyList events, + CancellationToken ct = default) + { + await _db.MatchmakingAssignments.AddRangeAsync(assignments, ct); + await _db.OutboxMessages.AddRangeAsync(events, ct); + + // The tickets' Claimed -> Matched transitions are already tracked from the claim, so this single + // SaveChanges commits the state changes, the assignments, and the MatchRequestedV1 rows together or not at + // all. There is no instant at which a ticket is Matched with no assignment, or an assignment exists with no + // event for M8 to consume. + await _db.SaveChangesAsync(ct); + } + + public async Task SaveAsync(IReadOnlyList events, CancellationToken ct = default) + { + if (events.Count > 0) await _db.OutboxMessages.AddRangeAsync(events, ct); + await _db.SaveChangesAsync(ct); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Repositories/OutboxRepository.cs b/src/SimPle.Infrastructure/Persistence/Repositories/OutboxRepository.cs new file mode 100644 index 0000000..14d765b --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Repositories/OutboxRepository.cs @@ -0,0 +1,179 @@ +using Microsoft.EntityFrameworkCore; +using SimPle.Application.Common.Interfaces; +using SimPle.Domain.Outbox; + +namespace SimPle.Infrastructure.Persistence.Repositories; + +/// +/// The outbox's delivery side (D3). See for the contract and +/// OutboxProcessor for why leases are committed before any handler runs. +/// +public sealed class OutboxRepository : IOutboxRepository +{ + private readonly AppDbContext _db; + + public OutboxRepository(AppDbContext db) => _db = db; + + public async Task> LeaseAsync( + string handlerName, + IReadOnlyList eventTypes, + int batchSize, + DateTime nowUtc, + DateTime leaseUntilUtc, + CancellationToken ct = default) + { + if (eventTypes.Count == 0) return Array.Empty<(OutboxMessage, OutboxDelivery)>(); + + var types = eventTypes.ToArray(); + + await BackfillMissingDeliveriesAsync(handlerName, types, batchSize, ct); + + var deliveryIds = await SelectLeasableDeliveryIdsAsync(handlerName, types, batchSize, nowUtc, ct); + if (deliveryIds.Count == 0) return Array.Empty<(OutboxMessage, OutboxDelivery)>(); + + var deliveries = await _db.OutboxDeliveries + .Where(d => deliveryIds.Contains(d.Id)) + .ToListAsync(ct); + + var eventIds = deliveries.Select(d => d.EventId).ToList(); + var messages = await _db.OutboxMessages + .AsNoTracking() + .Where(m => eventIds.Contains(m.Id)) + .ToDictionaryAsync(m => m.Id, ct); + + var leased = new List<(OutboxMessage, OutboxDelivery)>(deliveries.Count); + foreach (var delivery in deliveries) + { + if (!messages.TryGetValue(delivery.EventId, out var message)) continue; + + delivery.AcquireLease(leaseUntilUtc); + leased.Add((message, delivery)); + } + + // The lease is written here and committed by the caller's transaction *before* any handler runs. If it were + // written after, a crash mid-handler would leave the attempt uncounted and the row immediately retryable — + // the retry budget would never advance and a poison event would be retried forever. + await _db.SaveChangesAsync(ct); + + // Oldest event first, so a handler sees its events in roughly the order they happened. + return leased + .OrderBy(x => x.Item1.OccurredAtUtc).ThenBy(x => x.Item1.Id) + .ToList(); + } + + /// + /// Creates the delivery rows this handler is missing. + /// + /// + /// A message and its delivery rows are not written together, and cannot be: Module 3 has been emitting + /// UserBlockedV1 since long before any consumer existed, and a producer must not have to know who will + /// eventually listen. So the dispatcher materializes its own rows on first sight — which also means a newly + /// registered handler backfills the entire history of its event types, one bounded batch at a time. + /// + /// + /// + /// That backfill is safe precisely because handlers are idempotent and decide from current state: + /// LobbyBlockHandler replaying a year-old block finds the two users share no lobby and does nothing. It + /// is also why no activation watermark is needed — a second mechanism to get wrong, for no behavior gained. + /// + /// + /// + /// A concurrent dispatcher inserting the same row loses to the unique (EventId, HandlerName) index; that + /// 23505 is contention, so the whole cycle is re-run and simply finds the row already there. + /// + /// + private async Task BackfillMissingDeliveriesAsync( + string handlerName, string[] eventTypes, int batchSize, CancellationToken ct) + { + var missing = await _db.OutboxMessages + .AsNoTracking() + .Where(m => eventTypes.Contains(m.EventType)) + .Where(m => !_db.OutboxDeliveries.Any(d => d.EventId == m.Id && d.HandlerName == handlerName)) + .OrderBy(m => m.OccurredAtUtc).ThenBy(m => m.Id) + .Take(batchSize) + .Select(m => m.Id) + .ToListAsync(ct); + + if (missing.Count == 0) return; + + foreach (var eventId in missing) + _db.OutboxDeliveries.Add(OutboxDelivery.Create(eventId, handlerName)); + + await _db.SaveChangesAsync(ct); + } + + /// + /// The delivery rows this dispatcher may take: unprocessed, not dead-lettered, and not currently leased to a + /// live dispatcher. + /// + /// + /// The lease is a timestamp, not a boolean, and that is what makes a crashed dispatcher recoverable: a + /// lease that has lapsed is reclaimable by anyone, so work is never stranded by a process that died holding it. + /// FOR UPDATE … SKIP LOCKED then ensures a second dispatcher steps over the rows this one is taking + /// rather than blocking behind them. + /// + /// + private async Task> SelectLeasableDeliveryIdsAsync( + string handlerName, string[] eventTypes, int batchSize, DateTime nowUtc, CancellationToken ct) + { + // InMemory has neither raw SQL nor row locks. The dispatcher tests that use it assert bookkeeping — + // backfill, attempt counting, dead-lettering, idempotent replay. The concurrent-lease behavior this SQL + // exists for is asserted where it can actually be proven: against real PostgreSQL. + if (!_db.Database.IsRelational()) + { + return await _db.OutboxDeliveries + .Where(d => d.HandlerName == handlerName + && !d.Processed + && !d.DeadLettered + && (d.Lease == null || d.Lease <= nowUtc)) + .Join(_db.OutboxMessages.Where(m => eventTypes.Contains(m.EventType)), + d => d.EventId, m => m.Id, (d, m) => new { d.Id, m.OccurredAtUtc }) + .OrderBy(x => x.OccurredAtUtc) + .Take(batchSize) + .Select(x => x.Id) + .ToListAsync(ct); + } + + // FOR UPDATE OF d — lock the delivery rows only. The joined message rows are immutable and read-only here; + // locking them too would serialize unrelated handlers against each other for no reason. + return await _db.Database + .SqlQuery($""" + SELECT d."Id" + FROM outbox_deliveries d + JOIN outbox_messages m ON m."Id" = d."EventId" + WHERE d."HandlerName" = {handlerName} + AND d."Processed" = false + AND d."DeadLettered" = false + AND (d."Lease" IS NULL OR d."Lease" <= {nowUtc}) + AND m."EventType" = ANY({eventTypes}) + ORDER BY m."OccurredAtUtc", m."Id" + LIMIT {batchSize} + FOR UPDATE OF d SKIP LOCKED + """) + .ToListAsync(ct); + } + + public Task SaveAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct); + + public async Task GetOldestPendingAgeAsync( + string handlerName, IReadOnlyList eventTypes, DateTime nowUtc, CancellationToken ct = default) + { + if (eventTypes.Count == 0) return null; + + var types = eventTypes.ToArray(); + + // Counts a message with no delivery row yet as pending — otherwise a dispatcher that had stopped before it + // could even create its rows would report a lag of zero, which is the exact failure the signal exists to + // catch. + var oldest = await _db.OutboxMessages + .AsNoTracking() + .Where(m => types.Contains(m.EventType)) + .Where(m => !_db.OutboxDeliveries.Any(d => + d.EventId == m.Id && d.HandlerName == handlerName && (d.Processed || d.DeadLettered))) + .OrderBy(m => m.OccurredAtUtc) + .Select(m => (DateTime?)m.OccurredAtUtc) + .FirstOrDefaultAsync(ct); + + return oldest is null ? null : nowUtc - oldest.Value; + } +} diff --git a/src/SimPle.Infrastructure/Persistence/WorkerTransaction.cs b/src/SimPle.Infrastructure/Persistence/WorkerTransaction.cs new file mode 100644 index 0000000..a155321 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/WorkerTransaction.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SimPle.Application.Common.Interfaces; + +namespace SimPle.Infrastructure.Persistence; + +/// +/// over . +/// +/// Mirrors 's retry semantics and shares its contention predicate +/// (), but takes no advisory lock — see for why. +/// +public sealed class WorkerTransaction : IWorkerTransaction +{ + private readonly AppDbContext _db; + private readonly ILogger _logger; + + private const int MaxAttempts = 3; + + public WorkerTransaction(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task RunAsync(Func> work, CancellationToken ct = default) + { + for (var attempt = 1; ; attempt++) + { + try + { + return await RunOnceAsync(work, ct); + } + catch (Exception ex) when (attempt < MaxAttempts && PostgresContention.IsContention(ex)) + { + // The failed transaction is already rolled back — including any FOR UPDATE SKIP LOCKED row locks it + // held, which returns the claimed tickets to Queued with no compensating write and no lease to + // expire. Discard the losing attempt's tracked entities so the rerun genuinely re-reads. + _db.ChangeTracker.Clear(); + + _logger.LogInformation( + "Worker transaction contention; rerunning cycle. Attempt={Attempt} Reason={Reason}", + attempt, ex.GetType().Name); + + await Task.Delay(15 * attempt, ct); + } + } + } + + private async Task RunOnceAsync(Func> work, CancellationToken ct) + { + // The EF InMemory provider has no transactions and no raw SQL. Worker tests that use it assert cycle + // bookkeeping only; every claim/assignment race this class exists for is asserted where it can actually be + // proven — against real PostgreSQL. + if (!_db.Database.IsRelational()) + return await work(ct); + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + var result = await work(ct); + + await transaction.CommitAsync(ct); + return result; + } +} diff --git a/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj b/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj index 9f9d8f9..c7ac31f 100644 --- a/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj +++ b/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj @@ -9,6 +9,8 @@ + + diff --git a/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs b/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs index 8c417c1..667caa4 100644 --- a/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs +++ b/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs @@ -40,6 +40,10 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["Auth:RefreshTokenExpiryDays"] = "7", ["Auth:MaxFailedLoginAttempts"] = "10", ["Auth:LockoutDurationMinutes"] = "15", + // Module 6: the API fails closed at startup when the join-credential key is unset, exactly as it + // does for Jwt:SecretKey. Tests supply their own rather than the module shipping a dev fallback. + ["LobbyCredential:Key"] = "integration-tests-lobby-credential-key-123456", + ["LobbyCredential:DefaultRegion"] = "eu-west", ["Recaptcha:SecretKey"] = "integration-tests-recaptcha-secret", ["Recaptcha:VerificationUrl"] = "https://captcha.invalid/siteverify", ["Email:From"] = "test@example.com", diff --git a/tests/SimPle.IntegrationTests/Friends/FriendsMigrationSmokeTests.cs b/tests/SimPle.IntegrationTests/Friends/FriendsMigrationSmokeTests.cs index 55a1240..cc8bd92 100644 --- a/tests/SimPle.IntegrationTests/Friends/FriendsMigrationSmokeTests.cs +++ b/tests/SimPle.IntegrationTests/Friends/FriendsMigrationSmokeTests.cs @@ -51,7 +51,12 @@ public async Task DisposeAsync() SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '{_dbName}' - AND pid <> pg_backend_pid();"; + AND pid <> pg_backend_pid() + -- Only this role's own backends. Terminating another role's process raises 42501, and an + -- autovacuum worker (which runs as the bootstrap superuser) can appear on this database at + -- any moment -- so the unfiltered form fails intermittently under a least-privilege test role. + -- The teardown below clears autovacuum on its own, so skipping those backends is safe. + AND usename = current_user;"; await terminateCmd.ExecuteNonQueryAsync(); await using var dropCmd = masterConn.CreateCommand(); diff --git a/tests/SimPle.IntegrationTests/Friends/FriendsPostgresConcurrencyTests.cs b/tests/SimPle.IntegrationTests/Friends/FriendsPostgresConcurrencyTests.cs index cb80e5a..a5c85dc 100644 --- a/tests/SimPle.IntegrationTests/Friends/FriendsPostgresConcurrencyTests.cs +++ b/tests/SimPle.IntegrationTests/Friends/FriendsPostgresConcurrencyTests.cs @@ -53,7 +53,12 @@ public async Task DisposeAsync() SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '{_dbName}' - AND pid <> pg_backend_pid();"; + AND pid <> pg_backend_pid() + -- Only this role's own backends. Terminating another role's process raises 42501, and an + -- autovacuum worker (which runs as the bootstrap superuser) can appear on this database at + -- any moment -- so the unfiltered form fails intermittently under a least-privilege test role. + -- The teardown below clears autovacuum on its own, so skipping those backends is safe. + AND usename = current_user;"; await terminateCmd.ExecuteNonQueryAsync(); await using var dropCmd = masterConn.CreateCommand(); diff --git a/tests/SimPle.IntegrationTests/GameHost/GameHostTestWebApplicationFactory.cs b/tests/SimPle.IntegrationTests/GameHost/GameHostTestWebApplicationFactory.cs index b253556..6b48c7d 100644 --- a/tests/SimPle.IntegrationTests/GameHost/GameHostTestWebApplicationFactory.cs +++ b/tests/SimPle.IntegrationTests/GameHost/GameHostTestWebApplicationFactory.cs @@ -58,6 +58,10 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["Auth:RefreshTokenExpiryDays"] = "7", ["Auth:MaxFailedLoginAttempts"] = "10", ["Auth:LockoutDurationMinutes"] = "15", + // Module 6: the API fails closed at startup when the join-credential key is unset, exactly as it + // does for Jwt:SecretKey. Tests supply their own rather than the module shipping a dev fallback. + ["LobbyCredential:Key"] = "integration-tests-lobby-credential-key-123456", + ["LobbyCredential:DefaultRegion"] = "eu-west", ["Recaptcha:SecretKey"] = "integration-tests-recaptcha-secret", ["Recaptcha:VerificationUrl"] = "https://captcha.invalid/siteverify", ["Email:From"] = "test@example.com", diff --git a/tests/SimPle.IntegrationTests/Games/GameCatalogMigrationTests.cs b/tests/SimPle.IntegrationTests/Games/GameCatalogMigrationTests.cs index 59f83a0..9aedf08 100644 --- a/tests/SimPle.IntegrationTests/Games/GameCatalogMigrationTests.cs +++ b/tests/SimPle.IntegrationTests/Games/GameCatalogMigrationTests.cs @@ -49,7 +49,12 @@ public async Task DisposeAsync() SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '{_dbName}' - AND pid <> pg_backend_pid();"; + AND pid <> pg_backend_pid() + -- Only this role's own backends. Terminating another role's process raises 42501, and an + -- autovacuum worker (which runs as the bootstrap superuser) can appear on this database at + -- any moment -- so the unfiltered form fails intermittently under a least-privilege test role. + -- The teardown below clears autovacuum on its own, so skipping those backends is safe. + AND usename = current_user;"; await terminateCmd.ExecuteNonQueryAsync(); await using var dropCmd = masterConn.CreateCommand(); diff --git a/tests/SimPle.IntegrationTests/Games/GameCatalogSeederTests.cs b/tests/SimPle.IntegrationTests/Games/GameCatalogSeederTests.cs index 04b45c3..0abb507 100644 --- a/tests/SimPle.IntegrationTests/Games/GameCatalogSeederTests.cs +++ b/tests/SimPle.IntegrationTests/Games/GameCatalogSeederTests.cs @@ -48,7 +48,12 @@ public async Task DisposeAsync() SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '{_dbName}' - AND pid <> pg_backend_pid();"; + AND pid <> pg_backend_pid() + -- Only this role's own backends. Terminating another role's process raises 42501, and an + -- autovacuum worker (which runs as the bootstrap superuser) can appear on this database at + -- any moment -- so the unfiltered form fails intermittently under a least-privilege test role. + -- The teardown below clears autovacuum on its own, so skipping those backends is safe. + AND usename = current_user;"; await terminateCmd.ExecuteNonQueryAsync(); await using var dropCmd = masterConn.CreateCommand(); diff --git a/tests/SimPle.IntegrationTests/Games/GamesPostgresConcurrencyTests.cs b/tests/SimPle.IntegrationTests/Games/GamesPostgresConcurrencyTests.cs index 680cdfd..a9993d6 100644 --- a/tests/SimPle.IntegrationTests/Games/GamesPostgresConcurrencyTests.cs +++ b/tests/SimPle.IntegrationTests/Games/GamesPostgresConcurrencyTests.cs @@ -55,7 +55,12 @@ public async Task DisposeAsync() SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '{_dbName}' - AND pid <> pg_backend_pid();"; + AND pid <> pg_backend_pid() + -- Only this role's own backends. Terminating another role's process raises 42501, and an + -- autovacuum worker (which runs as the bootstrap superuser) can appear on this database at + -- any moment -- so the unfiltered form fails intermittently under a least-privilege test role. + -- The teardown below clears autovacuum on its own, so skipping those backends is safe. + AND usename = current_user;"; await terminateCmd.ExecuteNonQueryAsync(); await using var dropCmd = masterConn.CreateCommand(); diff --git a/tests/SimPle.IntegrationTests/Lobbies/GameCapabilitySeederTests.cs b/tests/SimPle.IntegrationTests/Lobbies/GameCapabilitySeederTests.cs new file mode 100644 index 0000000..ef3f637 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Lobbies/GameCapabilitySeederTests.cs @@ -0,0 +1,313 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using Npgsql; +using SimPle.Domain.Games; +using SimPle.Infrastructure.Capabilities; +using SimPle.Infrastructure.Games; +using SimPle.Infrastructure.Persistence; +using Xunit; + +namespace SimPle.IntegrationTests.Lobbies; + +/// +/// The capability seeder (D2), against real PostgreSQL — the advisory lock, the checksum, and the FK to +/// games.slug only exist in a real database. +/// +/// The seeder's most important property is that it fails CLOSED. A capability profile that permits seat counts or +/// modes Module 4's catalog does not would let a lobby be created that Module 5's engine cannot host — a failure +/// that would otherwise surface as a confused player clicking Start. The seeder refuses to write it. +/// +public sealed class GameCapabilitySeederTests : IAsyncLifetime +{ + private readonly string? _masterConn = Environment.GetEnvironmentVariable("MIGRATION_TEST_CONNECTION_STRING"); + private readonly string _dbName = $"simple_m6_seed_{Guid.NewGuid():N}"; + private string? _testConn; + + public async Task InitializeAsync() + { + if (_masterConn is null) return; + + var builder = new NpgsqlConnectionStringBuilder(_masterConn) { Database = _dbName }; + _testConn = builder.ToString(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + await using var createCmd = masterConn.CreateCommand(); + createCmd.CommandText = $"CREATE DATABASE \"{_dbName}\""; + await createCmd.ExecuteNonQueryAsync(); + + await using var db = CreateTestDb(); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + if (_masterConn is null || _testConn is null) return; + + NpgsqlConnection.ClearAllPools(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + + await using var terminateCmd = masterConn.CreateCommand(); + terminateCmd.CommandText = $@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_dbName}' + AND pid <> pg_backend_pid() + -- Only this role's own backends. Terminating another role's process raises 42501, and an + -- autovacuum worker (which runs as the bootstrap superuser) can appear on this database at + -- any moment -- so the unfiltered form fails intermittently under a least-privilege test role. + -- The teardown below clears autovacuum on its own, so skipping those backends is safe. + AND usename = current_user;"; + await terminateCmd.ExecuteNonQueryAsync(); + + await using var dropCmd = masterConn.CreateCommand(); + dropCmd.CommandText = $"DROP DATABASE IF EXISTS \"{_dbName}\""; + await dropCmd.ExecuteNonQueryAsync(); + } + + private void SkipIfNoPg() => Skip.If(_masterConn is null, + "Set MIGRATION_TEST_CONNECTION_STRING to a PostgreSQL connection string to run seeder tests."); + + private AppDbContext CreateTestDb() => + new(new DbContextOptionsBuilder().UseNpgsql(_testConn).Options); + + private GameCapabilitySeeder CreateSeeder(AppDbContext db) => + new(db, new FakeTimeProvider(new DateTime(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc)), + NullLogger.Instance); + + private async Task SeedCatalogAsync() + { + await using var db = CreateTestDb(); + var seeder = new GameCatalogSeeder(db, NullLogger.Instance); + var result = await seeder.SeedAsync(); + Assert.True(result.Success, result.Message); + } + + // ── Happy path ─────────────────────────────────────────────────────────── + + [SkippableFact] + public async Task Seeder_AppliesEveryProfileOverASeededCatalog() + { + SkipIfNoPg(); + + await SeedCatalogAsync(); + + await using var db = CreateTestDb(); + var result = await CreateSeeder(db).SeedAsync(); + + Assert.True(result.Success, result.Message); + Assert.Equal(8, result.ProfilesCreated); + Assert.Equal(0, result.ProfilesUpdated); + + await using var verify = CreateTestDb(); + Assert.Equal(8, await verify.GameCapabilityProfiles.CountAsync()); + Assert.Equal(8, await verify.GameCapabilityProfiles.CountAsync(p => p.IsActive)); + } + + [SkippableFact] + public async Task EverySeededProfileIsASubsetOfItsCatalogGame() + { + SkipIfNoPg(); + + // The invariant that keeps M6 and M4 honest with each other. Verified against what actually landed in the + // database, not against the manifest text. + await SeedCatalogAsync(); + + await using var db = CreateTestDb(); + await CreateSeeder(db).SeedAsync(); + + await using var verify = CreateTestDb(); + var profiles = await verify.GameCapabilityProfiles.AsNoTracking().ToListAsync(); + var games = await verify.Games.AsNoTracking().Include(g => g.Capabilities).ToListAsync(); + + foreach (var profile in profiles) + { + var game = games.Single(g => g.Slug == profile.GameSlug); + + var drift = profile.ContradictsCatalog( + game.MinPlayers, game.MaxPlayers, game.Capabilities.Select(c => c.Mode)); + + Assert.True(drift.Allowed, $"{profile.GameSlug}: {drift.Reason}"); + } + } + + [SkippableFact] + public async Task Seeder_RecordsItsManifestVersionAndChecksum() + { + SkipIfNoPg(); + + await SeedCatalogAsync(); + + await using var db = CreateTestDb(); + await CreateSeeder(db).SeedAsync(); + + await using var verify = CreateTestDb(); + var history = await verify.CapabilitySeedHistory.AsNoTracking().SingleAsync(); + + Assert.Equal("2026.1", history.ManifestVersion); + Assert.Matches("^[0-9a-f]{64}$", history.Checksum); + Assert.Equal(new DateTime(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc), history.AppliedAtUtc); + } + + // ── Idempotency ────────────────────────────────────────────────────────── + + [SkippableFact] + public async Task RerunningTheSeeder_IsANoOp() + { + SkipIfNoPg(); + + // The seeder runs on every deploy. A second run must not duplicate profiles or bump anything. + await SeedCatalogAsync(); + + await using (var first = CreateTestDb()) + Assert.True((await CreateSeeder(first).SeedAsync()).Success); + + await using var db = CreateTestDb(); + var second = await CreateSeeder(db).SeedAsync(); + + Assert.True(second.Success); + Assert.Contains("no-op", second.Message); + Assert.Equal(0, second.ProfilesCreated); + Assert.Equal(0, second.ProfilesUpdated); + + await using var verify = CreateTestDb(); + Assert.Equal(8, await verify.GameCapabilityProfiles.CountAsync()); + Assert.Equal(1, await verify.CapabilitySeedHistory.CountAsync()); + } + + // ── Fail-closed paths ──────────────────────────────────────────────────── + + [SkippableFact] + public async Task Seeder_FailsClosedWhenTheCatalogHasNotBeenSeeded() + { + SkipIfNoPg(); + + // Every profile is FK'd to games.slug. Seeding capabilities into an empty catalog must produce a clear + // message naming the missing game, not an opaque foreign-key violation. + await using var db = CreateTestDb(); + var result = await CreateSeeder(db).SeedAsync(); + + Assert.False(result.Success); + Assert.Contains("not in the Module 4 catalog", result.Message); + + await using var verify = CreateTestDb(); + Assert.Equal(0, await verify.GameCapabilityProfiles.CountAsync()); + Assert.Equal(0, await verify.CapabilitySeedHistory.CountAsync()); + } + + [SkippableFact] + public async Task Seeder_FailsClosedOnAChecksumMismatch() + { + SkipIfNoPg(); + + // Same manifest version, different content = someone edited a shipped manifest in place. Refusing to + // overwrite is what stops a silent capability change from reaching lobbies already pinned to that version. + await SeedCatalogAsync(); + + await using (var first = CreateTestDb()) + Assert.True((await CreateSeeder(first).SeedAsync()).Success); + + // Corrupt the recorded checksum to simulate the manifest having changed under the same version. + await using (var tamper = CreateTestDb()) + { + var history = await tamper.CapabilitySeedHistory.SingleAsync(); + await tamper.Database.ExecuteSqlRawAsync( + "UPDATE capability_seed_history SET \"Checksum\" = 'deadbeef' WHERE \"Id\" = {0}", history.Id); + } + + await using var db = CreateTestDb(); + var result = await CreateSeeder(db).SeedAsync(); + + Assert.False(result.Success); + Assert.Contains("Checksum mismatch", result.Message); + } + + [SkippableFact] + public async Task Seeder_FailsClosedWhenAProfileContradictsTheCatalog() + { + SkipIfNoPg(); + + // Simulate catalog drift: narrow a game's catalog bounds so the shipped profile no longer fits inside it. + // The seeder must refuse rather than write a profile that permits a lobby M5's engine cannot host. + await SeedCatalogAsync(); + + await using (var drift = CreateTestDb()) + { + // snake-rush ships as catalog 1..8; the profile pins 2..8. Shrink the catalog to 1..2. + await drift.Database.ExecuteSqlRawAsync( + "UPDATE games SET \"MaxPlayers\" = 2 WHERE \"Slug\" = 'snake-rush'"); + } + + await using var db = CreateTestDb(); + var result = await CreateSeeder(db).SeedAsync(); + + Assert.False(result.Success); + Assert.Contains("snake-rush", result.Message); + Assert.Contains("exceed the catalog", result.Message); + + await using var verify = CreateTestDb(); + Assert.Equal(0, await verify.GameCapabilityProfiles.CountAsync()); + Assert.Equal(0, await verify.CapabilitySeedHistory.CountAsync()); + } + + [SkippableFact] + public async Task Seeder_UsesADistinctAdvisoryLockFromTheCatalogSeeder() + { + SkipIfNoPg(); + + // 44004001 belongs to GameCatalogSeeder. Sharing it would serialize two unrelated seeders against each + // other for no reason. This asserts the capability seeder's lock is genuinely free while the catalog + // seeder's is held — i.e. that they are different keys. + await SeedCatalogAsync(); + + await using var holder = new NpgsqlConnection(_testConn); + await holder.OpenAsync(); + await using var holdCmd = holder.CreateCommand(); + holdCmd.CommandText = "SELECT pg_advisory_lock(44004001);"; // hold the CATALOG seeder's lock + await holdCmd.ExecuteNonQueryAsync(); + + try + { + // The capability seeder must still complete: it takes 44006001, not 44004001. + await using var db = CreateTestDb(); + var result = await CreateSeeder(db).SeedAsync(); + + Assert.True(result.Success, result.Message); + Assert.Equal(8, result.ProfilesCreated); + } + finally + { + await using var releaseCmd = holder.CreateCommand(); + releaseCmd.CommandText = "SELECT pg_advisory_unlock(44004001);"; + await releaseCmd.ExecuteNonQueryAsync(); + } + } + + [SkippableFact] + public async Task Seeder_IsSafeUnderConcurrentRuns() + { + SkipIfNoPg(); + + // Two deploy pods starting at once. The advisory lock serializes them; exactly one creates the profiles and + // the other converges to a no-op. Neither may fail, and there must be no duplicates. + await SeedCatalogAsync(); + + async Task RunAsync() + { + await using var db = CreateTestDb(); + return await CreateSeeder(db).SeedAsync(); + } + + var results = await Task.WhenAll(RunAsync(), RunAsync()); + + Assert.All(results, r => Assert.True(r.Success, r.Message)); + Assert.Equal(8, results.Sum(r => r.ProfilesCreated)); + + await using var verify = CreateTestDb(); + Assert.Equal(8, await verify.GameCapabilityProfiles.CountAsync()); + Assert.Equal(1, await verify.CapabilitySeedHistory.CountAsync()); + } +} diff --git a/tests/SimPle.IntegrationTests/Lobbies/LobbiesPostgresConcurrencyTests.cs b/tests/SimPle.IntegrationTests/Lobbies/LobbiesPostgresConcurrencyTests.cs new file mode 100644 index 0000000..dd3d050 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Lobbies/LobbiesPostgresConcurrencyTests.cs @@ -0,0 +1,484 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using Npgsql; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.GameHost.Services; +using SimPle.Application.Lobbies.DTOs; +using SimPle.Application.Lobbies.Services; +using SimPle.Domain.Capabilities; +using SimPle.Domain.GameHost; +using SimPle.Domain.Games; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Outbox; +using SimPle.Domain.Users; +using SimPle.Infrastructure.Lobbies; +using SimPle.Infrastructure.Persistence; +using SimPle.Infrastructure.Persistence.Repositories; +using SimPle.Shared.Common; +using Xunit; + +namespace SimPle.IntegrationTests.Lobbies; + +/// +/// Real-PostgreSQL tests for reconciliation R3 — the bounded whole-command retry +/// (). +/// +/// +/// These cannot run on InMemory, and the reason is the entire point of the class. InMemory does not enforce +/// filtered unique indexes, CHECK constraints, or xmin row versions, so a last-seat-join race asserted +/// against it would pass whatever the code did — including code that overfilled the lobby or threw a 500. Every +/// assertion below is about behavior that only a real database can produce. +/// +/// +/// +/// Each test builds the real over a real +/// and a real , each on its own +/// — separate contexts are what make the racers genuinely concurrent rather than two +/// calls sharing one change tracker. +/// +/// +/// Skipped unless MIGRATION_TEST_CONNECTION_STRING points at a running PostgreSQL instance. +/// +public sealed class LobbiesPostgresConcurrencyTests : IAsyncLifetime +{ + private readonly string? _masterConn = Environment.GetEnvironmentVariable("MIGRATION_TEST_CONNECTION_STRING"); + private readonly string _dbName = $"simple_m6b_{Guid.NewGuid():N}"; + private string? _testConn; + + private static readonly DateTime T0 = new(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc); + + public async Task InitializeAsync() + { + if (_masterConn is null) return; + + _testConn = new NpgsqlConnectionStringBuilder(_masterConn) { Database = _dbName }.ToString(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + await using var createCmd = masterConn.CreateCommand(); + createCmd.CommandText = $"CREATE DATABASE \"{_dbName}\""; + await createCmd.ExecuteNonQueryAsync(); + + await using var db = CreateDb(); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + if (_masterConn is null || _testConn is null) return; + + NpgsqlConnection.ClearAllPools(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + + await using var terminateCmd = masterConn.CreateCommand(); + terminateCmd.CommandText = $@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_dbName}' + AND pid <> pg_backend_pid() + -- Only this role's own backends. Terminating another role's process raises 42501, and an + -- autovacuum worker (which runs as the bootstrap superuser) can appear on this database at + -- any moment -- so the unfiltered form fails intermittently under a least-privilege test role. + -- The teardown below clears autovacuum on its own, so skipping those backends is safe. + AND usename = current_user;"; + await terminateCmd.ExecuteNonQueryAsync(); + + await using var dropCmd = masterConn.CreateCommand(); + dropCmd.CommandText = $"DROP DATABASE IF EXISTS \"{_dbName}\""; + await dropCmd.ExecuteNonQueryAsync(); + } + + // ── The R3 test ────────────────────────────────────────────────────────── + + /// + /// The reason exists. + /// + /// Two users race for the one remaining seat. Both read a lobby with room, both decide to join, both write. + /// The lobby's xmin row version makes the loser's UPDATE affect zero rows — EF surfaces that as + /// DbUpdateConcurrencyException, which is neither a unique violation nor a serialization failure, and + /// which PostgresRetry would not catch at all. + /// + /// The runner reruns the whole command. The rerun re-reads, finds the lobby full, and answers a typed + /// Lobbies.Full. Retrying only the SaveChanges — the pre-existing behavior this module + /// deliberately does not reuse — would have replayed a decision made against stale state and either overfilled + /// the lobby or surfaced a 500. + /// + [SkippableFact] + public async Task ConcurrentLastSeatJoins_ExactlyOneWins_AndTheLoserGetsATypedFull_NeverA500() + { + SkipIfNoPg(); + + var host = await SeedUserAsync(); + var racerA = await SeedUserAsync(); + var racerB = await SeedUserAsync(); + await SeedCatalogAsync(); + + // A 2-seat lobby with the host already seated: exactly one seat is left for two racers. + var lobbyId = await SeedLobbyAsync(host.Id, maxPlayers: 2); + var code = await SeedCredentialAsync(lobbyId, "RACE-CODE"); + + await using var dbA = CreateDb(); + await using var dbB = CreateDb(); + + var joinA = BuildService(dbA).JoinByCredentialAsync(racerA.Id, new JoinLobbyRequestDto(code, null)); + var joinB = BuildService(dbB).JoinByCredentialAsync(racerB.Id, new JoinLobbyRequestDto(code, null)); + + var results = await Task.WhenAll(joinA, joinB); + + // Exactly one winner. + results.Count(r => r.IsSuccess).Should().Be(1); + + // The loser is a typed, honest conflict — not an exception, not a 500, not a silent overfill. + var loser = results.Single(r => !r.IsSuccess); + loser.Error!.Code.Should().Be(LobbyErrors.Full); + + // And the database agrees: two seats, no more. + await using var verify = CreateDb(); + var seated = await verify.LobbyMembers + .CountAsync(m => m.LobbyId == lobbyId && m.State == LobbyMemberState.Joined); + seated.Should().Be(2); + } + + /// + /// Proves the retry mechanism fires, deterministically — the test above proves the observable + /// outcome, but two tasks racing on a fast machine can serialize by luck, in which case the loser would read a + /// full lobby and answer Lobbies.Full without the runner ever retrying anything. That test would then + /// pass with 's retry as dead code. This one cannot. + /// + /// + /// The setup forces the exact failure: context A loads the lobby, then a different context commits a + /// change to it. A's tracked copy is now stale. EF returns the tracked instance from A's next read + /// (that is what a change tracker does), so the first attempt writes against a stale xmin and the + /// UPDATE affects zero rows — DbUpdateConcurrencyException. + /// + /// + /// + /// The runner must then clear A's change tracker and rerun the whole delegate, so the second attempt genuinely + /// re-reads. Asserting the delegate ran twice is what proves both halves: the retry, and the + /// ChangeTracker.Clear() without which the rerun would re-read the same stale entity and lose forever. + /// + /// + [SkippableFact] + public async Task CommandRunner_OnAStaleRowVersion_ClearsTheTrackerAndRerunsTheWholeCommand() + { + SkipIfNoPg(); + + var host = await SeedUserAsync(); + var joiner = await SeedUserAsync(); + await SeedCatalogAsync(); + + var lobbyId = await SeedLobbyAsync(host.Id, maxPlayers: 4); + + await using var dbA = CreateDb(); + var repoA = new LobbyRepository(dbA); + var runner = new LobbyCommandRunner(dbA, NullLogger.Instance); + + // A loads the lobby and holds it tracked. + var staleLobby = await repoA.GetForUpdateAsync(lobbyId); + staleLobby!.Revision.Should().Be(1); + + // A different connection seats a member, bumping Revision (and xmin) underneath A. A's tracked copy is now + // stale, and it does not know it. + await using (var dbB = CreateDb()) + { + var repoB = new LobbyRepository(dbB); + var lobbyB = await repoB.GetForUpdateAsync(lobbyId); + lobbyB!.Join(joiner.Id, T0).Should().Be(LobbyOutcome.Ok); + await repoB.SaveAsync(Array.Empty()); + } + + var attempts = 0; + + var result = await runner.RunAsync(host.Id, async ct => + { + attempts++; + + // Re-read through the repository. On attempt 1 the change tracker still holds the stale instance and + // EF hands that back; on attempt 2 the tracker has been cleared, so this is a genuine fresh read. + var lobby = await repoA.GetForUpdateAsync(lobbyId, ct); + lobby!.ChangeSettings( + host.Id, + lobby.CurrentSettings with { TimeControlId = "rapid-10-0" }, + T0); + + await repoA.SaveAsync(Array.Empty(), ct); + return Result.Ok(lobby.Revision); + }); + + result.IsSuccess.Should().BeTrue(); + + // The whole read-decide-write ran twice: once against the stale row (which lost), once against the fresh + // one (which won). Exactly the behavior PostgresRetry's save-only retry could not have produced. + attempts.Should().Be(2); + + await using var verify = CreateDb(); + var final = await verify.Lobbies.AsNoTracking().SingleAsync(l => l.Id == lobbyId); + final.TimeControlId.Should().Be("rapid-10-0"); + } + + /// + /// The cross-table half of the one-active-lobby-or-ticket invariant (brief Risk #2). + /// + /// Two filtered unique indexes on different tables cannot see each other. Under READ COMMITTED, a join and an + /// enqueue racing for the same user would each read "nothing active" — neither seeing the other's uncommitted + /// row — and both would commit, leaving the user in a lobby and a queue. + /// + /// The runner's transaction-scoped pg_advisory_xact_lock on the actor is what closes that window. Here + /// the ticket is committed first, so the join must see it and refuse. + /// + [SkippableFact] + public async Task Join_WhileHoldingAQueuedTicket_IsRefused_AcrossTables() + { + SkipIfNoPg(); + + var host = await SeedUserAsync(); + var joiner = await SeedUserAsync(); + await SeedCatalogAsync(); + + var lobbyId = await SeedLobbyAsync(host.Id, maxPlayers: 4); + var code = await SeedCredentialAsync(lobbyId, "TICKET-CODE"); + + await using (var seedDb = CreateDb()) + { + seedDb.MatchmakingTickets.Add(MatchmakingTicket.Enqueue( + joiner.Id, "chess-lite", 1, "multiplayer", 2, "blitz-3-2", false, "eu-west", + MatchmakingTicket.ProvisionalRating, MatchmakingTicket.ProvisionalRatingSource, + Guid.NewGuid(), T0)); + await seedDb.SaveChangesAsync(); + } + + await using var db = CreateDb(); + var result = await BuildService(db).JoinByCredentialAsync(joiner.Id, new JoinLobbyRequestDto(code, null)); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(LobbyErrors.AlreadyActive); + + await using var verify = CreateDb(); + (await verify.LobbyMembers.AnyAsync(m => m.UserId == joiner.Id && m.State == LobbyMemberState.Joined)) + .Should().BeFalse(); + } + + /// + /// The same user double-submitting a create (a double-clicked button, a retried request). The advisory lock + /// serializes them; the second sees the first's committed lobby and refuses. Without the lock, both would read + /// "no active lobby" and the ux_lobby_members_one_joined_per_user index would catch it as a raw 23505 — + /// correct, but only after the second lobby row had already been written and had to be rolled back. + /// + [SkippableFact] + public async Task ConcurrentCreatesByTheSameUser_ProduceExactlyOneLobby() + { + SkipIfNoPg(); + + var user = await SeedUserAsync(); + await SeedCatalogAsync(); + + await using var dbA = CreateDb(); + await using var dbB = CreateDb(); + + var createA = BuildService(dbA).CreateAsync(user.Id, CreateRequest()); + var createB = BuildService(dbB).CreateAsync(user.Id, CreateRequest()); + + var results = await Task.WhenAll(createA, createB); + + results.Count(r => r.IsSuccess).Should().Be(1); + results.Single(r => !r.IsSuccess).Error!.Code.Should().Be(LobbyErrors.AlreadyActive); + + await using var verify = CreateDb(); + (await verify.Lobbies.CountAsync(l => l.HostUserId == user.Id)).Should().Be(1); + (await verify.LobbyMembers.CountAsync(m => m.UserId == user.Id && m.State == LobbyMemberState.Joined)) + .Should().Be(1); + } + + /// + /// The join code's uniqueness index is real, and a redeemed credential is durable: the digest — never the + /// plaintext — is what is stored, and the plaintext cannot be recovered from the row. + /// + [SkippableFact] + public async Task AJoinCredential_IsPersistedOnlyAsADigest() + { + SkipIfNoPg(); + + var host = await SeedUserAsync(); + await SeedCatalogAsync(); + + await using var db = CreateDb(); + var result = await BuildService(db).CreateAsync(host.Id, CreateRequest()); + result.IsSuccess.Should().BeTrue(); + + var plaintextCode = result.Value!.Credential.Code; + var plaintextToken = result.Value.Credential.LinkToken; + + await using var verify = CreateDb(); + var credential = await verify.LobbyJoinCredentials + .SingleAsync(c => c.LobbyId == result.Value.Lobby.LobbyId); + + credential.CodeDigest.Should().NotBe(plaintextCode); + credential.LinkTokenDigest.Should().NotBe(plaintextToken); + credential.CodeDigest.Should().NotContain(plaintextCode); + + // The two secrets are independent — a leaked code must not imply the link token. + credential.CodeDigest.Should().NotBe(credential.LinkTokenDigest); + } + + /// + /// Outbox atomicity: the membership change and its integration event commit together. A joined member with no + /// LobbyMemberJoinedV1 row would leave M7/M11 permanently unaware that someone is in the lobby. + /// + [SkippableFact] + public async Task AJoin_CommitsItsOutboxEventInTheSameTransactionAsTheSeat() + { + SkipIfNoPg(); + + var host = await SeedUserAsync(); + var joiner = await SeedUserAsync(); + await SeedCatalogAsync(); + + var lobbyId = await SeedLobbyAsync(host.Id, maxPlayers: 4); + var code = await SeedCredentialAsync(lobbyId, "OUTBOX-CODE"); + + await using var db = CreateDb(); + var result = await BuildService(db).JoinByCredentialAsync(joiner.Id, new JoinLobbyRequestDto(code, null)); + result.IsSuccess.Should().BeTrue(); + + await using var verify = CreateDb(); + var seated = await verify.LobbyMembers + .AnyAsync(m => m.LobbyId == lobbyId && m.UserId == joiner.Id && m.State == LobbyMemberState.Joined); + var evented = await verify.OutboxMessages + .AnyAsync(e => e.AggregateId == lobbyId && e.EventType == "LobbyMemberJoinedV1"); + + seated.Should().BeTrue(); + evented.Should().BeTrue(); + + // No credential ever reaches an outbox payload — it is durable, replayable, and read by every future + // consumer, so a secret that landed in one would be permanently disclosed. + var payloads = await verify.OutboxMessages + .Where(e => e.AggregateId == lobbyId) + .Select(e => e.Payload) + .ToListAsync(); + payloads.Should().NotBeEmpty(); + payloads.Should().OnlyContain(p => !p.Contains(code)); + } + + // ── Fixtures ───────────────────────────────────────────────────────────── + + private void SkipIfNoPg() => Skip.If(_masterConn is null, + "Set MIGRATION_TEST_CONNECTION_STRING to a PostgreSQL connection string to run Module 6B race tests."); + + private AppDbContext CreateDb() => + new(new DbContextOptionsBuilder().UseNpgsql(_testConn).Options); + + private static CreateLobbyRequestDto CreateRequest() => new( + GameSlug: "chess-lite", CapabilityVersion: 1, Privacy: "Private", MaxPlayers: 4, + TimeControlId: "blitz-3-2", Rated: false, Region: "eu-west", + SpectatorPolicy: "Anyone", TieBreakRuleId: "none", AiFillRequested: false); + + /// + /// The real service over the real repository and the real command runner, on the supplied context. Only the + /// leaf collaborators M6 does not own (storage, the M5 registry, the M7/M8/M9 probes) are substituted. + /// + private LobbiesService BuildService(AppDbContext db) + { + var hasher = new HmacLobbyCredentialHasher( + Options.Create(new LobbyCredentialOptions { Key = TestKey, DefaultRegion = "eu-west" })); + + var throttle = Substitute.For(); + throttle.GetRetryAfterUtcAsync(Arg.Any(), Arg.Any()).Returns((DateTime?)null); + + var matchRuntime = Substitute.For(); + matchRuntime.IsAvailableAsync(Arg.Any()).Returns(false); + matchRuntime.IsInActiveMatchAsync(Arg.Any(), Arg.Any()).Returns(false); + + var chat = Substitute.For(); + chat.IsAvailableAsync(Arg.Any()).Returns(false); + var ai = Substitute.For(); + ai.IsAvailableAsync(Arg.Any()).Returns(false); + + var engines = Substitute.For(); + engines.RegisteredDefinitions.Returns(Array.Empty()); + + var storage = Substitute.For(); + + return new LobbiesService( + new LobbyRepository(db), + new LobbyCommandRunner(db, NullLogger.Instance), + hasher, throttle, matchRuntime, chat, ai, engines, + new UserRepository(db), storage, + Options.Create(new StorageOptions()), + Options.Create(new LobbyCredentialOptions { Key = TestKey, DefaultRegion = "eu-west" }), + new FakeTimeProvider(T0), + NullLogger.Instance); + } + + private const string TestKey = "postgres-race-tests-lobby-credential-key"; + + private async Task SeedUserAsync() + { + await using var db = CreateDb(); + var g = Guid.NewGuid(); + var user = User.Create($"m6b{g:N}"[..20], $"m6b{g:N}@test.io", "hash", "M6B Race User"); + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } + + private async Task SeedCatalogAsync() + { + await using var db = CreateDb(); + + db.Games.Add(Game.Create( + slug: "chess-lite", name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: GameDifficulty.Medium, + estimatedDurationMinMinutes: 10, estimatedDurationMaxMinutes: 20, + minPlayers: 2, maxPlayers: 4, initialLifecycle: GameLifecycle.Available, + featuredRank: null, sortOrder: 1, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", + tags: new[] { "classic" }, + modes: new[] { "multiplayer", "cooperative" })); + + db.GameCapabilityProfiles.Add(GameCapabilityProfile.Create( + "chess-lite", 1, minPlayers: 2, maxPlayers: 4, + allowedModes: new[] { "multiplayer", "cooperative" }, + timeControls: new[] { "blitz-3-2", "rapid-10-0", "untimed" }, + tieBreakRules: new[] { "none", "sudden-death" }, + spectatorPolicies: new[] { "Anyone", "FriendsOnly", "Disabled" }, + ratedEligible: false, aiFillEligible: false, + manifestVersion: "2026.1")); + + await db.SaveChangesAsync(); + } + + private async Task SeedLobbyAsync(Guid hostId, int maxPlayers) + { + await using var db = CreateDb(); + var lobby = Lobby.Create( + hostId, + new LobbySettings("chess-lite", 1, LobbyPrivacy.Private, maxPlayers, "blitz-3-2", false, + "eu-west", SpectatorPolicy.Anyone, "none", false), + Guid.NewGuid(), T0); + db.Lobbies.Add(lobby); + await db.SaveChangesAsync(); + return lobby.Id; + } + + private async Task SeedCredentialAsync(Guid lobbyId, string plaintextCode) + { + var hasher = new HmacLobbyCredentialHasher( + Options.Create(new LobbyCredentialOptions { Key = TestKey, DefaultRegion = "eu-west" })); + + await using var db = CreateDb(); + db.LobbyJoinCredentials.Add(LobbyJoinCredential.Issue( + lobbyId, hasher.HashCode(plaintextCode), hasher.HashLinkToken(plaintextCode + "-link"), 1, T0)); + await db.SaveChangesAsync(); + + return plaintextCode; + } +} diff --git a/tests/SimPle.IntegrationTests/Lobbies/LobbyEndpointsTests.cs b/tests/SimPle.IntegrationTests/Lobbies/LobbyEndpointsTests.cs new file mode 100644 index 0000000..5231f19 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Lobbies/LobbyEndpointsTests.cs @@ -0,0 +1,719 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SimPle.Domain.Capabilities; +using SimPle.Domain.Friends; +using SimPle.Domain.Games; +using SimPle.Infrastructure.Persistence; +using SimPle.IntegrationTests.Auth; + +namespace SimPle.IntegrationTests.Lobbies; + +/// +/// HTTP-contract tests for the Module 6 lobby command surface (InMemory-backed +/// ): routing, auth, CSRF, validation, the privacy-safe not-found (BOLA), +/// credential-oracle prevention, and honest deferral of Start. +/// +/// +/// Everything genuinely concurrent lives in LobbiesPostgresConcurrencyTests instead, and that split is not +/// cosmetic: the EF InMemory provider does not enforce filtered unique indexes, CHECK constraints, or row +/// versions at all. A last-seat-join race asserted here would pass no matter what the code did, which is +/// worse than no test. +/// +/// +public sealed class LobbyEndpointsTests : IDisposable +{ + private readonly TestWebApplicationFactory _factory = new(); + + // ── Auth & CSRF ────────────────────────────────────────────────────────── + + [Fact] + public async Task Create_Anonymous_Returns401() + { + using var client = CreateClient(); + + var response = await client.PostAsJsonAsync("/api/lobbies", CreateRequest()); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Create_MissingCsrfHeader_Returns400() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + client.DefaultRequestHeaders.Remove("X-Requested-With"); + + var response = await client.PostAsJsonAsync("/api/lobbies", CreateRequest()); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await response.Content.ReadAsStringAsync()).Should().Contain("Auth.CsrfHeaderRequired"); + } + + [Fact] + public async Task Join_MissingCsrfHeader_Returns400() + { + using var client = CreateClient(); + await SignInAsync(client); + client.DefaultRequestHeaders.Remove("X-Requested-With"); + + var response = await client.PostAsJsonAsync("/api/lobbies/join", new { code = "ABCD-EFGH-JKLM" }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ── Create ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Create_ReturnsTheLobbyAndItsCredentialExactlyOnce() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + + var response = await client.PostAsJsonAsync("/api/lobbies", CreateRequest()); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var body = await ReadJsonAsync(response); + var lobby = body.GetProperty("lobby"); + var credential = body.GetProperty("credential"); + + lobby.GetProperty("state").GetString().Should().Be("Open"); + lobby.GetProperty("revision").GetInt32().Should().Be(1); + lobby.GetProperty("seats").GetArrayLength().Should().Be(1); + credential.GetProperty("code").GetString().Should().NotBeNullOrWhiteSpace(); + + // The subsequent read of the same lobby must NOT carry the credential — it is returned at create and + // rotate only, and a member re-reading their lobby must not be handed the secret again. + var lobbyId = lobby.GetProperty("lobbyId").GetGuid(); + var reread = await client.GetAsync($"/api/lobbies/{lobbyId}"); + var rereadBody = await reread.Content.ReadAsStringAsync(); + + rereadBody.Should().NotContain(credential.GetProperty("code").GetString()!); + rereadBody.Should().NotContain(credential.GetProperty("linkToken").GetString()!); + } + + [Fact] + public async Task Create_WithNoCapabilityProfile_Returns409CapabilityDisabled() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedGameOnlyAsync(); // catalog row, but no M6 capability profile pinned to it + + var response = await client.PostAsJsonAsync("/api/lobbies", CreateRequest()); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await response.Content.ReadAsStringAsync()).Should().Contain("Lobbies.CapabilityDisabled"); + } + + [Fact] + public async Task Create_WithAnUnknownTimeControl_Returns400() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + + var response = await client.PostAsJsonAsync( + "/api/lobbies", CreateRequest() with { TimeControlId = "not-a-time-control" }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await response.Content.ReadAsStringAsync()).Should().Contain("Validation.Failed"); + } + + [Fact] + public async Task Create_WhileAlreadyInALobby_Returns409() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + + await client.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var second = await client.PostAsJsonAsync("/api/lobbies", CreateRequest()); + + second.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await second.Content.ReadAsStringAsync()).Should().Contain("Lobbies.AlreadyActive"); + } + + // ── BOLA: a foreign or private lobby is indistinguishable from a missing one ── + + [Fact] + public async Task Get_APrivateLobbyBelongingToAnotherUser_Returns404_Not403() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + await SignInAsync(host); + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var lobbyId = (await ReadJsonAsync(created)).GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + + using var outsider = CreateClient(); + await SignInAsync(outsider); + + var foreign = await outsider.GetAsync($"/api/lobbies/{lobbyId}"); + var missing = await outsider.GetAsync($"/api/lobbies/{Guid.NewGuid()}"); + + // Identical status AND identical body. A 403, or a differently-worded 404, would confirm the id exists. + foreign.StatusCode.Should().Be(HttpStatusCode.NotFound); + missing.StatusCode.Should().Be(HttpStatusCode.NotFound); + (await foreign.Content.ReadAsStringAsync()) + .Should().Be(await missing.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Kick_ByAnOutsider_Returns404_NeverRevealingTheLobby() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + await SignInAsync(host); + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var lobbyId = (await ReadJsonAsync(created)).GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + + using var outsider = CreateClient(); + var outsiderId = await SignInAsync(outsider); + + var response = await outsider.PostAsJsonAsync( + $"/api/lobbies/{lobbyId}/kick", new { targetUserId = outsiderId, expectedRevision = 1 }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task AcceptInvite_AnotherUsersInviteId_Returns404() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + var hostId = await SignInAsync(host); + using var friend = CreateClient(); + var friendId = await SignInAsync(friend); + await MakeFriendsAsync(hostId, friendId); + + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var lobbyId = (await ReadJsonAsync(created)).GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + + var invited = await host.PostAsJsonAsync( + $"/api/lobbies/{lobbyId}/invites", new { inviteeUserId = friendId }); + var inviteId = (await ReadJsonAsync(invited)).GetProperty("inviteId").GetGuid(); + + // A third party holding the invite id must learn nothing from it. + using var outsider = CreateClient(); + await SignInAsync(outsider); + + var response = await outsider.PostAsync($"/api/lobbies/invites/{inviteId}/accept", null); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ── Credential join ────────────────────────────────────────────────────── + + [Fact] + public async Task Join_WithTheRealCode_SeatsTheCaller() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + await SignInAsync(host); + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var code = (await ReadJsonAsync(created)).GetProperty("credential").GetProperty("code").GetString(); + + using var joiner = CreateClient(); + await SignInAsync(joiner); + + var response = await joiner.PostAsJsonAsync("/api/lobbies/join", new { code }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await ReadJsonAsync(response)).GetProperty("seats").GetArrayLength().Should().Be(2); + } + + [Fact] + public async Task Join_WithALowercasedAndUnseparatedCode_StillWorks() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + await SignInAsync(host); + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var code = (await ReadJsonAsync(created)).GetProperty("credential").GetProperty("code").GetString()!; + + using var joiner = CreateClient(); + await SignInAsync(joiner); + + // A human retyping a code off a screen will not reproduce the dashes or the casing. + var mangled = code.Replace("-", string.Empty).ToLowerInvariant(); + var response = await joiner.PostAsJsonAsync("/api/lobbies/join", new { code = mangled }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Join_WithAWrongCode_Returns404_TheSameAsAnUnknownLobby() + { + using var client = CreateClient(); + await SignInAsync(client); + + var response = await client.PostAsJsonAsync("/api/lobbies/join", new { code = "ZZZZ-ZZZZ-ZZZZ" }); + + // 404, not 403 and not a bespoke code — the join endpoint must not be an oracle. + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + (await response.Content.ReadAsStringAsync()).Should().Contain("Lobbies.CredentialInvalid"); + } + + [Fact] + public async Task Join_WithARotatedCode_Returns404_IdenticalToAWrongCode() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + await SignInAsync(host); + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var body = await ReadJsonAsync(created); + var lobbyId = body.GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + var oldCode = body.GetProperty("credential").GetProperty("code").GetString(); + + var rotated = await host.PostAsync($"/api/lobbies/{lobbyId}/credential/rotate", null); + rotated.StatusCode.Should().Be(HttpStatusCode.OK); + var newCode = (await ReadJsonAsync(rotated)).GetProperty("code").GetString(); + newCode.Should().NotBe(oldCode); + + using var joiner = CreateClient(); + await SignInAsync(joiner); + + var withOld = await joiner.PostAsJsonAsync("/api/lobbies/join", new { code = oldCode }); + var withWrong = await joiner.PostAsJsonAsync("/api/lobbies/join", new { code = "ZZZZ-ZZZZ-ZZZZ" }); + + // The old code is dead the instant it is rotated, and it dies *indistinguishably*. + withOld.StatusCode.Should().Be(HttpStatusCode.NotFound); + (await withOld.Content.ReadAsStringAsync()) + .Should().Be(await withWrong.Content.ReadAsStringAsync()); + + // ...and the new one works. + var withNew = await joiner.PostAsJsonAsync("/api/lobbies/join", new { code = newCode }); + withNew.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Join_WithBothCodeAndLinkToken_Returns400() + { + using var client = CreateClient(); + await SignInAsync(client); + + var response = await client.PostAsJsonAsync( + "/api/lobbies/join", new { code = "ABCD-EFGH-JKLM", linkToken = "some-token" }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Join_WithTheShareLinkToken_AlsoSeatsTheCaller() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + await SignInAsync(host); + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var linkToken = (await ReadJsonAsync(created)) + .GetProperty("credential").GetProperty("linkToken").GetString(); + + using var joiner = CreateClient(); + await SignInAsync(joiner); + + var response = await joiner.PostAsJsonAsync("/api/lobbies/join", new { linkToken }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // ── Readiness, settings, stale revision ────────────────────────────────── + + [Fact] + public async Task SetReadiness_WithAStaleRevision_Returns409() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + await SignInAsync(host); + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var body = await ReadJsonAsync(created); + var lobbyId = body.GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + var code = body.GetProperty("credential").GetProperty("code").GetString(); + + using var joiner = CreateClient(); + await SignInAsync(joiner); + await joiner.PostAsJsonAsync("/api/lobbies/join", new { code }); // bumps the revision to 2 + + var response = await joiner.PutAsJsonAsync( + $"/api/lobbies/{lobbyId}/ready", new { isReady = true, expectedRevision = 1 }); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await response.Content.ReadAsStringAsync()).Should().Contain("Lobbies.StaleRevision"); + } + + [Fact] + public async Task UpdateSettings_ByANonHostMember_Returns403() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + await SignInAsync(host); + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var body = await ReadJsonAsync(created); + var lobbyId = body.GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + var code = body.GetProperty("credential").GetProperty("code").GetString(); + + using var joiner = CreateClient(); + await SignInAsync(joiner); + await joiner.PostAsJsonAsync("/api/lobbies/join", new { code }); + + var response = await joiner.PatchAsJsonAsync($"/api/lobbies/{lobbyId}/settings", new + { + gameSlug = "chess-lite", + capabilityVersion = 1, + privacy = "Private", + maxPlayers = 4, + timeControlId = "rapid-10-0", + rated = false, + region = "eu-west", + spectatorPolicy = "Anyone", + tieBreakRuleId = "none", + aiFillRequested = false, + expectedRevision = 2, + }); + + // 403, not 404: this caller is a seated member and already knows the lobby exists. + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await response.Content.ReadAsStringAsync()).Should().Contain("Lobbies.Forbidden"); + } + + // ── Honest deferral ────────────────────────────────────────────────────── + + /// + /// The module's central promise, asserted end-to-end over HTTP: with no Module 8 registered, Start is a 503, + /// the lobby stays Open, and nothing hands the client a room to navigate to. + /// + [Fact] + public async Task Start_WithEveryoneReadyButNoMatchRuntime_Returns503_AndTheLobbyStaysOpen() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + await SignInAsync(host); + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var body = await ReadJsonAsync(created); + var lobbyId = body.GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + var code = body.GetProperty("credential").GetProperty("code").GetString(); + + using var joiner = CreateClient(); + await SignInAsync(joiner); + var joined = await joiner.PostAsJsonAsync("/api/lobbies/join", new { code }); + var revision = (await ReadJsonAsync(joined)).GetProperty("revision").GetInt32(); + + var readied = await joiner.PutAsJsonAsync( + $"/api/lobbies/{lobbyId}/ready", new { isReady = true, expectedRevision = revision }); + var readyBody = await ReadJsonAsync(readied); + readyBody.GetProperty("seats").EnumerateArray() + .All(s => s.GetProperty("isReady").GetBoolean()).Should().BeTrue(); + + var start = await host.PostAsJsonAsync($"/api/lobbies/{lobbyId}/start", new + { + expectedRevision = readyBody.GetProperty("revision").GetInt32(), + idempotencyKey = Guid.NewGuid().ToString("N"), + }); + + start.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + (await start.Content.ReadAsStringAsync()).Should().Contain("Lobbies.MatchRuntimeUnavailable"); + + // The lobby did not move, and no room exists to navigate to. + var after = await ReadJsonAsync(await host.GetAsync($"/api/lobbies/{lobbyId}")); + after.GetProperty("state").GetString().Should().Be("Open"); + after.GetProperty("dependencyReadiness").GetProperty("matchRuntime").GetBoolean().Should().BeFalse(); + after.GetProperty("allowedActions").EnumerateArray() + .Select(a => a.GetString()).Should().NotContain("start"); + } + + [Fact] + public async Task Rematch_Returns503_BecauseModule8OwnsMatchRecords() + { + using var client = CreateClient(); + await SignInAsync(client); + + var response = await client.PostAsync($"/api/matches/{Guid.NewGuid()}/rematch-lobbies", null); + + response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + (await response.Content.ReadAsStringAsync()).Should().Contain("Lobbies.MatchRuntimeUnavailable"); + } + + // ── Discovery ──────────────────────────────────────────────────────────── + + [Fact] + public async Task GetPublic_ShowsPublicLobbiesAndNeverPrivateOnes() + { + await SeedCatalogAsync(); + + using var publicHost = CreateClient(); + await SignInAsync(publicHost); + await publicHost.PostAsJsonAsync("/api/lobbies", CreateRequest() with { Privacy = "Public" }); + + using var privateHost = CreateClient(); + await SignInAsync(privateHost); + var privateCreated = await privateHost.PostAsJsonAsync( + "/api/lobbies", CreateRequest() with { Privacy = "Private" }); + var privateId = (await ReadJsonAsync(privateCreated)) + .GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + + using var browser = CreateClient(); + await SignInAsync(browser); + + var response = await browser.GetAsync("/api/lobbies?limit=20"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var raw = await response.Content.ReadAsStringAsync(); + raw.Should().NotContain(privateId.ToString()); + + var items = (await ReadJsonAsync(response)).GetProperty("items"); + items.GetArrayLength().Should().Be(1); + } + + [Fact] + public async Task GetPublic_WithAForgedCursor_Returns400() + { + using var client = CreateClient(); + await SignInAsync(client); + + var response = await client.GetAsync("/api/lobbies?cursor=not-a-real-cursor!!!"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await response.Content.ReadAsStringAsync()).Should().Contain("Pagination.InvalidCursor"); + } + + [Fact] + public async Task GetMyActive_ReportsTheLobbyAndNeverBothLobbyAndTicket() + { + await SeedCatalogAsync(); + + using var client = CreateClient(); + await SignInAsync(client); + await client.PostAsJsonAsync("/api/lobbies", CreateRequest()); + + var response = await client.GetAsync("/api/lobbies/me/active"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await ReadJsonAsync(response); + + body.GetProperty("lobby").ValueKind.Should().NotBe(JsonValueKind.Null); + body.GetProperty("ticketId").ValueKind.Should().Be(JsonValueKind.Null); + } + + // ── Invites (R6) ───────────────────────────────────────────────────────── + + [Fact] + public async Task Invite_ThenAccept_SeatsTheFriend_AndTheInviteIsListedFirst() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + var hostId = await SignInAsync(host); + using var friend = CreateClient(); + var friendId = await SignInAsync(friend); + await MakeFriendsAsync(hostId, friendId); + + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var lobbyId = (await ReadJsonAsync(created)).GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + + var invited = await host.PostAsJsonAsync( + $"/api/lobbies/{lobbyId}/invites", new { inviteeUserId = friendId }); + invited.StatusCode.Should().Be(HttpStatusCode.Created); + var inviteId = (await ReadJsonAsync(invited)).GetProperty("inviteId").GetGuid(); + + // The invitee sees it in their own list — the dashboard's badge count is this same bounded query. + var listed = await ReadJsonAsync(await friend.GetAsync("/api/lobbies/me/invites")); + listed.GetArrayLength().Should().Be(1); + listed[0].GetProperty("inviteId").GetGuid().Should().Be(inviteId); + + var accepted = await friend.PostAsync($"/api/lobbies/invites/{inviteId}/accept", null); + + accepted.StatusCode.Should().Be(HttpStatusCode.OK); + (await ReadJsonAsync(accepted)).GetProperty("seats").GetArrayLength().Should().Be(2); + } + + [Fact] + public async Task Invite_ToANonFriend_Returns400_SoAPrivateLobbyCannotBeRevealed() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + await SignInAsync(host); + using var stranger = CreateClient(); + var strangerId = await SignInAsync(stranger); + + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var lobbyId = (await ReadJsonAsync(created)).GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + + var response = await host.PostAsJsonAsync( + $"/api/lobbies/{lobbyId}/invites", new { inviteeUserId = strangerId }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await response.Content.ReadAsStringAsync()).Should().Contain("Lobbies.InvalidTarget"); + } + + [Fact] + public async Task RevokedInvite_CannotBeAccepted() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + var hostId = await SignInAsync(host); + using var friend = CreateClient(); + var friendId = await SignInAsync(friend); + await MakeFriendsAsync(hostId, friendId); + + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var lobbyId = (await ReadJsonAsync(created)).GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + + var invited = await host.PostAsJsonAsync( + $"/api/lobbies/{lobbyId}/invites", new { inviteeUserId = friendId }); + var inviteId = (await ReadJsonAsync(invited)).GetProperty("inviteId").GetGuid(); + + var revoked = await host.DeleteAsync($"/api/lobbies/{lobbyId}/invites/{inviteId}"); + revoked.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var accepted = await friend.PostAsync($"/api/lobbies/invites/{inviteId}/accept", null); + + // Revocation is immediate. This is exactly what an invite-carries-the-join-code design would have broken. + accepted.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + // ── Leave & host transfer ──────────────────────────────────────────────── + + [Fact] + public async Task Leave_ByTheHost_TransfersHostingToTheRemainingMember() + { + await SeedCatalogAsync(); + + using var host = CreateClient(); + await SignInAsync(host); + var created = await host.PostAsJsonAsync("/api/lobbies", CreateRequest()); + var body = await ReadJsonAsync(created); + var lobbyId = body.GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + var code = body.GetProperty("credential").GetProperty("code").GetString(); + + using var joiner = CreateClient(); + var joinerId = await SignInAsync(joiner); + await joiner.PostAsJsonAsync("/api/lobbies/join", new { code }); + + var left = await host.PostAsync($"/api/lobbies/{lobbyId}/leave", null); + left.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var after = await ReadJsonAsync(await joiner.GetAsync($"/api/lobbies/{lobbyId}")); + + after.GetProperty("hostUserId").GetGuid().Should().Be(joinerId); + after.GetProperty("seats").GetArrayLength().Should().Be(1); + after.GetProperty("allowedActions").EnumerateArray() + .Select(a => a.GetString()).Should().Contain("invite"); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private const string TestPassword = "ValidPassword1"; + + private sealed record CreateLobbyBody( + string GameSlug, int CapabilityVersion, string Privacy, int MaxPlayers, string TimeControlId, + bool Rated, string? Region, string SpectatorPolicy, string TieBreakRuleId, bool AiFillRequested); + + private static CreateLobbyBody CreateRequest() => new( + "chess-lite", 1, "Private", 4, "blitz-3-2", false, "eu-west", "Anyone", "none", false); + + private HttpClient CreateClient() => + _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + HandleCookies = true, + }).WithCsrfHeader(); + + /// Registers + logs in a fresh account and returns its user id. + private async Task SignInAsync(HttpClient client) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var email = $"lby-{suffix}@example.com"; + var username = $"lby{suffix}"; + + await client.PostAsJsonAsync("/api/auth/register", new + { + Username = username, + Email = email, + Password = TestPassword, + ConfirmPassword = TestPassword, + CaptchaToken = "test-captcha-token", + }); + await client.PostAsJsonAsync("/api/auth/login", new + { + EmailOrUsername = email, + Password = TestPassword, + CaptchaToken = "test-captcha-token", + }); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.SingleAsync(u => u.NormalizedUsername == username.ToUpperInvariant()); + return user.Id; + } + + private async Task MakeFriendsAsync(Guid a, Guid b) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var friendship = Friendship.Request(a, b); + friendship.Accept(b); // the addressee is the one who accepts + db.Friendships.Add(friendship); + await db.SaveChangesAsync(); + } + + /// An available catalog row plus the M6 capability profile a lobby pins to it. + private async Task SeedCatalogAsync() + { + await SeedGameOnlyAsync(); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.GameCapabilityProfiles.Add(GameCapabilityProfile.Create( + "chess-lite", 1, minPlayers: 2, maxPlayers: 4, + allowedModes: new[] { "multiplayer", "cooperative" }, + timeControls: new[] { "blitz-3-2", "rapid-10-0", "untimed" }, + tieBreakRules: new[] { "none", "sudden-death" }, + spectatorPolicies: new[] { "Anyone", "FriendsOnly", "Disabled" }, + ratedEligible: false, aiFillEligible: false, + manifestVersion: "2026.1")); + await db.SaveChangesAsync(); + } + + private async Task SeedGameOnlyAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (await db.Games.AnyAsync(g => g.Slug == "chess-lite")) return; + + db.Games.Add(Game.Create( + slug: "chess-lite", name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: GameDifficulty.Medium, + estimatedDurationMinMinutes: 10, estimatedDurationMaxMinutes: 20, + minPlayers: 2, maxPlayers: 4, initialLifecycle: GameLifecycle.Available, + featuredRank: null, sortOrder: 1, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", + tags: new[] { "classic" }, + modes: new[] { "multiplayer", "cooperative" })); + await db.SaveChangesAsync(); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) => + JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone(); + + public void Dispose() => _factory.Dispose(); +} diff --git a/tests/SimPle.IntegrationTests/Lobbies/LobbyMatchmakingMigrationTests.cs b/tests/SimPle.IntegrationTests/Lobbies/LobbyMatchmakingMigrationTests.cs new file mode 100644 index 0000000..0652628 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Lobbies/LobbyMatchmakingMigrationTests.cs @@ -0,0 +1,759 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SimPle.Domain.Capabilities; +using SimPle.Domain.Games; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Users; +using SimPle.Infrastructure.Persistence; +using Xunit; + +namespace SimPle.IntegrationTests.Lobbies; + +/// +/// PostgreSQL-only tests for the AddLobbyMatchmakingAndCapabilities migration. Skipped unless +/// MIGRATION_TEST_CONNECTION_STRING points at a running PostgreSQL instance. Each test METHOD gets its own +/// isolated database (xUnit IAsyncLifetime), created in InitializeAsync and dropped in DisposeAsync. +/// +/// These tests exist because the application layer CANNOT enforce Module 6's core invariants. A C# "is this user +/// already in a lobby?" check reads, decides, and writes across a window in which another transaction can do the +/// same — both see "no", both insert, and the invariant is gone. Only a partial unique index rejects the second +/// writer. Everything asserted here is therefore asserted against a real database, never InMemory (which silently +/// ignores filtered indexes and CHECK constraints entirely). +/// +public sealed class LobbyMatchmakingMigrationTests : IAsyncLifetime +{ + private readonly string? _masterConn = Environment.GetEnvironmentVariable("MIGRATION_TEST_CONNECTION_STRING"); + private readonly string _dbName = $"simple_m6_smoke_{Guid.NewGuid():N}"; + private string? _testConn; + + public async Task InitializeAsync() + { + if (_masterConn is null) return; + + var builder = new NpgsqlConnectionStringBuilder(_masterConn) { Database = _dbName }; + _testConn = builder.ToString(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + await using var createCmd = masterConn.CreateCommand(); + createCmd.CommandText = $"CREATE DATABASE \"{_dbName}\""; + await createCmd.ExecuteNonQueryAsync(); + + await using var db = CreateTestDb(); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + if (_masterConn is null || _testConn is null) return; + + NpgsqlConnection.ClearAllPools(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + + await using var terminateCmd = masterConn.CreateCommand(); + terminateCmd.CommandText = $@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_dbName}' + AND pid <> pg_backend_pid() + -- Only this role's own backends. Terminating another role's process raises 42501, and an + -- autovacuum worker (which runs as the bootstrap superuser) can appear on this database at + -- any moment -- so the unfiltered form fails intermittently under a least-privilege test role. + -- The teardown below clears autovacuum on its own, so skipping those backends is safe. + AND usename = current_user;"; + await terminateCmd.ExecuteNonQueryAsync(); + + await using var dropCmd = masterConn.CreateCommand(); + dropCmd.CommandText = $"DROP DATABASE IF EXISTS \"{_dbName}\""; + await dropCmd.ExecuteNonQueryAsync(); + } + + private void SkipIfNoPg() => Skip.If(_masterConn is null, + "Set MIGRATION_TEST_CONNECTION_STRING to a PostgreSQL connection string to run Module 6 smoke tests."); + + private AppDbContext CreateTestDb() => + new(new DbContextOptionsBuilder().UseNpgsql(_testConn).Options); + + private static readonly DateTime T0 = new(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc); + + private async Task SeedUserAsync() + { + await using var db = CreateTestDb(); + var g = Guid.NewGuid(); + var user = User.Create($"m6sm{g:N}"[..24], $"m6sm{g:N}@test.io", "hash", "M6 Smoke User"); + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } + + private async Task SeedGameAsync(string slug, int minPlayers = 2, int maxPlayers = 4, + params string[] modes) + { + await using var db = CreateTestDb(); + var game = Game.Create( + slug, "Test Game", "Summary.", "Rules.", GameDifficulty.Easy, + 1, 5, minPlayers, maxPlayers, GameLifecycle.ComingSoon, null, 0, + "art-token", "#111111", "#222222", "Test artwork", "2026.1", + "strategy", new[] { "puzzle" }, + modes.Length > 0 ? modes : new[] { "multiplayer", "ranked", "ai" }); + db.Games.Add(game); + await db.SaveChangesAsync(); + return game; + } + + private static LobbySettings Settings(string gameSlug = "smoke-game", int maxPlayers = 4) => + new(gameSlug, 1, LobbyPrivacy.Private, maxPlayers, "blitz-3-2", false, + "eu-west", SpectatorPolicy.Anyone, "none", false); + + private static async Task AddLobbyAsync(AppDbContext db, Guid hostId, string gameSlug, int maxPlayers = 4) + { + var lobby = Lobby.Create(hostId, Settings(gameSlug, maxPlayers), Guid.NewGuid(), T0); + db.Lobbies.Add(lobby); + await db.SaveChangesAsync(); + return lobby; + } + + private static bool IsUniqueViolation(Exception ex) => + ex is DbUpdateException { InnerException: PostgresException { SqlState: "23505" } }; + + private static bool IsCheckViolation(Exception ex) => + ex is DbUpdateException { InnerException: PostgresException { SqlState: "23514" } }; + + // ── Migration health ───────────────────────────────────────────────────── + + [SkippableFact] + public async Task Migration_CreatesEveryModule6Table() + { + SkipIfNoPg(); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + + foreach (var table in new[] + { + "lobbies", "lobby_members", "lobby_invites", "lobby_join_credentials", + "lobby_start_requests", "matchmaking_tickets", "matchmaking_assignments", + "game_capability_profiles", "capability_seed_history", + }) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = @t"; + cmd.Parameters.AddWithValue("t", table); + Assert.Equal(1L, (long)(await cmd.ExecuteScalarAsync())!); + } + } + + [SkippableFact] + public async Task Migration_CreatesEveryPartialUniqueIndex_AsActuallyPartial() + { + SkipIfNoPg(); + + // Asserting the index exists is not enough: an index created WITHOUT its filter would still be "present" + // while silently forbidding a user from ever re-joining a lobby they had left. The WHERE clause is the + // whole point, so it is asserted explicitly. + var expected = new (string Index, string MustContain)[] + { + ("ux_lobby_members_one_joined_per_user", "Joined"), + ("ux_matchmaking_tickets_one_nonterminal_per_user", "Queued"), + ("ux_matchmaking_assignments_one_active_per_ticket", "Active"), + ("ux_lobby_join_credentials_active_code", "Active"), + ("ux_lobby_start_requests_one_open_per_revision", "Open"), + ("ux_game_capability_profiles_one_active_per_game", "IsActive"), + ("ix_lobbies_public_discovery", "Public"), + }; + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + + foreach (var (index, mustContain) in expected) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT indexdef FROM pg_indexes WHERE indexname = @i"; + cmd.Parameters.AddWithValue("i", index); + + var indexDef = (string?)await cmd.ExecuteScalarAsync(); + + Assert.NotNull(indexDef); + Assert.Contains("WHERE", indexDef!); + Assert.Contains(mustContain, indexDef!); + } + } + + // ── One joined membership per user, ACROSS lobbies ─────────────────────── + + [SkippableFact] + public async Task OneJoinedMembershipPerUser_IsEnforcedAcrossDifferentLobbies() + { + SkipIfNoPg(); + + // The cross-lobby case is the one an application check reliably gets wrong, because the natural query is + // scoped to the lobby being joined. + var host = await SeedUserAsync(); + var joiner = await SeedUserAsync(); + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + var lobbyA = await AddLobbyAsync(db, host.Id, "smoke-game"); + var lobbyB = await AddLobbyAsync(db, (await SeedUserAsync()).Id, "smoke-game"); + + lobbyA.Join(joiner.Id, T0); + await db.SaveChangesAsync(); + + lobbyB.Join(joiner.Id, T0); + + var ex = await Assert.ThrowsAnyAsync(() => db.SaveChangesAsync()); + Assert.True(IsUniqueViolation(ex), "a second joined seat in another lobby must be rejected by the index"); + } + + [SkippableFact] + public async Task AUserWhoLeftALobbyMayJoinAnother() + { + SkipIfNoPg(); + + // The mirror image: the filter must be narrow enough that departing frees the user. If the index were not + // partial, a user could join exactly one lobby ever. + var joiner = await SeedUserAsync(); + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + var lobbyA = await AddLobbyAsync(db, (await SeedUserAsync()).Id, "smoke-game"); + var lobbyB = await AddLobbyAsync(db, (await SeedUserAsync()).Id, "smoke-game"); + + lobbyA.Join(joiner.Id, T0); + await db.SaveChangesAsync(); + + lobbyA.Leave(joiner.Id, T0); + await db.SaveChangesAsync(); + + lobbyB.Join(joiner.Id, T0); + await db.SaveChangesAsync(); // must not throw + + var joinedCount = await db.LobbyMembers + .CountAsync(m => m.UserId == joiner.Id && m.State == LobbyMemberState.Joined); + Assert.Equal(1, joinedCount); + } + + [SkippableFact] + public async Task ConcurrentLastSeatJoins_ProduceExactlyOneWinner_AndNeverOverfillTheLobby() + { + SkipIfNoPg(); + + // Two genuinely concurrent writers, each on its own connection, racing for the final seat. + // + // Capacity is the one Module 6 invariant a partial unique index CANNOT express: "joined members < + // MaxPlayers" is a COUNT, not a uniqueness property, and the member index is keyed on UserId — so it + // happily admits two *different* users into the same last seat. What actually serializes them is the + // lobby's xmin row version: every join bumps Revision, so both racers issue + // UPDATE lobbies SET "Revision" = 2, ... WHERE "Id" = @id AND xmin = @loaded + // and only the first can match. The loser's UPDATE affects 0 rows and surfaces as a concurrency conflict — + // exactly the signal 6B's BoundedTransactionRetry (R3) reruns on, re-reading the lobby to discover it is + // now full and returning a typed Lobbies.Full rather than a 500. + // + // This test pins that mechanism. Remove the Revision bump or the row version and the lobby would silently + // overfill; nothing else in the suite would notice. + var host = await SeedUserAsync(); + var alice = await SeedUserAsync(); + var bob = await SeedUserAsync(); + await SeedGameAsync("smoke-game"); + + Guid lobbyId; + await using (var setup = CreateTestDb()) + { + var lobby = await AddLobbyAsync(setup, host.Id, "smoke-game", maxPlayers: 2); + lobbyId = lobby.Id; + } + + using var bothHaveRead = new Barrier(2); + + async Task TryJoinAsync(Guid userId) + { + await using var db = CreateTestDb(); + var lobby = await db.Lobbies.Include(l => l.Members).FirstAsync(l => l.Id == lobbyId); + + // Both racers have now READ a lobby with one free seat. Releasing them together is what makes the race + // real rather than incidentally serialized by the scheduler. + bothHaveRead.SignalAndWait(); + + if (lobby.Join(userId, T0) != LobbyOutcome.Ok) return false; + + try + { + await db.SaveChangesAsync(); + return true; + } + catch (DbUpdateConcurrencyException) + { + return false; // lost the row-version race + } + catch (DbUpdateException ex) when (IsUniqueViolation(ex)) + { + return false; // lost the member-index race + } + } + + var results = await Task.WhenAll( + Task.Run(() => TryJoinAsync(alice.Id)), + Task.Run(() => TryJoinAsync(bob.Id))); + + Assert.Equal(1, results.Count(won => won)); + + await using var verify = CreateTestDb(); + + var seated = await verify.LobbyMembers + .CountAsync(m => m.LobbyId == lobbyId && m.State == LobbyMemberState.Joined); + Assert.Equal(2, seated); // the host plus exactly one racer — never 3 in a 2-seat lobby + + var storedLobby = await verify.Lobbies.AsNoTracking().FirstAsync(l => l.Id == lobbyId); + Assert.Equal(2, storedLobby.Revision); // exactly one join applied => exactly one revision bump + } + + // ── One nonterminal ticket per user ────────────────────────────────────── + + [SkippableFact] + public async Task OneNonterminalTicketPerUser_IsEnforced() + { + SkipIfNoPg(); + + var user = await SeedUserAsync(); + + await using var db = CreateTestDb(); + db.MatchmakingTickets.Add(NewTicket(user.Id)); + await db.SaveChangesAsync(); + + db.MatchmakingTickets.Add(NewTicket(user.Id)); + + var ex = await Assert.ThrowsAnyAsync(() => db.SaveChangesAsync()); + Assert.True(IsUniqueViolation(ex)); + } + + [SkippableFact] + public async Task ACancelledTicketFreesTheUserToQueueAgain() + { + SkipIfNoPg(); + + var user = await SeedUserAsync(); + + await using var db = CreateTestDb(); + var first = NewTicket(user.Id); + db.MatchmakingTickets.Add(first); + await db.SaveChangesAsync(); + + first.Cancel(T0); + await db.SaveChangesAsync(); + + db.MatchmakingTickets.Add(NewTicket(user.Id)); + await db.SaveChangesAsync(); // must not throw + } + + // ── Zero duplicate assignment: the Risk #1 boundary ────────────────────── + + [SkippableFact] + public async Task TwoCompetingWorkers_ProduceZeroDuplicateAssignments() + { + SkipIfNoPg(); + + // This is the assertion the brief singles out. SKIP LOCKED prevents two workers CONTENDING on one row, but + // it is not exclusivity: a requeued ticket or a serialization retry can still attempt a second assignment. + // Only ux_matchmaking_assignments_one_active_per_ticket makes double-assignment impossible — so the test + // deliberately bypasses any row lock and has both "workers" go straight for the insert. + var user = await SeedUserAsync(); + + Guid ticketId; + await using (var setup = CreateTestDb()) + { + var ticket = NewTicket(user.Id); + setup.MatchmakingTickets.Add(ticket); + await setup.SaveChangesAsync(); + ticketId = ticket.Id; + } + + async Task TryAssignAsync() + { + await using var db = CreateTestDb(); + db.MatchmakingAssignments.Add( + MatchmakingAssignment.Create(ticketId, Guid.NewGuid(), Guid.NewGuid(), T0)); + try + { + await db.SaveChangesAsync(); + return true; + } + catch (DbUpdateException ex) when (IsUniqueViolation(ex)) + { + return false; + } + } + + var results = await Task.WhenAll(TryAssignAsync(), TryAssignAsync(), TryAssignAsync()); + + Assert.Equal(1, results.Count(won => won)); + + await using var verify = CreateTestDb(); + var active = await verify.MatchmakingAssignments + .CountAsync(a => a.TicketId == ticketId && a.State == MatchmakingAssignmentState.Active); + Assert.Equal(1, active); + } + + [SkippableFact] + public async Task ASupersededAssignmentAllowsTheTicketToBeAssignedAgain() + { + SkipIfNoPg(); + + // The legitimate re-assignment path after a failed handoff. If the index were not partial, a requeued + // ticket could never be matched again. + var user = await SeedUserAsync(); + + await using var db = CreateTestDb(); + var ticket = NewTicket(user.Id); + db.MatchmakingTickets.Add(ticket); + await db.SaveChangesAsync(); + + var first = MatchmakingAssignment.Create(ticket.Id, Guid.NewGuid(), Guid.NewGuid(), T0); + db.MatchmakingAssignments.Add(first); + await db.SaveChangesAsync(); + + first.Supersede(T0); + await db.SaveChangesAsync(); + + db.MatchmakingAssignments.Add( + MatchmakingAssignment.Create(ticket.Id, Guid.NewGuid(), Guid.NewGuid(), T0)); + await db.SaveChangesAsync(); // must not throw + } + + // ── Credential uniqueness and rotation ─────────────────────────────────── + + [SkippableFact] + public async Task TwoActiveCredentialsCannotShareACodeDigest() + { + SkipIfNoPg(); + + // Join-by-code looks the digest up on its own, so two live lobbies sharing a code would make the lookup + // ambiguous. The generator's bounded collision retry catches exactly this 23505. + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + var lobbyA = await AddLobbyAsync(db, (await SeedUserAsync()).Id, "smoke-game"); + var lobbyB = await AddLobbyAsync(db, (await SeedUserAsync()).Id, "smoke-game"); + + db.LobbyJoinCredentials.Add( + LobbyJoinCredential.Issue(lobbyA.Id, "same-code-digest", "link-a", 1, T0)); + await db.SaveChangesAsync(); + + db.LobbyJoinCredentials.Add( + LobbyJoinCredential.Issue(lobbyB.Id, "same-code-digest", "link-b", 1, T0)); + + var ex = await Assert.ThrowsAnyAsync(() => db.SaveChangesAsync()); + Assert.True(IsUniqueViolation(ex)); + } + + [SkippableFact] + public async Task RotationSupersedesTheOldCredentialAndAllowsANewActiveOne() + { + SkipIfNoPg(); + + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + var lobby = await AddLobbyAsync(db, (await SeedUserAsync()).Id, "smoke-game"); + + var gen1 = LobbyJoinCredential.Issue(lobby.Id, "digest-gen-1", "link-gen-1", 1, T0); + db.LobbyJoinCredentials.Add(gen1); + await db.SaveChangesAsync(); + + // A second ACTIVE credential for the same lobby must be impossible while gen1 is still active. + db.LobbyJoinCredentials.Add(LobbyJoinCredential.Issue(lobby.Id, "digest-gen-2", "link-gen-2", 2, T0)); + var ex = await Assert.ThrowsAnyAsync(() => db.SaveChangesAsync()); + Assert.True(IsUniqueViolation(ex), "one active credential per lobby"); + + // Rotate properly: supersede, then issue. + await using var db2 = CreateTestDb(); + var stored = await db2.LobbyJoinCredentials.FirstAsync(c => c.LobbyId == lobby.Id); + stored.MarkRotated(T0); + db2.LobbyJoinCredentials.Add(LobbyJoinCredential.Issue(lobby.Id, "digest-gen-2", "link-gen-2", 2, T0)); + await db2.SaveChangesAsync(); // must not throw + + var active = await db2.LobbyJoinCredentials + .CountAsync(c => c.LobbyId == lobby.Id && c.State == LobbyCredentialState.Active); + Assert.Equal(1, active); + } + + // ── One open start-request per lobby revision ──────────────────────────── + + [SkippableFact] + public async Task OneOpenStartRequestPerLobbyRevision_IsEnforced() + { + SkipIfNoPg(); + + // This is what makes a retried Start idempotent rather than a second match request. + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + var lobby = await AddLobbyAsync(db, (await SeedUserAsync()).Id, "smoke-game"); + + db.LobbyStartRequests.Add( + LobbyStartRequest.Open(lobby.Id, 3, Guid.NewGuid(), "idem-a", Guid.NewGuid())); + await db.SaveChangesAsync(); + + db.LobbyStartRequests.Add( + LobbyStartRequest.Open(lobby.Id, 3, Guid.NewGuid(), "idem-b", Guid.NewGuid())); + + var ex = await Assert.ThrowsAnyAsync(() => db.SaveChangesAsync()); + Assert.True(IsUniqueViolation(ex)); + } + + [SkippableFact] + public async Task AFailedStartRequestAllowsARetryAtANewRevision() + { + SkipIfNoPg(); + + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + var lobby = await AddLobbyAsync(db, (await SeedUserAsync()).Id, "smoke-game"); + + var first = LobbyStartRequest.Open(lobby.Id, 3, Guid.NewGuid(), "idem-a", Guid.NewGuid()); + db.LobbyStartRequests.Add(first); + await db.SaveChangesAsync(); + + first.MarkFailed("Match runtime unavailable.", T0); + await db.SaveChangesAsync(); + + db.LobbyStartRequests.Add( + LobbyStartRequest.Open(lobby.Id, 4, Guid.NewGuid(), "idem-b", Guid.NewGuid())); + await db.SaveChangesAsync(); // must not throw + } + + // ── CHECK constraints ──────────────────────────────────────────────────── + + [SkippableFact] + public async Task AQueuedTicketCannotNameAWorker() + { + SkipIfNoPg(); + + // A stale worker id on a queued row would mean a claim leaked and two workers could believe they hold it. + var user = await SeedUserAsync(); + + await using var db = CreateTestDb(); + var ticket = NewTicket(user.Id); + db.MatchmakingTickets.Add(ticket); + await db.SaveChangesAsync(); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE matchmaking_tickets SET \"ClaimedByWorker\" = 'ghost' WHERE \"Id\" = @id"; + cmd.Parameters.AddWithValue("id", ticket.Id); + + var ex = await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsync()); + Assert.Equal("23514", ex.SqlState); + } + + [SkippableFact] + public async Task ATerminalTicketMayKeepItsWorkerAttribution() + { + SkipIfNoPg(); + + // The mirror of the above: the constraint must NOT be so strict that it throws away the attribution behind + // the matchmaking-worker-failure observability signal. + var user = await SeedUserAsync(); + + await using var db = CreateTestDb(); + var ticket = NewTicket(user.Id); + db.MatchmakingTickets.Add(ticket); + await db.SaveChangesAsync(); + + ticket.Claim("worker-1", T0); + ticket.MarkMatched(T0); + await db.SaveChangesAsync(); // must not throw + + var stored = await db.MatchmakingTickets.AsNoTracking().FirstAsync(t => t.Id == ticket.Id); + Assert.Equal("worker-1", stored.ClaimedByWorker); + Assert.Equal(MatchmakingTicketState.Matched, stored.State); + } + + [SkippableFact] + public async Task ATerminalLobbyMustCarryAClosedReason() + { + SkipIfNoPg(); + + // An unexplained Closed row is a bug, not a valid state — the reason is the audit trail for why a lobby + // stopped existing. + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + var lobby = await AddLobbyAsync(db, (await SeedUserAsync()).Id, "smoke-game"); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE lobbies SET \"State\" = 'Closed' WHERE \"Id\" = @id"; // no ClosedReason + cmd.Parameters.AddWithValue("id", lobby.Id); + + var ex = await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsync()); + Assert.Equal("23514", ex.SqlState); + } + + [SkippableFact] + public async Task ALiveLobbyMustNotCarryAClosedReason() + { + SkipIfNoPg(); + + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + var lobby = await AddLobbyAsync(db, (await SeedUserAsync()).Id, "smoke-game"); + + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE lobbies SET \"ClosedReason\" = 'HostLeft' WHERE \"Id\" = @id"; // still Open + cmd.Parameters.AddWithValue("id", lobby.Id); + + var ex = await Assert.ThrowsAsync(() => cmd.ExecuteNonQueryAsync()); + Assert.Equal("23514", ex.SqlState); + } + + // ── Capability profiles (D2) ───────────────────────────────────────────── + + [SkippableFact] + public async Task ACapabilityProfileRequiresARealCatalogGame() + { + SkipIfNoPg(); + + // The FK to games.slug. A profile pointing at a game that does not exist would resolve to nothing at + // command time, so it must not be storable at all. + await using var db = CreateTestDb(); + db.GameCapabilityProfiles.Add(NewProfile("does-not-exist")); + + var ex = await Assert.ThrowsAnyAsync(() => db.SaveChangesAsync()); + Assert.Equal("23503", ((PostgresException)ex.InnerException!).SqlState); // foreign_key_violation + } + + [SkippableFact] + public async Task OnlyOneCapabilityProfilePerGameMayBeActive() + { + SkipIfNoPg(); + + // Otherwise a create command would have to choose between two live profiles for the same game. + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + db.GameCapabilityProfiles.Add(NewProfile("smoke-game", capabilityVersion: 1)); + await db.SaveChangesAsync(); + + db.GameCapabilityProfiles.Add(NewProfile("smoke-game", capabilityVersion: 2)); + + var ex = await Assert.ThrowsAnyAsync(() => db.SaveChangesAsync()); + Assert.True(IsUniqueViolation(ex)); + } + + [SkippableFact] + public async Task ADeactivatedProfileAllowsANewActiveVersionToBePublished() + { + SkipIfNoPg(); + + // The version-bump path: publish v2 by deactivating v1, never by editing v1 — a lobby that pinned v1 must + // keep meaning what it meant when it was created. + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + var v1 = NewProfile("smoke-game", capabilityVersion: 1); + db.GameCapabilityProfiles.Add(v1); + await db.SaveChangesAsync(); + + v1.Deactivate(); + db.GameCapabilityProfiles.Add(NewProfile("smoke-game", capabilityVersion: 2)); + await db.SaveChangesAsync(); // must not throw + + var pinnedV1 = await db.GameCapabilityProfiles.AsNoTracking() + .FirstAsync(p => p.GameSlug == "smoke-game" && p.CapabilityVersion == 1); + Assert.False(pinnedV1.IsActive); + Assert.Equal(2, await db.GameCapabilityProfiles.CountAsync(p => p.GameSlug == "smoke-game")); + } + + [SkippableFact] + public async Task TheSamePinCannotBePublishedTwice() + { + SkipIfNoPg(); + + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + var v1 = NewProfile("smoke-game", capabilityVersion: 1); + db.GameCapabilityProfiles.Add(v1); + await db.SaveChangesAsync(); + + v1.Deactivate(); + await db.SaveChangesAsync(); + + // Even deactivated, (slug, version) is the immutable pin — a second v1 would make the pin ambiguous. + db.GameCapabilityProfiles.Add(NewProfile("smoke-game", capabilityVersion: 1)); + + var ex = await Assert.ThrowsAnyAsync(() => db.SaveChangesAsync()); + Assert.True(IsUniqueViolation(ex)); + } + + [SkippableFact] + public async Task CapabilityListsRoundTripThroughPostgresTextArrays() + { + SkipIfNoPg(); + + await SeedGameAsync("smoke-game"); + + await using var db = CreateTestDb(); + db.GameCapabilityProfiles.Add(NewProfile("smoke-game")); + await db.SaveChangesAsync(); + + await using var read = CreateTestDb(); + var stored = await read.GameCapabilityProfiles.AsNoTracking().FirstAsync(); + + Assert.Equal(new[] { "multiplayer", "ranked", "ai" }, stored.AllowedModes); + Assert.Equal(new[] { "blitz-3-2", "rapid-10-0" }, stored.TimeControls); + Assert.Equal(new[] { "none", "sudden-death" }, stored.TieBreakRules); + Assert.Equal(new[] { "Anyone", "FriendsOnly", "Disabled" }, stored.SpectatorPolicies); + } + + // ── Module 4's tables are untouched ────────────────────────────────────── + + [SkippableFact] + public async Task Module4AndModule3TablesAreUnchangedByThisMigration() + { + SkipIfNoPg(); + + // The migration is additive. The one thing it does add to an M4 table is the AK_games_Slug unique + // constraint, which the FK from game_capability_profiles requires — it drops nothing and cannot fail on + // existing data, since IX_games_Slug already guaranteed uniqueness. + await using var conn = new NpgsqlConnection(_testConn); + await conn.OpenAsync(); + + await using var akCmd = conn.CreateCommand(); + akCmd.CommandText = @" + SELECT COUNT(*) FROM pg_constraint + WHERE conname = 'AK_games_Slug' AND contype = 'u';"; + Assert.Equal(1L, (long)(await akCmd.ExecuteScalarAsync())!); + + // No M3/M4 column was dropped or retyped: spot-check the columns Module 6 reads. + await using var colCmd = conn.CreateCommand(); + colCmd.CommandText = @" + SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'games' AND column_name IN ('Slug', 'MinPlayers', 'MaxPlayers', 'Lifecycle');"; + Assert.Equal(4L, (long)(await colCmd.ExecuteScalarAsync())!); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static MatchmakingTicket NewTicket(Guid userId) => MatchmakingTicket.Enqueue( + userId, "smoke-game", 1, "multiplayer", 2, "blitz-3-2", false, "eu-west", + MatchmakingTicket.ProvisionalRating, MatchmakingTicket.ProvisionalRatingSource, Guid.NewGuid(), T0); + + private static GameCapabilityProfile NewProfile(string gameSlug, int capabilityVersion = 1) => + GameCapabilityProfile.Create( + gameSlug, capabilityVersion, 2, 4, + new[] { "multiplayer", "ranked", "ai" }, + new[] { "blitz-3-2", "rapid-10-0" }, + new[] { "none", "sudden-death" }, + new[] { "Anyone", "FriendsOnly", "Disabled" }, + ratedEligible: true, aiFillEligible: true, "test-1"); +} diff --git a/tests/SimPle.IntegrationTests/Lobbies/MatchmakingEndpointsTests.cs b/tests/SimPle.IntegrationTests/Lobbies/MatchmakingEndpointsTests.cs new file mode 100644 index 0000000..b3dd4eb --- /dev/null +++ b/tests/SimPle.IntegrationTests/Lobbies/MatchmakingEndpointsTests.cs @@ -0,0 +1,398 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SimPle.Domain.Capabilities; +using SimPle.Domain.Games; +using SimPle.Infrastructure.Persistence; +using SimPle.IntegrationTests.Auth; + +namespace SimPle.IntegrationTests.Lobbies; + +/// +/// HTTP-contract tests for the Module 6 Quick Match ticket surface (slice 6C, InMemory-backed): routing, auth, CSRF, +/// validation, the privacy-safe not-found (BOLA), the cross-table one-active-lobby-or-ticket invariant, and the +/// cancel-versus-claim race's honest 200. +/// +/// +/// Everything genuinely concurrent — two workers, FOR UPDATE SKIP LOCKED, the partial unique index that makes +/// double assignment impossible — lives in MatchmakingPostgresConcurrencyTests. The EF InMemory provider does +/// not enforce filtered unique indexes or row versions at all, so a claim race asserted here would pass no matter +/// what the code did, which is worse than no test. +/// +/// +public sealed class MatchmakingEndpointsTests : IDisposable +{ + private const string TestPassword = "TestPassword123!"; + + private readonly TestWebApplicationFactory _factory = new(); + + // ── Auth & CSRF ────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateTicket_Anonymous_Returns401() + { + using var client = CreateClient(); + + var response = await client.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest()); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task CreateTicket_MissingCsrfHeader_Returns400() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + client.DefaultRequestHeaders.Remove("X-Requested-With"); + + var response = await client.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest()); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await response.Content.ReadAsStringAsync()).Should().Contain("Auth.CsrfHeaderRequired"); + } + + [Fact] + public async Task CancelTicket_MissingCsrfHeader_Returns400() + { + using var client = CreateClient(); + await SignInAsync(client); + client.DefaultRequestHeaders.Remove("X-Requested-With"); + + var response = await client.DeleteAsync($"/api/matchmaking/tickets/{Guid.NewGuid()}"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + // ── Enqueue ────────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateTicket_Returns201_WithAQueuedTicketAndAnHonestDependencyReadiness() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + + var response = await client.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest()); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var ticket = await ReadTicketAsync(response); + ticket.GetProperty("state").GetString().Should().Be("Queued"); + ticket.GetProperty("currentBand").GetInt32().Should().Be(100); + ticket.GetProperty("rating").GetInt32().Should().Be(1200); + ticket.GetProperty("ratingSourceVersion").GetString().Should().Be("provisional-1200-v1"); + + // Enqueue works before Module 8 — the truth is told through dependencyReadiness, not by refusing the + // request. `assignment` is null because a ticket that has not matched has no handoff to point at. + ticket.GetProperty("dependencyReadiness").GetProperty("matchRuntime").GetBoolean().Should().BeFalse(); + ticket.GetProperty("assignment").ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact] + public async Task CreateTicket_IsIdempotent_AnIdenticalRepeatReturnsTheSameTicket() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + + var first = await ReadTicketAsync(await client.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest())); + var second = await ReadTicketAsync(await client.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest())); + + second.GetProperty("ticketId").GetGuid().Should().Be(first.GetProperty("ticketId").GetGuid()); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + (await db.MatchmakingTickets.CountAsync()).Should().Be(1, "a retry must not mint a second ticket"); + } + + [Fact] + public async Task CreateTicket_ADifferentTicketWhileOneIsLive_Returns409AlreadyQueued() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + + await client.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest()); + var response = await client.PostAsJsonAsync( + "/api/matchmaking/tickets", TicketRequest(timeControlId: "rapid-10-0")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await response.Content.ReadAsStringAsync()).Should().Contain("Matchmaking.AlreadyQueued"); + } + + [Fact] + public async Task CreateTicket_WhileAlreadyInALobby_Returns409() + { + // The cross-table invariant, from the queue's side: one active lobby OR one active ticket, never both. + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + + var lobby = await client.PostAsJsonAsync("/api/lobbies", CreateLobbyRequest()); + lobby.StatusCode.Should().Be(HttpStatusCode.Created); + + var response = await client.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest()); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await response.Content.ReadAsStringAsync()).Should().Contain("Lobbies.AlreadyActive"); + } + + [Theory] + [InlineData(1)] + [InlineData(9)] + public async Task CreateTicket_RejectsAnUnsupportedPlayerCount(int playerCount) + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + + var response = await client.PostAsJsonAsync( + "/api/matchmaking/tickets", TicketRequest(playerCount: playerCount)); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await response.Content.ReadAsStringAsync()).Should().Contain("Validation.Failed"); + } + + [Fact] + public async Task CreateTicket_ForAnUnknownCapabilityVersion_Returns409CapabilityDisabled() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + + var response = await client.PostAsJsonAsync( + "/api/matchmaking/tickets", TicketRequest(capabilityVersion: 99)); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + (await response.Content.ReadAsStringAsync()).Should().Contain("Lobbies.CapabilityDisabled"); + } + + // ── Status: BOLA ───────────────────────────────────────────────────────── + + [Fact] + public async Task GetTicket_AnotherUsersTicketId_Returns404_NotA403() + { + // A 403 would confirm the id exists. Missing and foreign ticket ids must be indistinguishable + // (OWASP API1:2023). + using var owner = CreateClient(); + await SignInAsync(owner); + await SeedCatalogAsync(); + + var created = await ReadTicketAsync(await owner.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest())); + var ticketId = created.GetProperty("ticketId").GetGuid(); + + using var attacker = CreateClient(); + await SignInAsync(attacker); + + var foreign = await attacker.GetAsync($"/api/matchmaking/tickets/{ticketId}"); + var missing = await attacker.GetAsync($"/api/matchmaking/tickets/{Guid.NewGuid()}"); + + foreign.StatusCode.Should().Be(HttpStatusCode.NotFound); + missing.StatusCode.Should().Be(HttpStatusCode.NotFound); + + // Byte-identical bodies: the response must not be an oracle either. + (await foreign.Content.ReadAsStringAsync()) + .Should().Be(await missing.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task GetTicket_ByItsOwner_Returns200() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + + var created = await ReadTicketAsync(await client.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest())); + var ticketId = created.GetProperty("ticketId").GetGuid(); + + var response = await client.GetAsync($"/api/matchmaking/tickets/{ticketId}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.CacheControl!.NoStore.Should().BeTrue("a ticket is private to its owner"); + + (await ReadTicketAsync(response)).GetProperty("ticketId").GetGuid().Should().Be(ticketId); + } + + // ── Cancel ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task CancelTicket_Returns200_AndFreesTheUserToQueueAgain() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + + var created = await ReadTicketAsync(await client.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest())); + var ticketId = created.GetProperty("ticketId").GetGuid(); + + var cancel = await client.DeleteAsync($"/api/matchmaking/tickets/{ticketId}"); + + cancel.StatusCode.Should().Be(HttpStatusCode.OK); + (await ReadTicketAsync(cancel)).GetProperty("state").GetString().Should().Be("Cancelled"); + + // A cancelled ticket is terminal, so it no longer occupies the user's single active slot. + var requeue = await client.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest()); + requeue.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task CancelTicket_AnotherUsersTicket_Returns404() + { + using var owner = CreateClient(); + await SignInAsync(owner); + await SeedCatalogAsync(); + + var created = await ReadTicketAsync(await owner.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest())); + var ticketId = created.GetProperty("ticketId").GetGuid(); + + using var attacker = CreateClient(); + await SignInAsync(attacker); + + var response = await attacker.DeleteAsync($"/api/matchmaking/tickets/{ticketId}"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + // And it really is untouched, not merely reported as missing. + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var row = await db.MatchmakingTickets.AsNoTracking().FirstAsync(t => t.Id == ticketId); + row.State.Should().Be(SimPle.Domain.Matchmaking.MatchmakingTicketState.Queued); + } + + [Fact] + public async Task CancelTicket_AfterAWorkerClaim_Returns200WithTheCurrentStatus_NotAnError() + { + // Matchmaking.CancelTooLate is a 200, per the error catalogue. The user pressed cancel in good faith and the + // queue got there first — reporting a failure would blame them for losing a race they cannot see. + using var client = CreateClient(); + var userId = await SignInAsync(client); + await SeedCatalogAsync(); + + var created = await ReadTicketAsync(await client.PostAsJsonAsync("/api/matchmaking/tickets", TicketRequest())); + var ticketId = created.GetProperty("ticketId").GetGuid(); + + // Simulate the worker winning the race. + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var ticket = await db.MatchmakingTickets.FirstAsync(t => t.Id == ticketId); + ticket.Claim("worker-1", DateTime.UtcNow); + await db.SaveChangesAsync(); + } + + var response = await client.DeleteAsync($"/api/matchmaking/tickets/{ticketId}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await ReadTicketAsync(response)).GetProperty("state").GetString().Should().Be("Claimed"); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private HttpClient CreateClient() => + _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + HandleCookies = true, + }).WithCsrfHeader(); + + private static async Task ReadTicketAsync(HttpResponseMessage response) => + JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + + private async Task SignInAsync(HttpClient client) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var email = $"mm-{suffix}@example.com"; + var username = $"mm{suffix}"; + + await client.PostAsJsonAsync("/api/auth/register", new + { + Username = username, + Email = email, + Password = TestPassword, + ConfirmPassword = TestPassword, + CaptchaToken = "test-captcha-token", + }); + await client.PostAsJsonAsync("/api/auth/login", new + { + EmailOrUsername = email, + Password = TestPassword, + CaptchaToken = "test-captcha-token", + }); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return (await db.Users.AsNoTracking().FirstAsync(u => u.Email == email)).Id; + } + + private static object TicketRequest( + string gameSlug = "chess-lite", + int capabilityVersion = 1, + string mode = "multiplayer", + int playerCount = 2, + string timeControlId = "blitz-3-2", + bool rated = false) => new + { + GameSlug = gameSlug, + CapabilityVersion = capabilityVersion, + Mode = mode, + PlayerCount = playerCount, + TimeControlId = timeControlId, + Rated = rated, + Region = (string?)null, + }; + + private static object CreateLobbyRequest() => new + { + GameSlug = "chess-lite", + CapabilityVersion = 1, + Privacy = "Private", + MaxPlayers = 2, + TimeControlId = "blitz-3-2", + Rated = false, + Region = (string?)null, + SpectatorPolicy = "Anyone", + TieBreakRuleId = "none", + AiFillRequested = false, + }; + + private async Task SeedCatalogAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (!await db.Games.AnyAsync(g => g.Slug == "chess-lite")) + { + db.Games.Add(Game.Create( + slug: "chess-lite", name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: GameDifficulty.Medium, + estimatedDurationMinMinutes: 10, estimatedDurationMaxMinutes: 20, + minPlayers: 2, maxPlayers: 4, initialLifecycle: GameLifecycle.Available, + featuredRank: null, sortOrder: 1, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", + tags: new[] { "classic", "logic" }, + modes: new[] { "multiplayer", "cooperative" })); + } + + if (!await db.GameCapabilityProfiles.AnyAsync(p => p.GameSlug == "chess-lite")) + { + db.GameCapabilityProfiles.Add(GameCapabilityProfile.Create( + "chess-lite", 1, minPlayers: 2, maxPlayers: 4, + allowedModes: new[] { "multiplayer", "cooperative" }, + timeControls: new[] { "blitz-3-2", "rapid-10-0", "untimed" }, + tieBreakRules: new[] { "none", "sudden-death" }, + spectatorPolicies: new[] { "Anyone", "FriendsOnly", "Disabled" }, + ratedEligible: false, aiFillEligible: false, + manifestVersion: "2026.1")); + } + + await db.SaveChangesAsync(); + } + + public void Dispose() => _factory.Dispose(); +} diff --git a/tests/SimPle.IntegrationTests/Lobbies/MatchmakingPostgresConcurrencyTests.cs b/tests/SimPle.IntegrationTests/Lobbies/MatchmakingPostgresConcurrencyTests.cs new file mode 100644 index 0000000..b398c34 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Lobbies/MatchmakingPostgresConcurrencyTests.cs @@ -0,0 +1,878 @@ +using System.Diagnostics; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using Npgsql; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.Expiry; +using SimPle.Application.Friends.Outbox; +using SimPle.Application.Lobbies.DTOs; +using SimPle.Application.Lobbies.Services; +using SimPle.Application.Matchmaking.DTOs; +using SimPle.Application.Matchmaking.Outbox; +using SimPle.Application.Matchmaking.Services; +using SimPle.Application.Outbox; +using SimPle.Application.Outbox.Handlers; +using SimPle.Domain.Capabilities; +using SimPle.Domain.Friends; +using SimPle.Domain.GameHost; +using SimPle.Domain.Games; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Users; +using SimPle.Application.GameHost.Services; +using SimPle.Infrastructure.Lobbies; +using SimPle.Infrastructure.Persistence; +using SimPle.Infrastructure.Persistence.Repositories; +using Xunit; + +namespace SimPle.IntegrationTests.Lobbies; + +/// +/// Real-PostgreSQL tests for slice 6C: the two-worker claim race, the partial unique index that makes double +/// assignment impossible, the cross-table one-active-lobby-or-ticket invariant, outbox atomicity, +/// the expiry sweep, the outbox dispatcher's lease, and the D3 benchmark. +/// +/// +/// None of these can run on InMemory, and that is the entire point of the class. InMemory enforces no filtered +/// unique index, no CHECK constraint, no row version, and has no FOR UPDATE SKIP LOCKED — so a two-worker +/// race asserted against it would pass whatever the code did, including code that assigned one ticket to two +/// matches. Every assertion below is about behavior only a real database can produce. +/// +/// +/// Skipped unless MIGRATION_TEST_CONNECTION_STRING points at a running PostgreSQL instance. +/// +public sealed class MatchmakingPostgresConcurrencyTests : IAsyncLifetime +{ + private const string TestKey = "test-lobby-credential-key-32-chars-min"; + + private readonly string? _masterConn = Environment.GetEnvironmentVariable("MIGRATION_TEST_CONNECTION_STRING"); + private readonly string _dbName = $"simple_m6c_{Guid.NewGuid():N}"; + private string? _testConn; + + private static readonly DateTime T0 = new(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc); + + public async Task InitializeAsync() + { + if (_masterConn is null) return; + + _testConn = new NpgsqlConnectionStringBuilder(_masterConn) { Database = _dbName }.ToString(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + await using var createCmd = masterConn.CreateCommand(); + createCmd.CommandText = $"CREATE DATABASE \"{_dbName}\""; + await createCmd.ExecuteNonQueryAsync(); + + await using var db = CreateDb(); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + if (_masterConn is null || _testConn is null) return; + + NpgsqlConnection.ClearAllPools(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + + await using var terminateCmd = masterConn.CreateCommand(); + // `AND usename = current_user` matters: under a least-privilege test role, terminating another role's + // backend raises 42501, and an autovacuum worker (running as the bootstrap superuser) can appear on this + // database at any moment. The unfiltered form fails intermittently and strands the database. + terminateCmd.CommandText = $@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_dbName}' + AND pid <> pg_backend_pid() + AND usename = current_user;"; + await terminateCmd.ExecuteNonQueryAsync(); + + await using var dropCmd = masterConn.CreateCommand(); + dropCmd.CommandText = $"DROP DATABASE IF EXISTS \"{_dbName}\""; + await dropCmd.ExecuteNonQueryAsync(); + } + + // ── THE test: two competing workers, zero duplicate assignment ─────────── + + /// + /// The guarantee the whole slice is built around (brief Risk #1). + /// + /// + /// FOR UPDATE SKIP LOCKED is not exclusivity — it stops two workers contending + /// on one row, but a requeued ticket or a serialization retry can still attempt a second assignment. The partial + /// unique index ux_matchmaking_assignments_one_active_per_ticket is the correctness boundary, and this + /// test asserts against exactly that: whatever the two workers do, no ticket ends up with two active + /// assignments, and no ticket is matched into two different groups. + /// + /// + [SkippableFact] + public async Task TwoCompetingWorkers_ProduceZeroDuplicateAssignment() + { + SkipIfNoPg(); + await SeedCatalogAsync(); + + // 20 identical, mutually-compatible tickets: every one of them can pair with any other, which is the + // worst case for two workers racing over the same pool. + var tickets = await SeedQueuedTicketsAsync(count: 20); + + await using var dbA = CreateDb(); + await using var dbB = CreateDb(); + + var workerA = BuildCoordinator(dbA, matchRuntimeAvailable: true); + var workerB = BuildCoordinator(dbB, matchRuntimeAvailable: true); + + // Genuinely concurrent: two coordinators, two contexts, two connections. + var cycles = await Task.WhenAll( + workerA.RunCycleAsync("worker-A"), + workerB.RunCycleAsync("worker-B")); + + await using var verify = CreateDb(); + + // 1. No ticket has more than one ACTIVE assignment. This is the index's promise. + var activeByTicket = await verify.MatchmakingAssignments + .AsNoTracking() + .Where(a => a.State == MatchmakingAssignmentState.Active) + .GroupBy(a => a.TicketId) + .Select(g => new { TicketId = g.Key, Count = g.Count() }) + .ToListAsync(); + + activeByTicket.Should().OnlyContain(x => x.Count == 1, "no ticket may hold two active assignments"); + + // 2. Every assignment belongs to a group of exactly the right size, and no ticket spans two groups. + var assignments = await verify.MatchmakingAssignments.AsNoTracking().ToListAsync(); + assignments.Select(a => a.TicketId).Should().OnlyHaveUniqueItems( + "a ticket assigned twice — even across groups — is a double-booked player"); + + foreach (var group in assignments.GroupBy(a => a.GroupId)) + { + group.Should().HaveCount(2, "every proposal in this fixture is a 2-player group"); + group.Select(a => a.MatchRequestId).Distinct().Should().ContainSingle( + "one group means one match request"); + } + + // 3. Exactly one MatchRequestedV1 per group — never one per ticket. + var events = await verify.OutboxMessages + .AsNoTracking() + .Where(m => m.EventType == MatchmakingOutbox.MatchRequested) + .ToListAsync(); + + events.Should().HaveCount(assignments.Select(a => a.GroupId).Distinct().Count()); + + // 4. Every Matched ticket has an assignment, and every assigned ticket is Matched. A ticket marked Matched + // with nothing to hand to M8 is a player staring at a match that does not exist. + var matched = await verify.MatchmakingTickets + .AsNoTracking() + .Where(t => t.State == MatchmakingTicketState.Matched) + .Select(t => t.Id) + .ToListAsync(); + + matched.Should().BeEquivalentTo(assignments.Select(a => a.TicketId)); + + // 5. Nothing was lost: every ticket is either matched or still queued for the next cycle. + var stillQueued = await verify.MatchmakingTickets + .AsNoTracking() + .CountAsync(t => t.State == MatchmakingTicketState.Queued); + + (matched.Count + stillQueued).Should().Be(tickets.Count); + + // Sanity: the workers actually did work, so the assertions above are not vacuously true. + cycles.Sum(c => c.TicketsMatched).Should().BeGreaterThan(0); + matched.Should().NotBeEmpty(); + } + + [SkippableFact] + public async Task ARequeuedTicketCannotAcquireASecondActiveAssignment_TheIndexRejectsIt() + { + // Proves the index is load-bearing rather than incidental. SKIP LOCKED cannot help here: there is no second + // worker and no contention — just the same ticket being assigned twice, which is exactly what a + // serialization retry or a requeue could attempt. + SkipIfNoPg(); + await SeedCatalogAsync(); + + var tickets = await SeedQueuedTicketsAsync(count: 2); + + await using var db = CreateDb(); + var ticketId = tickets[0]; + + db.MatchmakingAssignments.Add( + MatchmakingAssignment.Create(ticketId, Guid.NewGuid(), Guid.NewGuid(), T0)); + await db.SaveChangesAsync(); + + db.MatchmakingAssignments.Add( + MatchmakingAssignment.Create(ticketId, Guid.NewGuid(), Guid.NewGuid(), T0)); + + (await CaptureSqlStateAsync(db)).Should().Be(PostgresErrorCodes.UniqueViolation); + } + + [SkippableFact] + public async Task ASupersededAssignmentFreesTheTicketForAFreshOne() + { + // The other half of the index's contract: `Active` is the only state it counts, so standing an assignment + // down must genuinely release the slot — otherwise a failed M8 handoff could never be retried. + SkipIfNoPg(); + await SeedCatalogAsync(); + + var tickets = await SeedQueuedTicketsAsync(count: 1); + var ticketId = tickets[0]; + + await using var db = CreateDb(); + + var first = MatchmakingAssignment.Create(ticketId, Guid.NewGuid(), Guid.NewGuid(), T0); + db.MatchmakingAssignments.Add(first); + await db.SaveChangesAsync(); + + first.Supersede(T0.AddSeconds(1)); + db.MatchmakingAssignments.Add( + MatchmakingAssignment.Create(ticketId, Guid.NewGuid(), Guid.NewGuid(), T0.AddSeconds(1))); + + var act = async () => await db.SaveChangesAsync(); + await act.Should().NotThrowAsync(); + + (await db.MatchmakingAssignments.AsNoTracking() + .CountAsync(a => a.TicketId == ticketId && a.State == MatchmakingAssignmentState.Active)) + .Should().Be(1); + } + + // ── Cross-table one-active-lobby-OR-ticket ────────────────────────────── + + /// + /// Brief Risk #2. The filtered unique index on lobby_members and the one on matchmaking_tickets + /// live on different tables and cannot see each other: under READ COMMITTED, a concurrent join + /// and enqueue would each read "nothing active", neither seeing the other's uncommitted row, and both would + /// commit. + /// + /// What closes that window is the transaction-scoped pg_advisory_xact_lock keyed on the actor, taken by + /// — and, critically, taken by the enqueue path too. 6C's enqueue runs + /// through the same runner for exactly this reason; had it opened its own transaction, this test would fail. + /// + [SkippableFact] + public async Task ConcurrentJoinAndEnqueue_ByTheSameUser_ProduceExactlyOneWinner() + { + SkipIfNoPg(); + + var host = await SeedUserAsync(); + var racer = await SeedUserAsync(); + await SeedCatalogAsync(); + + var lobbyId = await SeedLobbyAsync(host.Id, maxPlayers: 4); + var code = await SeedCredentialAsync(lobbyId, "JOIN-VS-QUEUE"); + + await using var dbJoin = CreateDb(); + await using var dbQueue = CreateDb(); + + var join = BuildLobbiesService(dbJoin).JoinByCredentialAsync(racer.Id, new JoinLobbyRequestDto(code, null)); + var enqueue = BuildMatchmakingService(dbQueue).EnqueueAsync(racer.Id, TicketRequest()); + + var joinResult = await join; + var enqueueResult = await enqueue; + + await using var verify = CreateDb(); + + var seated = await verify.LobbyMembers.AsNoTracking() + .CountAsync(m => m.UserId == racer.Id && m.State == LobbyMemberState.Joined); + var queued = await verify.MatchmakingTickets.AsNoTracking() + .CountAsync(t => t.UserId == racer.Id + && (t.State == MatchmakingTicketState.Queued + || t.State == MatchmakingTicketState.Claimed)); + + // Exactly one of the two committed. Which one wins is a genuine race and is not asserted — that the user + // ends up in *both* is the bug, and it is the one thing that must never happen. + (seated + queued).Should().Be(1, "a user holds one active lobby OR one active ticket, never both"); + + // And the loser was told the truth, with a typed conflict rather than a 500. + if (seated == 1) + { + enqueueResult.IsSuccess.Should().BeFalse(); + enqueueResult.Error!.Code.Should().Be(LobbyErrors.AlreadyActive); + } + else + { + joinResult.IsSuccess.Should().BeFalse(); + joinResult.Error!.Code.Should().Be(LobbyErrors.AlreadyActive); + } + } + + [SkippableFact] + public async Task TheOneNonterminalTicketPerUserIndexRejectsASecondTicket() + { + SkipIfNoPg(); + await SeedCatalogAsync(); + + var user = await SeedUserAsync(); + + await using var db = CreateDb(); + + db.MatchmakingTickets.Add(NewTicket(user.Id, T0)); + await db.SaveChangesAsync(); + + db.MatchmakingTickets.Add(NewTicket(user.Id, T0)); + + (await CaptureSqlStateAsync(db)).Should().Be(PostgresErrorCodes.UniqueViolation); + } + + /// + /// Saves and returns the PostgreSQL SQLSTATE of the resulting violation. Asserting on the raw SQLSTATE rather + /// than merely "it threw" is what makes these tests prove the index rejected the write, and not some + /// unrelated failure that happens to also throw. + /// + private static async Task CaptureSqlStateAsync(AppDbContext db) + { + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg) + { + return pg.SqlState; + } + + throw new InvalidOperationException("Expected a unique-index violation, but the save succeeded."); + } + + // ── Outbox atomicity ──────────────────────────────────────────────────── + + [SkippableFact] + public async Task AMatchedTicketAndItsMatchRequestedEventCommitTogether() + { + // Risk #6's storage half: an assignment with no event would leave M8 with a match nobody asked it to make; + // an event with no assignment would hand M8 a request whose tickets are still queued. + SkipIfNoPg(); + await SeedCatalogAsync(); + await SeedQueuedTicketsAsync(count: 2); + + await using var db = CreateDb(); + var result = await BuildCoordinator(db, matchRuntimeAvailable: true).RunCycleAsync("worker-1"); + + result.TicketsMatched.Should().Be(2); + + await using var verify = CreateDb(); + + var assignments = await verify.MatchmakingAssignments.AsNoTracking().ToListAsync(); + var events = await verify.OutboxMessages.AsNoTracking() + .Where(m => m.EventType == MatchmakingOutbox.MatchRequested) + .ToListAsync(); + + assignments.Should().HaveCount(2); + events.Should().ContainSingle(); + + // The event names the group, and the group's assignments name the same match request. + events[0].AggregateId.Should().Be(assignments[0].GroupId); + assignments.Select(a => a.MatchRequestId).Distinct().Should().ContainSingle(); + + // No credential, no rating, no region ever reaches an outbox payload. + events[0].Payload.Should().NotContain("1200"); + events[0].Payload.Should().NotContain("eu-west"); + } + + [SkippableFact] + public async Task WithNoMatchRuntime_TheWorkerCommitsNothingAtAll() + { + // The M8 gate, proven against the database rather than a substitute: not merely "no assignment returned", + // but no row written anywhere. + SkipIfNoPg(); + await SeedCatalogAsync(); + await SeedQueuedTicketsAsync(count: 4); + + await using var db = CreateDb(); + var result = await BuildCoordinator(db, matchRuntimeAvailable: false).RunCycleAsync("worker-1"); + + result.Executed.Should().BeFalse(); + + await using var verify = CreateDb(); + (await verify.MatchmakingAssignments.CountAsync()).Should().Be(0); + (await verify.OutboxMessages.CountAsync(m => m.EventType == MatchmakingOutbox.MatchRequested)).Should().Be(0); + (await verify.MatchmakingTickets.CountAsync(t => t.State == MatchmakingTicketState.Queued)).Should().Be(4); + } + + // ── Expiry sweep ──────────────────────────────────────────────────────── + + [SkippableFact] + public async Task TheExpirySweepTimesOutOverdueTicketsAndIsIdempotent() + { + SkipIfNoPg(); + await SeedCatalogAsync(); + await SeedQueuedTicketsAsync(count: 3, enqueuedAt: T0); + + await using var db = CreateDb(); + var clock = new FakeTimeProvider(T0.AddSeconds(63)); + var sweeper = BuildSweeper(db, clock); + + var first = await sweeper.SweepAsync(); + first.TicketsExpired.Should().Be(3); + first.MaxTicketLag.Should().Be(TimeSpan.FromSeconds(3)); + + // Re-running the sweep must not double-expire anything: TryTimeOut returns false rather than transitioning + // a second time. + var second = await sweeper.SweepAsync(); + second.TicketsExpired.Should().Be(0); + + await using var verify = CreateDb(); + (await verify.MatchmakingTickets.CountAsync(t => t.State == MatchmakingTicketState.TimedOut)).Should().Be(3); + } + + [SkippableFact] + public async Task AnExpiredTicketReleasesTheUsersSingleActiveSlot() + { + // The reason the sweep must actually write, rather than the read path merely treating the ticket as dead: a + // ticket stuck Queued would keep its owner locked out of joining anything else, forever. + SkipIfNoPg(); + await SeedCatalogAsync(); + + var user = await SeedUserAsync(); + await using (var seed = CreateDb()) + { + seed.MatchmakingTickets.Add(NewTicket(user.Id, T0)); + await seed.SaveChangesAsync(); + } + + await using var db = CreateDb(); + await BuildSweeper(db, new FakeTimeProvider(T0.AddSeconds(61))).SweepAsync(); + + // The user can now queue again — the filtered unique index no longer sees a nonterminal ticket. + await using var dbQueue = CreateDb(); + var result = await BuildMatchmakingService(dbQueue, new FakeTimeProvider(T0.AddSeconds(61))) + .EnqueueAsync(user.Id, TicketRequest()); + + result.IsSuccess.Should().BeTrue(); + } + + // ── Outbox dispatcher (D3) ────────────────────────────────────────────── + + [SkippableFact] + public async Task TheDispatcherBackfillsDeliveryRows_ProcessesTheEvent_AndIsIdempotentOnReplay() + { + SkipIfNoPg(); + await SeedCatalogAsync(); + + var host = await SeedUserAsync(); + var member = await SeedUserAsync(); + + var lobbyId = await SeedLobbyAsync(host.Id, maxPlayers: 4); + await SeedJoinedMemberAsync(lobbyId, member.Id); + + // A real M3 block event — emitted by the producer, with no delivery row, exactly as M3 has been writing them + // since long before any consumer existed. + await using (var seed = CreateDb()) + { + seed.Blocks.Add(Block.Create(host.Id, member.Id)); + seed.OutboxMessages.Add(FriendOutbox.UserBlockedEvent(Block.Create(host.Id, member.Id))); + await seed.SaveChangesAsync(); + } + + await using var db = CreateDb(); + var (processor, handler) = BuildDispatcher(db); + + var result = await processor.DispatchAsync(handler); + + result.Leased.Should().Be(1); + result.Processed.Should().Be(1); + result.Failed.Should().Be(0); + + await using var verify = CreateDb(); + + // The host blocked the member, so the member is removed and the host keeps the lobby they own. + var joined = await verify.LobbyMembers.AsNoTracking() + .Where(m => m.LobbyId == lobbyId && m.State == LobbyMemberState.Joined) + .Select(m => m.UserId) + .ToListAsync(); + + joined.Should().BeEquivalentTo(new[] { host.Id }); + + var delivery = await verify.OutboxDeliveries.AsNoTracking().SingleAsync(); + delivery.Processed.Should().BeTrue(); + delivery.HandlerName.Should().Be("lobby-block"); + + // A second pass leases nothing — the delivery row is the memory that keeps at-least-once from becoming + // at-least-twice-visibly. + await using var db2 = CreateDb(); + var (processor2, handler2) = BuildDispatcher(db2); + var replay = await processor2.DispatchAsync(handler2); + + replay.Leased.Should().Be(0); + replay.Processed.Should().Be(0); + } + + [SkippableFact] + public async Task TwoConcurrentDispatchers_NeverProcessTheSameEventTwice() + { + // The lease, proven under real contention. FOR UPDATE SKIP LOCKED is what makes the second dispatcher step + // over the rows the first is holding rather than block behind them — and the unique (EventId, HandlerName) + // index is what stops them both creating the delivery row in the first place. + SkipIfNoPg(); + await SeedCatalogAsync(); + + var host = await SeedUserAsync(); + var member = await SeedUserAsync(); + var lobbyId = await SeedLobbyAsync(host.Id, maxPlayers: 4); + await SeedJoinedMemberAsync(lobbyId, member.Id); + + await using (var seed = CreateDb()) + { + seed.Blocks.Add(Block.Create(host.Id, member.Id)); + seed.OutboxMessages.Add(FriendOutbox.UserBlockedEvent(Block.Create(host.Id, member.Id))); + await seed.SaveChangesAsync(); + } + + await using var dbA = CreateDb(); + await using var dbB = CreateDb(); + var (processorA, handlerA) = BuildDispatcher(dbA); + var (processorB, handlerB) = BuildDispatcher(dbB); + + // One of these may fail outright on a unique-violation while backfilling the same delivery row; that is + // contention, not a bug, and the surviving pass still delivers the event. What must never happen is both + // succeeding in *processing* it. + var results = await Task.WhenAll( + SafeDispatchAsync(processorA, handlerA), + SafeDispatchAsync(processorB, handlerB)); + + results.Sum(r => r?.Processed ?? 0).Should().BeLessThanOrEqualTo(1); + + await using var verify = CreateDb(); + (await verify.OutboxDeliveries.AsNoTracking().CountAsync()).Should().Be(1, "one event, one handler, one row"); + } + + // ── D3 benchmark ──────────────────────────────────────────────────────── + + /// + /// The brief's local benchmark profile: 50 concurrent ticket creators, two competing workers, zero duplicate + /// assignment, matchmaking cycle p95 under one second, and expiry lag under five seconds. + /// + /// + /// This is local evidence, not an internet-scale SLO. It runs against a single PostgreSQL + /// container on a developer machine with no network hop, no other tenants, and no cold caches. Its value is that + /// it would catch an accidental O(n²) scan or a missing index at 50 tickets — not that it predicts production. + /// + /// + [SkippableFact] + public async Task Benchmark_FiftyConcurrentCreators_TwoWorkers_NoDuplicateAssignment_P95UnderOneSecond() + { + SkipIfNoPg(); + await SeedCatalogAsync(); + + // 50 users, each enqueueing concurrently through the real service — including the advisory lock and the + // whole-command retry, so the measurement covers the real write path rather than a bare INSERT. + var users = new List(); + for (var i = 0; i < 50; i++) users.Add(await SeedUserAsync()); + + var enqueueSw = Stopwatch.StartNew(); + + var enqueues = users.Select(async user => + { + await using var db = CreateDb(); + return await BuildMatchmakingService(db).EnqueueAsync(user.Id, TicketRequest()); + }).ToList(); + + var enqueueResults = await Task.WhenAll(enqueues); + enqueueSw.Stop(); + + enqueueResults.Should().OnlyContain(r => r.IsSuccess, "every one of the 50 tickets must be accepted"); + + // Two competing workers, run repeatedly until the queue drains, measuring each cycle. + var cycleTimes = new List(); + var totalMatched = 0; + + for (var round = 0; round < 30; round++) + { + await using var dbA = CreateDb(); + await using var dbB = CreateDb(); + var workerA = BuildCoordinator(dbA, matchRuntimeAvailable: true); + var workerB = BuildCoordinator(dbB, matchRuntimeAvailable: true); + + var sw = Stopwatch.StartNew(); + var cycles = await Task.WhenAll( + workerA.RunCycleAsync("bench-A"), + workerB.RunCycleAsync("bench-B")); + sw.Stop(); + + cycleTimes.Add(sw.Elapsed.TotalMilliseconds); + totalMatched += cycles.Sum(c => c.TicketsMatched); + + if (cycles.All(c => c.TicketsClaimed == 0)) break; + } + + await using var verify = CreateDb(); + + // ── Correctness first. A fast worker that double-books players is worthless. ── + var assignments = await verify.MatchmakingAssignments.AsNoTracking().ToListAsync(); + assignments.Select(a => a.TicketId).Should().OnlyHaveUniqueItems("zero duplicate assignment"); + assignments.Should().HaveCount(50, "all 50 tickets pair up into 25 groups"); + + var groups = assignments.GroupBy(a => a.GroupId).ToList(); + groups.Should().HaveCount(25); + groups.Should().OnlyContain(g => g.Count() == 2); + + var events = await verify.OutboxMessages.AsNoTracking() + .CountAsync(m => m.EventType == MatchmakingOutbox.MatchRequested); + events.Should().Be(25, "one MatchRequestedV1 per group, never one per ticket"); + + totalMatched.Should().Be(50); + + // ── Then the budgets. ── + var p95 = Percentile(cycleTimes, 0.95); + p95.Should().BeLessThan(1000, "matchmaking cycle p95 must stay under 1s"); + + // Expiry lag: how far past its deadline the sweep is when it reaches an overdue ticket. + await SeedQueuedTicketsAsync(count: 5, enqueuedAt: T0, userPrefix: "lag"); + + await using var dbSweep = CreateDb(); + var sweepSw = Stopwatch.StartNew(); + var sweep = await BuildSweeper(dbSweep, new FakeTimeProvider(T0.AddSeconds(60))).SweepAsync(); + sweepSw.Stop(); + + sweep.TicketsExpired.Should().Be(5); + sweepSw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(5), "expiry lag budget"); + + // Recorded honestly for the evidence report rather than only asserted. + Console.WriteLine( + $"[M6C BENCHMARK] 50 enqueues in {enqueueSw.Elapsed.TotalMilliseconds:F0}ms; " + + $"cycles={cycleTimes.Count}; cycle p95={p95:F1}ms; max={cycleTimes.Max():F1}ms; " + + $"matched={totalMatched}; groups={groups.Count}; sweep={sweepSw.Elapsed.TotalMilliseconds:F0}ms"); + } + + private static double Percentile(List values, double percentile) + { + var sorted = values.OrderBy(v => v).ToList(); + var index = (int)Math.Ceiling(percentile * sorted.Count) - 1; + return sorted[Math.Clamp(index, 0, sorted.Count - 1)]; + } + + private static async Task SafeDispatchAsync(IOutboxProcessor processor, IOutboxHandler handler) + { + try + { + return await processor.DispatchAsync(handler); + } + catch (DbUpdateException) + { + // Lost the backfill race on the unique (EventId, HandlerName) index. Expected under contention. + return null; + } + } + + // ── Composition ───────────────────────────────────────────────────────── + + private void SkipIfNoPg() => Skip.If(_masterConn is null, + "Set MIGRATION_TEST_CONNECTION_STRING to a PostgreSQL connection string to run Module 6C race tests."); + + private AppDbContext CreateDb() => + new(new DbContextOptionsBuilder().UseNpgsql(_testConn).Options); + + /// + /// The real coordinator over the real repository and the real worker transaction. Only the M8 probe is + /// substituted — because M8 does not exist, and the gate it drives is the thing under test. + /// + private MatchmakingCoordinator BuildCoordinator(AppDbContext db, bool matchRuntimeAvailable) + { + var probe = Substitute.For(); + probe.IsAvailableAsync(Arg.Any()).Returns(matchRuntimeAvailable); + probe.IsInActiveMatchAsync(Arg.Any(), Arg.Any()).Returns(false); + + return new MatchmakingCoordinator( + new MatchmakingRepository(db), + new WorkerTransaction(db, NullLogger.Instance), + probe, + Options.Create(new MatchmakingOptions()), + TimeProvider.System, + NullLogger.Instance); + } + + private ExpirySweeper BuildSweeper(AppDbContext db, TimeProvider clock) => + new(new MatchmakingRepository(db), + new LobbyRepository(db), + new WorkerTransaction(db, NullLogger.Instance), + Options.Create(new ExpiryOptions()), + clock, + NullLogger.Instance); + + private (IOutboxProcessor Processor, IOutboxHandler Handler) BuildDispatcher(AppDbContext db) + { + var transaction = new WorkerTransaction(db, NullLogger.Instance); + + var processor = new OutboxProcessor( + new OutboxRepository(db), transaction, Options.Create(new OutboxOptions()), + TimeProvider.System, NullLogger.Instance); + + var handler = new LobbyBlockHandler( + new LobbyRepository(db), + new LobbyCommandRunner(db, NullLogger.Instance), + TimeProvider.System, + NullLogger.Instance); + + return (processor, handler); + } + + private MatchmakingService BuildMatchmakingService(AppDbContext db, TimeProvider? clock = null) + { + var probe = Substitute.For(); + probe.IsAvailableAsync(Arg.Any()).Returns(false); + probe.IsInActiveMatchAsync(Arg.Any(), Arg.Any()).Returns(false); + + var chat = Substitute.For(); + chat.IsAvailableAsync(Arg.Any()).Returns(false); + var ai = Substitute.For(); + ai.IsAvailableAsync(Arg.Any()).Returns(false); + + return new MatchmakingService( + new MatchmakingRepository(db), + new LobbyRepository(db), + new LobbyCommandRunner(db, NullLogger.Instance), + probe, chat, ai, + new UserRepository(db), + Options.Create(new LobbyCredentialOptions { Key = TestKey, DefaultRegion = "eu-west" }), + clock ?? TimeProvider.System, + NullLogger.Instance); + } + + private LobbiesService BuildLobbiesService(AppDbContext db) + { + var hasher = new HmacLobbyCredentialHasher( + Options.Create(new LobbyCredentialOptions { Key = TestKey, DefaultRegion = "eu-west" })); + + var throttle = Substitute.For(); + throttle.GetRetryAfterUtcAsync(Arg.Any(), Arg.Any()).Returns((DateTime?)null); + + var probe = Substitute.For(); + probe.IsAvailableAsync(Arg.Any()).Returns(false); + probe.IsInActiveMatchAsync(Arg.Any(), Arg.Any()).Returns(false); + + var chat = Substitute.For(); + chat.IsAvailableAsync(Arg.Any()).Returns(false); + var ai = Substitute.For(); + ai.IsAvailableAsync(Arg.Any()).Returns(false); + + var engines = Substitute.For(); + engines.RegisteredDefinitions.Returns(Array.Empty()); + + return new LobbiesService( + new LobbyRepository(db), + new LobbyCommandRunner(db, NullLogger.Instance), + hasher, throttle, probe, chat, ai, engines, + new UserRepository(db), + Substitute.For(), + Options.Create(new StorageOptions()), + Options.Create(new LobbyCredentialOptions { Key = TestKey, DefaultRegion = "eu-west" }), + TimeProvider.System, + NullLogger.Instance); + } + + // ── Seeding ───────────────────────────────────────────────────────────── + + private static CreateTicketRequestDto TicketRequest() => new( + GameSlug: "chess-lite", CapabilityVersion: 1, Mode: "multiplayer", PlayerCount: 2, + TimeControlId: "blitz-3-2", Rated: false, Region: "eu-west"); + + private static MatchmakingTicket NewTicket(Guid userId, DateTime enqueuedAt) => + MatchmakingTicket.Enqueue( + userId, "chess-lite", 1, "multiplayer", 2, "blitz-3-2", false, "eu-west", + MatchmakingTicket.ProvisionalRating, MatchmakingTicket.ProvisionalRatingSource, + Guid.NewGuid(), enqueuedAt); + + /// + /// N identical, mutually-compatible queued tickets — the worst case for two workers racing over one pool. + /// Written straight to the table rather than through the service so the fixture is not itself under test. + /// + private async Task> SeedQueuedTicketsAsync( + int count, DateTime? enqueuedAt = null, string userPrefix = "mm") + { + var ids = new List(count); + await using var db = CreateDb(); + + for (var i = 0; i < count; i++) + { + var g = Guid.NewGuid(); + var user = User.Create($"{userPrefix}{g:N}"[..20], $"{userPrefix}{g:N}@test.io", "hash", "M6C User"); + db.Users.Add(user); + + var ticket = NewTicket(user.Id, enqueuedAt ?? DateTime.UtcNow.AddSeconds(-1)); + db.MatchmakingTickets.Add(ticket); + ids.Add(ticket.Id); + } + + await db.SaveChangesAsync(); + return ids; + } + + private async Task SeedUserAsync() + { + await using var db = CreateDb(); + var g = Guid.NewGuid(); + var user = User.Create($"m6c{g:N}"[..20], $"m6c{g:N}@test.io", "hash", "M6C Race User"); + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } + + private async Task SeedLobbyAsync(Guid hostId, int maxPlayers) + { + await using var db = CreateDb(); + + var lobby = Lobby.Create( + hostId, + new LobbySettings("chess-lite", 1, LobbyPrivacy.Private, maxPlayers, "blitz-3-2", false, + "eu-west", SpectatorPolicy.Anyone, "none", false), + Guid.NewGuid(), + DateTime.UtcNow); + + db.Lobbies.Add(lobby); + await db.SaveChangesAsync(); + return lobby.Id; + } + + private async Task SeedJoinedMemberAsync(Guid lobbyId, Guid userId) + { + await using var db = CreateDb(); + var lobby = await db.Lobbies.Include(l => l.Members).FirstAsync(l => l.Id == lobbyId); + lobby.Join(userId, DateTime.UtcNow); + await db.SaveChangesAsync(); + } + + private async Task SeedCredentialAsync(Guid lobbyId, string plaintextCode) + { + await using var db = CreateDb(); + + var hasher = new HmacLobbyCredentialHasher( + Options.Create(new LobbyCredentialOptions { Key = TestKey, DefaultRegion = "eu-west" })); + + db.LobbyJoinCredentials.Add(LobbyJoinCredential.Issue( + lobbyId, hasher.HashCode(plaintextCode), hasher.HashLinkToken(plaintextCode + "-link"), 1, + DateTime.UtcNow)); + + await db.SaveChangesAsync(); + return plaintextCode; + } + + private async Task SeedCatalogAsync() + { + await using var db = CreateDb(); + + if (await db.Games.AnyAsync(g => g.Slug == "chess-lite")) return; + + db.Games.Add(Game.Create( + slug: "chess-lite", name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: GameDifficulty.Medium, + estimatedDurationMinMinutes: 10, estimatedDurationMaxMinutes: 20, + minPlayers: 2, maxPlayers: 4, initialLifecycle: GameLifecycle.Available, + featuredRank: null, sortOrder: 1, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", + tags: new[] { "classic" }, + modes: new[] { "multiplayer", "cooperative" })); + + db.GameCapabilityProfiles.Add(GameCapabilityProfile.Create( + "chess-lite", 1, minPlayers: 2, maxPlayers: 4, + allowedModes: new[] { "multiplayer", "cooperative" }, + timeControls: new[] { "blitz-3-2", "rapid-10-0", "untimed" }, + tieBreakRules: new[] { "none", "sudden-death" }, + spectatorPolicies: new[] { "Anyone", "FriendsOnly", "Disabled" }, + ratedEligible: false, aiFillEligible: false, + manifestVersion: "2026.1")); + + await db.SaveChangesAsync(); + } +} diff --git a/tests/SimPle.IntegrationTests/Profiles/ProfilePrivacyMigrationSmokeTests.cs b/tests/SimPle.IntegrationTests/Profiles/ProfilePrivacyMigrationSmokeTests.cs index 591854c..23a8b61 100644 --- a/tests/SimPle.IntegrationTests/Profiles/ProfilePrivacyMigrationSmokeTests.cs +++ b/tests/SimPle.IntegrationTests/Profiles/ProfilePrivacyMigrationSmokeTests.cs @@ -51,7 +51,12 @@ public async Task DisposeAsync() SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '{_dbName}' - AND pid <> pg_backend_pid();"; + AND pid <> pg_backend_pid() + -- Only this role's own backends. Terminating another role's process raises 42501, and an + -- autovacuum worker (which runs as the bootstrap superuser) can appear on this database at + -- any moment -- so the unfiltered form fails intermittently under a least-privilege test role. + -- The teardown below clears autovacuum on its own, so skipping those backends is safe. + AND usename = current_user;"; await terminateCmd.ExecuteNonQueryAsync(); await using var dropCmd = masterConn.CreateCommand(); diff --git a/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj b/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj index af10372..306040b 100644 --- a/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj +++ b/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj @@ -14,6 +14,7 @@ + @@ -25,6 +26,10 @@ + + + + diff --git a/tests/SimPle.IntegrationTests/xunit.runner.json b/tests/SimPle.IntegrationTests/xunit.runner.json new file mode 100644 index 0000000..08c512b --- /dev/null +++ b/tests/SimPle.IntegrationTests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": false +} diff --git a/tests/SimPle.UnitTests/Capabilities/GameCapabilityProfileTests.cs b/tests/SimPle.UnitTests/Capabilities/GameCapabilityProfileTests.cs new file mode 100644 index 0000000..3d5abc9 --- /dev/null +++ b/tests/SimPle.UnitTests/Capabilities/GameCapabilityProfileTests.cs @@ -0,0 +1,310 @@ +using FluentAssertions; +using SimPle.Domain.Capabilities; +using SimPle.Domain.Lobbies; +using SimPle.UnitTests.Lobbies; + +namespace SimPle.UnitTests.Capabilities; + +/// +/// The capability profile (D2) is what turns the brief's "stale/unsupported combinations fail before persistence" +/// into a real, testable path. Module 4's catalog has no capability version, time controls, tie-breaks, spectator +/// policy, or rated flag, so without this table "capability disabled after create" could not be triggered at all. +/// +public class GameCapabilityProfileTests +{ + // ── Permits: the happy path and each rejection reason ──────────────────── + + [Fact] + public void AProfilePermitsSettingsItDeclares() + { + var profile = LobbyTestFactory.Profile(); + + var check = profile.Permits(LobbyTestFactory.Settings(timeControlId: "blitz-3-2", tieBreakRuleId: "none")); + + check.Allowed.Should().BeTrue(); + check.Reason.Should().BeNull(); + } + + [Fact] + public void ADeactivatedProfileRejectsEverything() + { + // This is the "capability disabled after create" path: a lobby pinned to v1 keeps existing, but every new + // command that depends on the pin fails closed. + var profile = LobbyTestFactory.Profile(); + profile.Deactivate(); + + var check = profile.Permits(LobbyTestFactory.Settings()); + + check.Allowed.Should().BeFalse(); + check.Reason.Should().Contain("no longer active"); + } + + [Fact] + public void DeactivationIsIdempotent() + { + var profile = LobbyTestFactory.Profile(); + profile.Deactivate(); + profile.Deactivate(); + + profile.IsActive.Should().BeFalse(); + } + + [Fact] + public void AProfileRejectsSettingsForADifferentGame() + { + var profile = LobbyTestFactory.Profile(gameSlug: "chess-lite"); + + var check = profile.Permits(LobbyTestFactory.Settings(gameSlug: "checkers")); + + check.Allowed.Should().BeFalse(); + check.Reason.Should().Contain("different game"); + } + + [Fact] + public void AProfileRejectsSettingsPinnedToADifferentCapabilityVersion() + { + var profile = LobbyTestFactory.Profile(capabilityVersion: 1); + + var check = profile.Permits(LobbyTestFactory.Settings(capabilityVersion: 2)); + + check.Allowed.Should().BeFalse(); + check.Reason.Should().Contain("different capability version"); + } + + [Fact] + public void AProfileRejectsAnUnsupportedTimeControl() + { + var profile = LobbyTestFactory.Profile(timeControls: new[] { "blitz-3-2" }); + + var check = profile.Permits(LobbyTestFactory.Settings(timeControlId: "classical-30-0")); + + check.Allowed.Should().BeFalse(); + check.Reason.Should().Contain("classical-30-0"); + } + + [Fact] + public void AProfileRejectsAnUnsupportedTieBreakRule() + { + var profile = LobbyTestFactory.Profile(tieBreakRules: new[] { "none" }); + + var check = profile.Permits(LobbyTestFactory.Settings(tieBreakRuleId: "sudden-death")); + + check.Allowed.Should().BeFalse(); + check.Reason.Should().Contain("sudden-death"); + } + + [Fact] + public void AProfileRejectsAnUnsupportedSpectatorPolicy() + { + var profile = LobbyTestFactory.Profile(spectatorPolicies: new[] { "Disabled" }); + + var check = profile.Permits(LobbyTestFactory.Settings(spectatorPolicy: SpectatorPolicy.Anyone)); + + check.Allowed.Should().BeFalse(); + check.Reason.Should().Contain("Anyone"); + } + + [Fact] + public void AProfileRejectsSeatCountsOutsideItsBounds() + { + var profile = LobbyTestFactory.Profile(minPlayers: 2, maxPlayers: 2); + + profile.Permits(LobbyTestFactory.Settings(maxPlayers: 4)).Allowed.Should().BeFalse(); + profile.Permits(LobbyTestFactory.Settings(maxPlayers: 2)).Allowed.Should().BeTrue(); + } + + [Fact] + public void AProfileThatIsNotRatedEligibleRejectsARatedLobby() + { + var profile = LobbyTestFactory.Profile( + allowedModes: new[] { "multiplayer" }, ratedEligible: false, aiFillEligible: false); + + var check = profile.Permits(LobbyTestFactory.Settings(rated: true)); + + check.Allowed.Should().BeFalse(); + check.Reason.Should().Contain("rated"); + } + + [Fact] + public void AProfileThatIsNotAiFillEligibleRejectsAnAiFillRequest() + { + var profile = LobbyTestFactory.Profile( + allowedModes: new[] { "multiplayer" }, ratedEligible: false, aiFillEligible: false); + + var check = profile.Permits(LobbyTestFactory.Settings(aiFillRequested: true)); + + check.Allowed.Should().BeFalse(); + check.Reason.Should().Contain("AI fill"); + } + + // ── Construction invariants ────────────────────────────────────────────── + + [Fact] + public void RatedEligibilityRequiresTheRankedMode() + { + // A "rated" game with no ranked mode is incoherent — there is nothing to be rated at. + var act = () => LobbyTestFactory.Profile( + allowedModes: new[] { "multiplayer" }, ratedEligible: true, aiFillEligible: false); + + act.Should().Throw().WithMessage("*ranked*"); + } + + [Fact] + public void AiFillEligibilityRequiresTheAiMode() + { + var act = () => LobbyTestFactory.Profile( + allowedModes: new[] { "multiplayer" }, ratedEligible: false, aiFillEligible: true); + + act.Should().Throw().WithMessage("*ai*"); + } + + [Fact] + public void AProfileMustSeatAtLeastTwoPlayers() + { + // A lobby is a multiplayer surface; a game's solo mode is reachable from the library, not from a lobby. + var act = () => LobbyTestFactory.Profile(minPlayers: 1); + + act.Should().Throw().WithMessage("*at least 2*"); + } + + [Fact] + public void AProfileRejectsATimeControlOutsideThePlatformAllowList() + { + var act = () => LobbyTestFactory.Profile(timeControls: new[] { "hyperbullet-0-1" }); + + act.Should().Throw().WithMessage("*allow-list*"); + } + + [Fact] + public void AProfileRejectsAModeOutsideModule4sAllowList() + { + // Reuses GameCatalogAllowLists.Modes rather than keeping a parallel copy that could drift from M4's. + var act = () => LobbyTestFactory.Profile(allowedModes: new[] { "battle-royale" }); + + act.Should().Throw().WithMessage("*allow-list*"); + } + + [Fact] + public void AProfileRejectsDuplicateValues() + { + var act = () => LobbyTestFactory.Profile(timeControls: new[] { "blitz-3-2", "blitz-3-2" }); + + act.Should().Throw().WithMessage("*duplicate*"); + } + + // ── Catalog drift ──────────────────────────────────────────────────────── + + [Fact] + public void AProfileThatMatchesTheCatalogDoesNotContradictIt() + { + var profile = LobbyTestFactory.Profile( + minPlayers: 2, maxPlayers: 2, allowedModes: new[] { "multiplayer", "ranked", "ai" }); + + var check = profile.ContradictsCatalog(2, 2, new[] { "ai", "multiplayer", "ranked" }); + + check.Allowed.Should().BeTrue(); + } + + [Fact] + public void AProfileWiderThanTheCatalogsSeatBoundsIsDrift() + { + // Would let a lobby be created that M5's engine cannot host. Fail closed at seed time rather than at a + // player's Start click. + var profile = LobbyTestFactory.Profile(minPlayers: 2, maxPlayers: 8); + + var check = profile.ContradictsCatalog(2, 4, new[] { "multiplayer", "ranked", "ai" }); + + check.Allowed.Should().BeFalse(); + check.Reason.Should().Contain("exceed the catalog"); + } + + [Fact] + public void AProfileAllowingAModeTheCatalogDoesNotIsDrift() + { + var profile = LobbyTestFactory.Profile( + allowedModes: new[] { "multiplayer", "ranked", "ai" }); + + var check = profile.ContradictsCatalog(2, 4, new[] { "multiplayer" }); + + check.Allowed.Should().BeFalse(); + check.Reason.Should().Contain("ranked"); + } + + [Fact] + public void AProfileNarrowerThanTheCatalogIsFine() + { + // A subset is exactly what a profile is meant to be: the catalog says what the game *can* do, the profile + // says what a lobby *may configure*. + var profile = LobbyTestFactory.Profile( + minPlayers: 2, maxPlayers: 2, allowedModes: new[] { "multiplayer" }, + ratedEligible: false, aiFillEligible: false); + + var check = profile.ContradictsCatalog(1, 8, new[] { "solo", "multiplayer", "ranked", "ai" }); + + check.Allowed.Should().BeTrue(); + } +} + +/// +/// Server-side region resolution. User.Region is free text validated only for length, so it is treated as +/// untrusted input rather than a trusted region. +/// +public class LobbyRegionTests +{ + [Fact] + public void AnExplicitAllowListedRequestWins() + { + LobbyRegion.Resolve("us-east", "eu-west", "eu-central").Should().Be("us-east"); + } + + [Fact] + public void AutoFallsBackToTheUsersProfileRegion() + { + LobbyRegion.Resolve(LobbyRegion.Auto, "ap-south", "eu-west").Should().Be("ap-south"); + } + + [Fact] + public void AutoFallsBackToTheDeploymentDefaultWhenTheProfileHasNoRegion() + { + LobbyRegion.Resolve(LobbyRegion.Auto, null, "eu-west").Should().Be("eu-west"); + } + + [Fact] + public void AGarbageProfileRegionFallsThroughToTheDeploymentDefault() + { + // User.Region is free text (UpdateProfileRequestValidator checks length only). A profile carrying "Narnia" + // must not partition the matchmaking queue into a pool of one. + LobbyRegion.Resolve(LobbyRegion.Auto, "Narnia", "eu-west").Should().Be("eu-west"); + } + + [Fact] + public void AGarbageRequestedRegionFallsThroughRatherThanBeingPersisted() + { + LobbyRegion.Resolve("mars-north", "ap-south", "eu-west").Should().Be("ap-south"); + } + + [Fact] + public void ResolveNeverReturnsAuto() + { + LobbyRegion.Resolve(LobbyRegion.Auto, LobbyRegion.Auto, "eu-west").Should().Be("eu-west"); + LobbyRegion.IsResolved(LobbyRegion.Resolve(LobbyRegion.Auto, null, "eu-west")).Should().BeTrue(); + } + + [Fact] + public void AMisconfiguredDeploymentDefaultFailsLoudly() + { + // Every fallback path ends at the deployment default. If that is itself not a real region, silently + // returning it would poison every lobby and ticket in the deployment. + var act = () => LobbyRegion.Resolve(LobbyRegion.Auto, null, "not-a-region"); + + act.Should().Throw().WithMessage("*not allow-listed*"); + } + + [Fact] + public void AutoIsNeverConsideredAResolvedRegion() + { + LobbyRegion.IsResolved(LobbyRegion.Auto).Should().BeFalse(); + LobbyRegion.IsResolved("eu-west").Should().BeTrue(); + LobbyRegion.IsResolved("narnia").Should().BeFalse(); + } +} diff --git a/tests/SimPle.UnitTests/Games/GamesServiceTests.cs b/tests/SimPle.UnitTests/Games/GamesServiceTests.cs index 090f3de..ab8537a 100644 --- a/tests/SimPle.UnitTests/Games/GamesServiceTests.cs +++ b/tests/SimPle.UnitTests/Games/GamesServiceTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using FluentAssertions; using NSubstitute; using SimPle.Application.Common.Interfaces; @@ -209,7 +210,9 @@ public async Task GetDetailAsync_EntryActions_IsTheFixedFiveActionProjection(Gam result.IsSuccess.Should().BeTrue(); result.Value!.Game!.EntryActions.Should().BeEquivalentTo(GameEntryActions.All); - result.Value.Game.EntryActions.Should().OnlyContain(a => a.Status == "deferred"); + // M9/M8 haven't shipped yet; M6's 3 owned actions flipped to enabled once its own gate passed. + result.Value.Game.EntryActions.Where(a => a.OwnerModule != 6).Should().OnlyContain(a => a.Status == "deferred"); + result.Value.Game.EntryActions.Where(a => a.OwnerModule == 6).Should().OnlyContain(a => a.Status == "enabled"); } // ── Detail: 404 / 410 / 200 per lifecycle ──────────────────────────────── diff --git a/tests/SimPle.UnitTests/Lobbies/LobbiesServiceTests.cs b/tests/SimPle.UnitTests/Lobbies/LobbiesServiceTests.cs new file mode 100644 index 0000000..da2a7f6 --- /dev/null +++ b/tests/SimPle.UnitTests/Lobbies/LobbiesServiceTests.cs @@ -0,0 +1,805 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.GameHost.Services; +using SimPle.Application.Lobbies.DTOs; +using SimPle.Application.Lobbies.Services; +using SimPle.Domain.Capabilities; +using SimPle.Domain.GameHost; +using SimPle.Domain.Games; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Outbox; +using SimPle.Domain.Users; +using SimPle.Shared.Common; +using Xunit; + +namespace SimPle.UnitTests.Lobbies; + +/// +/// Command-layer tests for : authorization, privacy-safe not-found, capability +/// validation, credential-oracle prevention, honest deferral, and the failed-join throttle. +/// +/// The is a pass-through here. That is deliberate, not a shortcut: the retry it +/// performs only means anything against a database that actually enforces unique indexes and row versions, so it +/// is proven in LobbiesPostgresConcurrencyTests against real PostgreSQL. Substituting a fake that +/// "simulated" contention would prove only that the fake works. +/// +public sealed class LobbiesServiceTests +{ + private static readonly DateTime T0 = LobbyTestFactory.T0; + + private readonly ILobbyRepository _repo = Substitute.For(); + private readonly ILobbyCredentialHasher _hasher = Substitute.For(); + private readonly ILobbyJoinThrottle _throttle = Substitute.For(); + private readonly IMatchRuntimeProbe _matchRuntime = Substitute.For(); + private readonly IChatRuntimeProbe _chatRuntime = Substitute.For(); + private readonly IAiParticipantProbe _aiProbe = Substitute.For(); + private readonly IGameRegistry _engines = Substitute.For(); + private readonly IUserRepository _users = Substitute.For(); + private readonly IFileStorageService _storage = Substitute.For(); + private readonly FakeTimeProvider _clock = new(T0); + + private readonly Guid _host = Guid.NewGuid(); + private readonly Guid _joiner = Guid.NewGuid(); + private readonly Guid _stranger = Guid.NewGuid(); + + private readonly LobbiesService _sut; + + public LobbiesServiceTests() + { + // Default world: nothing blocked, nobody already active, one available game with a permissive profile, + // and no downstream module registered — the true state of the platform at Module 6. + _repo.GetBlockedCounterpartsAsync(Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(Array.Empty()); + _repo.GetActiveLobbyForUserAsync(Arg.Any(), Arg.Any()).Returns((Lobby?)null); + _repo.GetActiveTicketIdForUserAsync(Arg.Any(), Arg.Any()).Returns((Guid?)null); + _repo.AreFriendsAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + _repo.GetCapabilityProfileAsync("chess-lite", 1, Arg.Any()).Returns(Profile()); + _repo.GetGameAsync("chess-lite", Arg.Any()).Returns(AvailableGame()); + _repo.GetGameModesAsync(Arg.Any(), Arg.Any()) + .Returns(new[] { "multiplayer", "cooperative" }); + _repo.GetUsersAsync(Arg.Any>(), Arg.Any()) + .Returns(call => Roster(call.Arg>())); + + _users.GetByIdAsync(Arg.Any(), Arg.Any()) + .Returns(call => MakeUser(call.Arg())); + + _hasher.HashCode(Arg.Any()).Returns(call => "hash:" + call.Arg()); + _hasher.HashLinkToken(Arg.Any()).Returns(call => "hash:" + call.Arg()); + + _throttle.GetRetryAfterUtcAsync(Arg.Any(), Arg.Any()).Returns((DateTime?)null); + + _matchRuntime.IsAvailableAsync(Arg.Any()).Returns(false); + _matchRuntime.IsInActiveMatchAsync(Arg.Any(), Arg.Any()).Returns(false); + _chatRuntime.IsAvailableAsync(Arg.Any()).Returns(false); + _aiProbe.IsAvailableAsync(Arg.Any()).Returns(false); + _engines.RegisteredDefinitions.Returns(Array.Empty()); + + _sut = new LobbiesService( + _repo, new PassThroughRunner(), _hasher, _throttle, _matchRuntime, _chatRuntime, _aiProbe, + _engines, _users, _storage, + Options.Create(new StorageOptions()), + Options.Create(new LobbyCredentialOptions { Key = new string('k', 32), DefaultRegion = "eu-west" }), + _clock, + NullLogger.Instance); + } + + // ── Create ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Create_IssuesACredentialAndSeatsTheHostReady() + { + var result = await _sut.CreateAsync(_host, CreateRequest()); + + result.IsSuccess.Should().BeTrue(); + var value = result.Value!; + + value.Lobby.HostUserId.Should().Be(_host); + value.Lobby.Seats.Should().ContainSingle() + .Which.Should().Match(s => s.IsHost && s.IsReady); + + // The plaintext is handed back exactly once, here. It is never stored — only its digest is. + value.Credential.Code.Should().NotBeNullOrWhiteSpace(); + value.Credential.LinkToken.Should().NotBeNullOrWhiteSpace(); + value.Credential.Generation.Should().Be(1); + + await _repo.Received(1).AddLobbyAsync( + Arg.Any(), + Arg.Is(c => + c.CodeDigest != value.Credential.Code && c.LinkTokenDigest != value.Credential.LinkToken), + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task Create_ResolvesAutoRegionServerSide_AndNeverPersistsAuto() + { + var result = await _sut.CreateAsync(_host, CreateRequest() with { Region = "Auto" }); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Lobby.ResolvedRegion.Should().Be("eu-west"); + result.Value.Lobby.ResolvedRegion.Should().NotBe(LobbyRegion.Auto); + } + + [Fact] + public async Task Create_WhenPinnedProfileIsInactive_FailsBeforePersistence() + { + var inactive = Profile(); + inactive.Deactivate(); + _repo.GetCapabilityProfileAsync("chess-lite", 1, Arg.Any()).Returns(inactive); + + var result = await _sut.CreateAsync(_host, CreateRequest()); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(LobbyErrors.CapabilityDisabled); + + // "Before persistence" is the claim, so the absence of the write is what the test actually asserts. + await _repo.DidNotReceive().AddLobbyAsync( + Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task Create_WhenTheGameIsNotAvailable_FailsClosed() + { + _repo.GetGameAsync("chess-lite", Arg.Any()) + .Returns(GameWithLifecycle(GameLifecycle.Maintenance)); + + var result = await _sut.CreateAsync(_host, CreateRequest()); + + result.Error!.Code.Should().Be(LobbyErrors.CapabilityDisabled); + } + + [Fact] + public async Task Create_WhenAlreadyInALobby_IsRejected() + { + _repo.GetActiveLobbyForUserAsync(_host, Arg.Any()) + .Returns(LobbyTestFactory.Open(_host, T0)); + + var result = await _sut.CreateAsync(_host, CreateRequest()); + + result.Error!.Code.Should().Be(LobbyErrors.AlreadyActive); + } + + /// + /// The cross-table half of the invariant. Two filtered unique indexes on different tables cannot see each + /// other, so a queued ticket has to block a lobby create in the command layer or not at all. + /// + [Fact] + public async Task Create_WhenAlreadyHoldingAMatchmakingTicket_IsRejected() + { + _repo.GetActiveTicketIdForUserAsync(_host, Arg.Any()).Returns(Guid.NewGuid()); + + var result = await _sut.CreateAsync(_host, CreateRequest()); + + result.Error!.Code.Should().Be(LobbyErrors.AlreadyActive); + } + + // ── Privacy-safe not-found (BOLA) ──────────────────────────────────────── + + [Fact] + public async Task Get_APrivateLobbyTheCallerIsNotIn_IsIndistinguishableFromOneThatDoesNotExist() + { + var lobby = LobbyTestFactory.Open(_host, T0, LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + _repo.GetByIdAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var foreignResult = await _sut.GetAsync(_stranger, lobby.Id); + var missingResult = await _sut.GetAsync(_stranger, Guid.NewGuid()); + + // Same code, same message. A 403 here would confirm the id exists — that is the leak. + foreignResult.Error!.Code.Should().Be(LobbyErrors.NotFound); + missingResult.Error!.Code.Should().Be(LobbyErrors.NotFound); + foreignResult.Error.Message.Should().Be(missingResult.Error.Message); + } + + [Fact] + public async Task Get_AnOpenPublicLobby_IsVisibleToANonMember() + { + var lobby = LobbyTestFactory.Open(_host, T0, LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public)); + _repo.GetByIdAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.GetAsync(_stranger, lobby.Id); + + result.IsSuccess.Should().BeTrue(); + // A non-member gets no actions — visibility is not authorization. + result.Value!.AllowedActions.Should().BeEmpty(); + } + + [Fact] + public async Task Kick_ByANonMember_Returns404_NotForbidden() + { + var lobby = LobbyTestFactory.Open(_host, T0); + lobby.Join(_joiner, T0); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.KickAsync(_stranger, lobby.Id, new KickMemberRequestDto(_joiner, lobby.Revision)); + + result.Error!.Code.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task Kick_ByAMemberWhoIsNotTheHost_IsForbidden_BecauseTheyCanAlreadySeeTheLobby() + { + var lobby = LobbyTestFactory.Open(_host, T0); + lobby.Join(_joiner, T0); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.KickAsync(_joiner, lobby.Id, new KickMemberRequestDto(_host, lobby.Revision)); + + // 403 is correct here and 404 would be wrong: this caller is a seated member and already knows the lobby + // exists. Hiding it would tell them nothing they do not know and would only obscure the real reason. + result.Error!.Code.Should().Be(LobbyErrors.Forbidden); + } + + // ── Credential join: the oracle must stay shut ─────────────────────────── + + [Fact] + public async Task Join_WithAValidCode_SeatsTheCallerUnready() + { + var lobby = LobbyTestFactory.Open(_host, T0); + var credential = IssuedCredential(lobby.Id, "GOOD-CODE"); + _repo.FindActiveByCodeDigestAsync("hash:GOOD-CODE", Arg.Any()).Returns(credential); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto("GOOD-CODE", null)); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Seats.Should().HaveCount(2); + result.Value.Seats.Single(s => !s.IsHost).IsReady.Should().BeFalse(); + + await _throttle.Received(1).ClearAsync(_joiner, Arg.Any()); + await _throttle.DidNotReceive().RecordFailureAsync(Arg.Any(), Arg.Any()); + } + + /// + /// The credential-oracle test. A wrong code, an expired one, a rotated one, and a closed lobby must be + /// byte-for-byte the same answer — any difference is a probe an attacker can use to enumerate which + /// lobbies and codes are live. + /// + [Fact] + public async Task Join_WrongExpiredRotatedAndClosed_AllReturnTheIdenticalError() + { + // The three time-independent cases first: FakeTimeProvider refuses to rewind, so the expired case — which + // is the only one that needs the clock moved — has to run last. + + // 1. Unknown code — no credential row at all. + _repo.FindActiveByCodeDigestAsync("hash:WRONG", Arg.Any()) + .Returns((LobbyJoinCredential?)null); + var wrong = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto("WRONG", null)); + + // 2. Rotated credential — superseded by a newer generation. + var rotatedLobby = LobbyTestFactory.Open(_host, T0); + var rotatedCred = IssuedCredential(rotatedLobby.Id, "ROTATED"); + rotatedCred.MarkRotated(T0); + _repo.FindActiveByCodeDigestAsync("hash:ROTATED", Arg.Any()).Returns(rotatedCred); + var rotated = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto("ROTATED", null)); + + // 3. Correct, live credential — but the lobby behind it has closed. + var closedLobby = LobbyTestFactory.Open(_host, T0); + closedLobby.Close(LobbyClosedReason.HostClosed); + var closedCred = IssuedCredential(closedLobby.Id, "CLOSED"); + _repo.FindActiveByCodeDigestAsync("hash:CLOSED", Arg.Any()).Returns(closedCred); + _repo.GetForUpdateAsync(closedLobby.Id, Arg.Any()).Returns(closedLobby); + var closed = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto("CLOSED", null)); + + // 4. Expired credential — the row exists and is still Active, but its 30 minutes are up. + var expiredLobby = LobbyTestFactory.Open(_host, T0); + var expiredCred = IssuedCredential(expiredLobby.Id, "EXPIRED"); + _repo.FindActiveByCodeDigestAsync("hash:EXPIRED", Arg.Any()).Returns(expiredCred); + _repo.GetForUpdateAsync(expiredLobby.Id, Arg.Any()).Returns(expiredLobby); + _clock.SetUtcNow(T0 + LobbyJoinCredential.Lifetime + TimeSpan.FromSeconds(1)); + var expired = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto("EXPIRED", null)); + + foreach (var result in new[] { wrong, expired, rotated, closed }) + { + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(LobbyErrors.CredentialInvalid); + result.Error.Message.Should().Be(wrong.Error!.Message); + } + } + + [Fact] + public async Task Join_WithABlockedMemberInTheLobby_IsRefused() + { + var lobby = LobbyTestFactory.Open(_host, T0); + var credential = IssuedCredential(lobby.Id, "GOOD-CODE"); + _repo.FindActiveByCodeDigestAsync("hash:GOOD-CODE", Arg.Any()).Returns(credential); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + _repo.GetBlockedCounterpartsAsync(_joiner, Arg.Any>(), Arg.Any()) + .Returns(new[] { _host }); + + var result = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto("GOOD-CODE", null)); + + result.Error!.Code.Should().Be(LobbyErrors.Blocked); + } + + [Fact] + public async Task Join_AFullLobby_ReturnsFull() + { + var lobby = LobbyTestFactory.Open(_host, T0, LobbyTestFactory.Settings(maxPlayers: 2)); + lobby.Join(Guid.NewGuid(), T0); // the second and last seat + + var credential = IssuedCredential(lobby.Id, "GOOD-CODE"); + _repo.FindActiveByCodeDigestAsync("hash:GOOD-CODE", Arg.Any()).Returns(credential); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto("GOOD-CODE", null)); + + result.Error!.Code.Should().Be(LobbyErrors.Full); + } + + /// + /// Only a bad credential feeds the throttle. Counting an already-in-a-lobby rejection would let an unrelated + /// failure lock a legitimate user out of joining — and it would tell an attacker nothing anyway. + /// + [Fact] + public async Task Join_OnlyAnInvalidCredentialCountsTowardTheFailureThrottle() + { + _repo.GetActiveLobbyForUserAsync(_joiner, Arg.Any()) + .Returns(LobbyTestFactory.Open(_joiner, T0)); + + var lobby = LobbyTestFactory.Open(_host, T0); + var credential = IssuedCredential(lobby.Id, "GOOD-CODE"); + _repo.FindActiveByCodeDigestAsync("hash:GOOD-CODE", Arg.Any()).Returns(credential); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var alreadyActive = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto("GOOD-CODE", null)); + + alreadyActive.Error!.Code.Should().Be(LobbyErrors.AlreadyActive); + await _throttle.DidNotReceive().RecordFailureAsync(Arg.Any(), Arg.Any()); + + _repo.FindActiveByCodeDigestAsync("hash:NOPE", Arg.Any()) + .Returns((LobbyJoinCredential?)null); + await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto("NOPE", null)); + + await _throttle.Received(1).RecordFailureAsync(_joiner, Arg.Any()); + } + + [Fact] + public async Task Join_WhileThrottled_IsRejectedWithARetryAfter_BeforeAnyDigestWorkHappens() + { + var until = T0.AddMinutes(5); + _throttle.GetRetryAfterUtcAsync(_joiner, Arg.Any()).Returns(until); + + var result = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto("ANYTHING", null)); + + result.Error!.Code.Should().Be(LobbyErrors.RateLimitExceeded); + result.Error.RetryAfterUtc.Should().Be(until); + + // A throttled attacker must not even be able to time the comparison. + await _repo.DidNotReceive().FindActiveByCodeDigestAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Join_WithBothOrNeitherCredential_IsAValidationError() + { + var both = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto("CODE", "TOKEN")); + var neither = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto(null, null)); + + both.Error!.Code.Should().Be(LobbyErrors.ValidationFailed); + neither.Error!.Code.Should().Be(LobbyErrors.ValidationFailed); + } + + [Fact] + public async Task Join_WithLobbyIdAlongsideACode_IsAValidationError() + { + var result = await _sut.JoinByCredentialAsync( + _joiner, new JoinLobbyRequestDto("CODE", null, Guid.NewGuid())); + + result.Error!.Code.Should().Be(LobbyErrors.ValidationFailed); + } + + // ── Join by id: only a Public+Open lobby, and it bypasses the credential throttle ──────── + + [Fact] + public async Task JoinByLobbyId_ForAPublicOpenLobby_SeatsTheCallerUnready() + { + var lobby = LobbyTestFactory.Open(_host, T0, LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public)); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto(null, null, lobby.Id)); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Seats.Should().HaveCount(2); + result.Value.Seats.Single(s => !s.IsHost).IsReady.Should().BeFalse(); + + // A resource id is not a secret: it never touches the failed-credential throttle. + await _throttle.DidNotReceive().GetRetryAfterUtcAsync(Arg.Any(), Arg.Any()); + await _throttle.DidNotReceive().RecordFailureAsync(Arg.Any(), Arg.Any()); + await _throttle.DidNotReceive().ClearAsync(Arg.Any(), Arg.Any()); + } + + /// + /// A private lobby's id, a closed lobby's id, and an unknown id must be byte-for-byte the same answer as the + /// single-lobby read's privacy-safe 404 — this is not a second oracle alongside the credential one. + /// + [Fact] + public async Task JoinByLobbyId_ForAPrivateClosedOrUnknownLobby_IsTheIdenticalNotFound() + { + var privateLobby = LobbyTestFactory.Open(_host, T0); // default settings: Private + _repo.GetForUpdateAsync(privateLobby.Id, Arg.Any()).Returns(privateLobby); + var private_ = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto(null, null, privateLobby.Id)); + + var closedLobby = LobbyTestFactory.Open(_host, T0, LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public)); + closedLobby.Close(LobbyClosedReason.HostClosed); + _repo.GetForUpdateAsync(closedLobby.Id, Arg.Any()).Returns(closedLobby); + var closed = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto(null, null, closedLobby.Id)); + + var unknownId = Guid.NewGuid(); + _repo.GetForUpdateAsync(unknownId, Arg.Any()).Returns((Lobby?)null); + var unknown = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto(null, null, unknownId)); + + foreach (var result in new[] { private_, closed, unknown }) + { + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(LobbyErrors.NotFound); + result.Error.Message.Should().Be(private_.Error!.Message); + } + } + + // ── Honest deferral: Start cannot succeed, and does not pretend it can ─── + + [Fact] + public async Task Start_WhileNoMatchRuntimeIsRegistered_Returns503_AndLeavesTheLobbyOpen() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, T0, _joiner); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.StartAsync( + _host, lobby.Id, new StartLobbyRequestDto(lobby.Revision, "idem-1")); + + result.Error!.Code.Should().Be(LobbyErrors.MatchRuntimeUnavailable); + + // The two claims that actually matter: the lobby did not move, and no match request was committed. + lobby.State.Should().Be(LobbyState.Open); + await _repo.DidNotReceive().AddStartRequestAsync( + Arg.Any(), Arg.Any>(), Arg.Any()); + } + + /// + /// Ordering check. With zero engines installed, checking M5 before M8 would answer + /// Lobbies.CapabilityDisabled — blaming this lobby's configuration for a platform-wide absence of any + /// runtime. The honest answer is the one the brief promises and the E2E asserts. + /// + [Fact] + public async Task Start_WithNoEnginesInstalled_BlamesTheMissingRuntime_NotTheLobbysCapability() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, T0, _joiner); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + _engines.RegisteredDefinitions.Returns(Array.Empty()); + + var result = await _sut.StartAsync( + _host, lobby.Id, new StartLobbyRequestDto(lobby.Revision, "idem-1")); + + result.Error!.Code.Should().Be(LobbyErrors.MatchRuntimeUnavailable); + result.Error.Code.Should().NotBe(LobbyErrors.CapabilityDisabled); + } + + [Fact] + public async Task Start_ByAMemberWhoIsNotTheHost_IsForbidden() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, T0, _joiner); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.StartAsync( + _joiner, lobby.Id, new StartLobbyRequestDto(lobby.Revision, "idem-1")); + + result.Error!.Code.Should().Be(LobbyErrors.Forbidden); + } + + [Fact] + public async Task AllowedActions_OmitStart_WhileNoMatchRuntimeExists() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, T0, _joiner); + _repo.GetByIdAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.GetAsync(_host, lobby.Id); + + // Everyone is ready and the host is asking — the only reason start is absent is that M8 does not exist. + // This is what makes the frontend's disabled button honest rather than decorative. + result.Value!.AllowedActions.Should().NotContain(LobbyActions.Start); + result.Value.AllowedActions.Should().Contain(LobbyActions.Invite); + result.Value.DependencyReadiness.MatchRuntime.Should().BeFalse(); + result.Value.DependencyReadiness.Chat.Should().BeFalse(); + result.Value.DependencyReadiness.AiParticipants.Should().BeFalse(); + } + + [Fact] + public async Task AllowedActions_DoNotOfferReadyToTheHost_WhoIsImplicitlyReady() + { + var lobby = LobbyTestFactory.Open(_host, T0); + lobby.Join(_joiner, T0); + _repo.GetByIdAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var hostView = await _sut.GetAsync(_host, lobby.Id); + var memberView = await _sut.GetAsync(_joiner, lobby.Id); + + hostView.Value!.AllowedActions.Should().NotContain(LobbyActions.Ready); + memberView.Value!.AllowedActions.Should().Contain(LobbyActions.Ready); + memberView.Value.AllowedActions.Should().NotContain(LobbyActions.Settings); + } + + [Fact] + public async Task Rematch_Returns503_BecauseModule8OwnsMatchRecords() + { + var result = await _sut.CreateRematchLobbyAsync(_host, Guid.NewGuid()); + + result.Error!.Code.Should().Be(LobbyErrors.MatchRuntimeUnavailable); + await _repo.DidNotReceive().AddLobbyAsync( + Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any()); + } + + // ── Stale revision ─────────────────────────────────────────────────────── + + [Fact] + public async Task SetReadiness_WithAStaleRevision_IsATypedConflictCarryingTheCurrentRevision() + { + var lobby = LobbyTestFactory.Open(_host, T0); + lobby.Join(_joiner, T0); // bumps Revision to 2 + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.SetReadinessAsync( + _joiner, lobby.Id, new SetReadinessRequestDto(true, ExpectedRevision: 1)); + + result.Error!.Code.Should().Be(LobbyErrors.StaleRevision); + result.Error.Message.Should().Contain(lobby.Revision.ToString()); + } + + // ── Invites (R6) ───────────────────────────────────────────────────────── + + [Fact] + public async Task AcceptInvite_SeatsTheInvitee() + { + var lobby = LobbyTestFactory.Open(_host, T0); + var invite = LobbyInvite.Create(lobby.Id, _host, _joiner, T0); + _repo.GetInviteForUpdateAsync(invite.Id, Arg.Any()).Returns(invite); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.AcceptInviteAsync(_joiner, invite.Id); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Seats.Should().HaveCount(2); + invite.State.Should().Be(LobbyInviteState.Accepted); + } + + /// An invite is permission to try, never a bypass of the lobby's own rules. + [Fact] + public async Task AcceptInvite_StillEnforcesBlocksAndTheOneActiveLobbyRule() + { + var lobby = LobbyTestFactory.Open(_host, T0); + var invite = LobbyInvite.Create(lobby.Id, _host, _joiner, T0); + _repo.GetInviteForUpdateAsync(invite.Id, Arg.Any()).Returns(invite); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + _repo.GetBlockedCounterpartsAsync(_joiner, Arg.Any>(), Arg.Any()) + .Returns(new[] { _host }); + + var blockedResult = await _sut.AcceptInviteAsync(_joiner, invite.Id); + blockedResult.Error!.Code.Should().Be(LobbyErrors.Blocked); + + _repo.GetBlockedCounterpartsAsync(_joiner, Arg.Any>(), Arg.Any()) + .Returns(Array.Empty()); + _repo.GetActiveTicketIdForUserAsync(_joiner, Arg.Any()).Returns(Guid.NewGuid()); + + var freshInvite = LobbyInvite.Create(lobby.Id, _host, _joiner, T0); + _repo.GetInviteForUpdateAsync(freshInvite.Id, Arg.Any()).Returns(freshInvite); + + var queuedResult = await _sut.AcceptInviteAsync(_joiner, freshInvite.Id); + queuedResult.Error!.Code.Should().Be(LobbyErrors.AlreadyActive); + } + + [Fact] + public async Task AcceptInvite_AnotherUsersInviteId_IsAPrivacySafeNotFound() + { + var lobby = LobbyTestFactory.Open(_host, T0); + var invite = LobbyInvite.Create(lobby.Id, _host, _joiner, T0); + _repo.GetInviteForUpdateAsync(invite.Id, Arg.Any()).Returns(invite); + + var result = await _sut.AcceptInviteAsync(_stranger, invite.Id); + + result.Error!.Code.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task AcceptInvite_AfterTheThirtyMinuteDeadline_IsExpired() + { + var lobby = LobbyTestFactory.Open(_host, T0); + var invite = LobbyInvite.Create(lobby.Id, _host, _joiner, T0); + _repo.GetInviteForUpdateAsync(invite.Id, Arg.Any()).Returns(invite); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + _clock.SetUtcNow(T0 + LobbyInvite.Lifetime); + + var result = await _sut.AcceptInviteAsync(_joiner, invite.Id); + + result.Error!.Code.Should().Be(LobbyErrors.Expired); + } + + [Fact] + public async Task CreateInvite_ToANonFriend_IsRefused_SoAPrivateLobbyCannotBeRevealed() + { + var lobby = LobbyTestFactory.Open(_host, T0, LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + _repo.AreFriendsAsync(_host, _stranger, Arg.Any()).Returns(false); + + var result = await _sut.CreateInviteAsync(_host, lobby.Id, new CreateInviteRequestDto(_stranger)); + + result.Error!.Code.Should().Be(LobbyErrors.InvalidTarget); + await _repo.DidNotReceive().AddInviteAsync( + Arg.Any(), Arg.Any>(), Arg.Any()); + } + + // ── Credential rotation ────────────────────────────────────────────────── + + [Fact] + public async Task RotateCredential_SupersedesTheOldValueAndBumpsTheGeneration() + { + var lobby = LobbyTestFactory.Open(_host, T0); + var outgoing = IssuedCredential(lobby.Id, "OLD-CODE"); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + _repo.GetActiveCredentialAsync(lobby.Id, Arg.Any()).Returns(outgoing); + + var result = await _sut.RotateCredentialAsync(_host, lobby.Id); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Generation.Should().Be(2); + result.Value.Code.Should().NotBe("OLD-CODE"); + + // The old row is dead the instant this commits — there is no window in which both codes work. + outgoing.State.Should().Be(LobbyCredentialState.Rotated); + await _repo.Received(1).RotateCredentialAsync( + Arg.Is(c => c.State == LobbyCredentialState.Rotated), + Arg.Is(c => c.Generation == 2 && c.State == LobbyCredentialState.Active), + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task RotateCredential_ByANonHostMember_IsForbidden() + { + var lobby = LobbyTestFactory.Open(_host, T0); + lobby.Join(_joiner, T0); + _repo.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + var result = await _sut.RotateCredentialAsync(_joiner, lobby.Id); + + result.Error!.Code.Should().Be(LobbyErrors.Forbidden); + } + + // ── Capability profile read ────────────────────────────────────────────── + + [Fact] + public async Task GetCapabilityProfile_ForAnActiveSlug_ReturnsThePinnedVersionSource() + { + _repo.GetActiveCapabilityProfileAsync("chess-lite", Arg.Any()).Returns(Profile()); + + var result = await _sut.GetCapabilityProfileAsync("chess-lite"); + + result.IsSuccess.Should().BeTrue(); + result.Value!.GameSlug.Should().Be("chess-lite"); + result.Value.CapabilityVersion.Should().Be(1); + result.Value.AllowedModes.Should().Contain("multiplayer"); + result.Value.TimeControls.Should().Contain("blitz-3-2"); + } + + [Fact] + public async Task GetCapabilityProfile_ForASlugWithNoActiveProfile_IsAPlain404_NotAPrivacyOracle() + { + // Unlike a lobby id, a game slug is public catalog data — there is nothing to leak by naming the code. + _repo.GetActiveCapabilityProfileAsync("unknown-slug", Arg.Any()) + .Returns((GameCapabilityProfile?)null); + + var result = await _sut.GetCapabilityProfileAsync("unknown-slug"); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(LobbyErrors.CapabilityNotFound); + } + + // ── Discovery ──────────────────────────────────────────────────────────── + + [Fact] + public async Task GetPublic_HidesFullExpiredAndBlockedLobbies_WithoutDisturbingTheCursor() + { + var openLobby = LobbyTestFactory.Open(_host, T0, LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public)); + + var fullHost = Guid.NewGuid(); + var fullLobby = LobbyTestFactory.Open( + fullHost, T0, LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public, maxPlayers: 2)); + fullLobby.Join(Guid.NewGuid(), T0); + + var blockedHost = Guid.NewGuid(); + var blockedLobby = LobbyTestFactory.Open( + blockedHost, T0, LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public)); + + _repo.GetPublicPageAsync(3, null, null, Arg.Any()) + .Returns(new[] { openLobby, fullLobby, blockedLobby }); + _repo.GetBlockedCounterpartsAsync(_stranger, Arg.Any>(), Arg.Any()) + .Returns(new[] { blockedHost }); + + var result = await _sut.GetPublicAsync(_stranger, limit: 3, cursor: null); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Items.Should().ContainSingle().Which.LobbyId.Should().Be(openLobby.Id); + + // The page is short, and that is correct. The cursor still advances past every row the *query* saw, so a + // filtered-out trailing row can never wedge pagination in place. + result.Value.NextCursor.Should().NotBeNull(); + } + + [Fact] + public async Task GetPublic_WithAForgedCursor_IsARejectedRequest_NotASilentRestart() + { + var result = await _sut.GetPublicAsync(_stranger, limit: 20, cursor: "not-a-real-cursor!!"); + + result.Error!.Code.Should().Be(LobbyErrors.InvalidCursor); + } + + // ── Fixtures ───────────────────────────────────────────────────────────── + + private static CreateLobbyRequestDto CreateRequest() => new( + GameSlug: "chess-lite", + CapabilityVersion: 1, + Privacy: "Private", + MaxPlayers: 4, + TimeControlId: "blitz-3-2", + Rated: false, + Region: "eu-west", + SpectatorPolicy: "Anyone", + TieBreakRuleId: "none", + AiFillRequested: false); + + private static GameCapabilityProfile Profile() => GameCapabilityProfile.Create( + "chess-lite", 1, minPlayers: 2, maxPlayers: 4, + allowedModes: new[] { "multiplayer", "cooperative" }, + timeControls: new[] { "blitz-3-2", "rapid-10-0", "untimed" }, + tieBreakRules: new[] { "none", "sudden-death" }, + spectatorPolicies: new[] { "Anyone", "FriendsOnly", "Disabled" }, + ratedEligible: false, aiFillEligible: false, + manifestVersion: "2026.1"); + + private static Game AvailableGame() => GameWithLifecycle(GameLifecycle.Available); + + private static Game GameWithLifecycle(GameLifecycle lifecycle) => Game.Create( + slug: "chess-lite", name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: GameDifficulty.Medium, + estimatedDurationMinMinutes: 10, estimatedDurationMaxMinutes: 20, + minPlayers: 2, maxPlayers: 4, initialLifecycle: lifecycle, + featuredRank: null, sortOrder: 1, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", + // Not "strategy" — M4's Game rejects a tag that duplicates the category. + tags: new[] { "classic", "logic" }, + modes: new[] { "multiplayer", "cooperative" }); + + private LobbyJoinCredential IssuedCredential(Guid lobbyId, string plaintextCode) => + LobbyJoinCredential.Issue( + lobbyId, _hasher.HashCode(plaintextCode), _hasher.HashLinkToken(plaintextCode + "-link"), 1, T0); + + private static User MakeUser(Guid id) + { + var user = User.Create($"user{id:N}"[..12], $"{id:N}@example.com", "hash", "Test Player"); + typeof(SimPle.Domain.Common.Entity) + .GetProperty(nameof(SimPle.Domain.Common.Entity.Id))! + .SetValue(user, id); + return user; + } + + private static IReadOnlyDictionary Roster(IReadOnlyList ids) => + ids.ToDictionary(id => id, MakeUser); + + /// + /// Runs the command exactly once. The real runner's retry, advisory lock, and transaction only mean anything + /// against a database that enforces indexes and row versions, so they are proven against real PostgreSQL — a + /// fake that "simulated" contention here would only prove the fake works. + /// + private sealed class PassThroughRunner : ILobbyCommandRunner + { + public Task> RunAsync( + Guid actorUserId, Func>> command, CancellationToken ct = default) => + command(ct); + } +} diff --git a/tests/SimPle.UnitTests/Lobbies/LobbyCredentialTests.cs b/tests/SimPle.UnitTests/Lobbies/LobbyCredentialTests.cs new file mode 100644 index 0000000..ef200cd --- /dev/null +++ b/tests/SimPle.UnitTests/Lobbies/LobbyCredentialTests.cs @@ -0,0 +1,293 @@ +using FluentAssertions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using SimPle.Application.Common.Options; +using SimPle.Domain.Lobbies; +using SimPle.Infrastructure.Lobbies; + +namespace SimPle.UnitTests.Lobbies; + +/// +/// Join-credential construction, expiry, and rotation (brief Risk #7): keyed digests, plaintext revealed only at +/// creation/rotation, rotation invalidates the old value immediately, and using a credential never extends its +/// deadline. +/// +public class LobbyCredentialTests +{ + private readonly FakeTimeProvider _clock = new(LobbyTestFactory.T0); + private DateTime Now => _clock.GetUtcNow().UtcDateTime; + + private static LobbyJoinCredential Issue(DateTime nowUtc, int generation = 1) => + LobbyJoinCredential.Issue(Guid.NewGuid(), "code-digest", "link-digest", generation, nowUtc); + + // ── The 30-minute expiry boundary ──────────────────────────────────────── + + [Fact] + public void ACredentialExpiresExactlyThirtyMinutesAfterIssue() + { + var credential = Issue(Now); + + credential.ExpiresAtUtc.Should().Be(LobbyTestFactory.T0.AddMinutes(30)); + } + + [Fact] + public void JustBeforeThirtyMinutes_TheCredentialIsStillRedeemable() + { + var credential = Issue(Now); + _clock.Advance(TimeSpan.FromMinutes(30) - TimeSpan.FromMilliseconds(1)); + + credential.CanRedeem(Now).Should().BeTrue(); + } + + [Fact] + public void ExactlyAtThirtyMinutes_TheCredentialIsNoLongerRedeemable() + { + var credential = Issue(Now); + _clock.Advance(TimeSpan.FromMinutes(30)); + + credential.IsExpired(Now).Should().BeTrue(); + credential.CanRedeem(Now).Should().BeFalse(); + } + + [Fact] + public void RedeemingACredentialDoesNotExtendItsDeadline() + { + // "Using one does not extend either deadline" — a credential is consumed, never refreshed. If redeeming + // slid the deadline forward, a busy lobby's code would effectively never expire. + var credential = Issue(Now); + var originalDeadline = credential.ExpiresAtUtc; + + _clock.Advance(TimeSpan.FromMinutes(20)); + credential.CanRedeem(Now).Should().BeTrue(); + + credential.ExpiresAtUtc.Should().Be(originalDeadline); + + _clock.Advance(TimeSpan.FromMinutes(10)); + credential.CanRedeem(Now).Should().BeFalse("the original 30-minute deadline has now passed"); + } + + // ── Rotation ───────────────────────────────────────────────────────────── + + [Fact] + public void RotationKillsTheOldCredentialImmediately() + { + var credential = Issue(Now); + credential.CanRedeem(Now).Should().BeTrue(); + + credential.MarkRotated(Now); + + credential.State.Should().Be(LobbyCredentialState.Rotated); + credential.CanRedeem(Now).Should().BeFalse("the old value is dead the instant it is replaced"); + credential.SupersededAtUtc.Should().Be(Now); + } + + [Fact] + public void RevocationKillsTheCredentialImmediately() + { + var credential = Issue(Now); + + credential.Revoke(Now); + + credential.State.Should().Be(LobbyCredentialState.Revoked); + credential.CanRedeem(Now).Should().BeFalse(); + } + + [Fact] + public void RotationAndRevocationAreIdempotent() + { + var credential = Issue(Now); + credential.MarkRotated(Now); + var supersededAt = credential.SupersededAtUtc; + + _clock.Advance(TimeSpan.FromMinutes(1)); + credential.MarkRotated(Now); + credential.Revoke(Now); + + credential.State.Should().Be(LobbyCredentialState.Rotated, "the first terminal transition wins"); + credential.SupersededAtUtc.Should().Be(supersededAt); + } + + [Fact] + public void ACredentialCannotBeIssuedWithoutDigests() + { + var act = () => LobbyJoinCredential.Issue(Guid.NewGuid(), "", "link", 1, Now); + + act.Should().Throw(); + } +} + +/// Plaintext generation: entropy, alphabet safety, and input normalization. +public class LobbyCredentialFormatTests +{ + [Fact] + public void TheManualCodeCarriesAtLeastSixtyBitsOfEntropy() + { + // The brief's floor. 12 symbols from a 32-symbol alphabet is exactly 5 bits each. + LobbyCredentialFormat.CodeEntropyBits.Should().BeGreaterThanOrEqualTo(60); + } + + [Fact] + public void TheAlphabetExcludesTheCharactersPeopleMisreadForEachOther() + { + LobbyCredentialFormat.Alphabet.Should().NotContain("0"); + LobbyCredentialFormat.Alphabet.Should().NotContain("O"); + LobbyCredentialFormat.Alphabet.Should().NotContain("1"); + LobbyCredentialFormat.Alphabet.Should().NotContain("I"); + } + + [Fact] + public void TheAlphabetIsExactlyThirtyTwoSymbols() + { + // Not cosmetic: 256 is an exact multiple of 32, which is what makes the `byte % 32` mapping in NewCode() + // uniform. A 31- or 33-symbol alphabet would silently introduce modulo bias and cost real entropy. + LobbyCredentialFormat.Alphabet.Should().HaveLength(32); + LobbyCredentialFormat.Alphabet.Distinct().Should().HaveCount(32, "a repeated symbol would also bias the draw"); + } + + [Fact] + public void AGeneratedCodeUsesOnlyAlphabetSymbolsAndGroupSeparators() + { + var code = LobbyCredentialFormat.NewCode(); + + code.Should().MatchRegex("^[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$"); + code.Replace("-", "").ToCharArray().Should() + .OnlyContain(c => LobbyCredentialFormat.Alphabet.Contains(c)); + } + + [Fact] + public void GeneratedCodesDoNotRepeat() + { + // A weak smoke test for the RNG: 500 draws from a 60-bit space must not collide. A constant or + // low-entropy generator would fail this instantly. + var codes = Enumerable.Range(0, 500).Select(_ => LobbyCredentialFormat.NewCode()).ToList(); + + codes.Distinct().Should().HaveCount(codes.Count); + } + + [Fact] + public void GeneratedLinkTokensAreDistinctAndUrlSafe() + { + var tokens = Enumerable.Range(0, 200).Select(_ => LobbyCredentialFormat.NewLinkToken()).ToList(); + + tokens.Distinct().Should().HaveCount(tokens.Count); + tokens.Should().OnlyContain(t => t.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_')); + } + + [Theory] + [InlineData("K7M2-9QRB-XTFH")] + [InlineData("k7m2-9qrb-xtfh")] + [InlineData("K7M29QRBXTFH")] + [InlineData("k7m2 9qrb xtfh")] + [InlineData(" K7M2-9QRB-XTFH ")] + public void NormalizationMakesCasingAndSeparatorsIrrelevant(string typed) + { + // A user reading a code off a screen will type it however they like. All of these must hash to the same + // digest as the issued value, or the code appears broken for no reason the user can see. + LobbyCredentialFormat.NormalizeCode(typed).Should().Be("K7M29QRBXTFH"); + } +} + +/// The keyed-digest boundary: keyed, deterministic, and constant-time. +public class HmacLobbyCredentialHasherTests +{ + private static HmacLobbyCredentialHasher Hasher(string key = "test-key-at-least-32-characters-long!!") => + new(Options.Create(new LobbyCredentialOptions { Key = key })); + + [Fact] + public void HashingIsDeterministicForTheSameKeyAndInput() + { + var hasher = Hasher(); + + hasher.HashCode("K7M2-9QRB-XTFH").Should().Be(hasher.HashCode("K7M2-9QRB-XTFH")); + } + + [Fact] + public void TheDigestDependsOnTheServerKey() + { + // This is the whole point of keying: with a bare hash, anyone holding the database could exhaust a 60-bit + // code offline. Two different keys must produce different digests for the same code. + var a = Hasher("key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + var b = Hasher("key-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + + a.HashCode("K7M2-9QRB-XTFH").Should().NotBe(b.HashCode("K7M2-9QRB-XTFH")); + } + + [Fact] + public void TheDigestNeverContainsThePlaintext() + { + var hasher = Hasher(); + const string code = "K7M2-9QRB-XTFH"; + + var digest = hasher.HashCode(code); + + digest.Should().NotContain("K7M2"); + digest.Should().MatchRegex("^[0-9a-f]{64}$", "a hex-encoded SHA-256 HMAC"); + } + + [Fact] + public void EquivalentlyTypedCodesProduceTheSameDigest() + { + var hasher = Hasher(); + + hasher.HashCode("k7m2 9qrb xtfh").Should().Be(hasher.HashCode("K7M2-9QRB-XTFH")); + } + + [Fact] + public void ADifferentCodeProducesADifferentDigest() + { + var hasher = Hasher(); + + hasher.HashCode("K7M2-9QRB-XTFH").Should().NotBe(hasher.HashCode("K7M2-9QRB-XTFJ")); + } + + [Fact] + public void CodeAndLinkTokenDigestsAreComputedOverDifferentInputs() + { + // The link token is not normalized (it is machine-generated and exact); the code is. Feeding the same + // string through both must therefore not collide by construction. + var hasher = Hasher(); + + hasher.HashLinkToken("abc-def").Should().NotBe(hasher.HashCode("abc-def")); + } + + [Fact] + public void MatchingDigestsCompareEqual() + { + var hasher = Hasher(); + var digest = hasher.HashCode("K7M2-9QRB-XTFH"); + + hasher.DigestsMatch(digest, hasher.HashCode("K7M2-9QRB-XTFH")).Should().BeTrue(); + } + + [Fact] + public void NonMatchingDigestsCompareUnequal() + { + var hasher = Hasher(); + + hasher.DigestsMatch(hasher.HashCode("K7M2-9QRB-XTFH"), hasher.HashCode("XXXX-XXXX-XXXX")) + .Should().BeFalse(); + } + + [Fact] + public void AMalformedDigestFailsClosedRatherThanThrowing() + { + // A truncated or non-hex stored value must be a clean "no match", not a 500 that tells an attacker they + // found a row with a corrupt digest. + var hasher = Hasher(); + var valid = hasher.HashCode("K7M2-9QRB-XTFH"); + + hasher.DigestsMatch("not-hex", valid).Should().BeFalse(); + hasher.DigestsMatch(valid, "abcd").Should().BeFalse(); + hasher.DigestsMatch("", valid).Should().BeFalse(); + } + + [Fact] + public void AnUnconfiguredKeyFailsClosedAtConstruction() + { + // No dev fallback: silently degrading to an unkeyed digest would make every code in the database + // offline-guessable, and nothing would look wrong. + var act = () => new HmacLobbyCredentialHasher(Options.Create(new LobbyCredentialOptions { Key = "" })); + + act.Should().Throw().WithMessage("*LobbyCredential:Key*"); + } +} diff --git a/tests/SimPle.UnitTests/Lobbies/LobbyHostTransferTests.cs b/tests/SimPle.UnitTests/Lobbies/LobbyHostTransferTests.cs new file mode 100644 index 0000000..a65b1e6 --- /dev/null +++ b/tests/SimPle.UnitTests/Lobbies/LobbyHostTransferTests.cs @@ -0,0 +1,182 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SimPle.Domain.Lobbies; + +namespace SimPle.UnitTests.Lobbies; + +/// +/// Deterministic host transfer (brief Risk #3): "transfer to the longest-tenured eligible joined human, tie-broken +/// by user id; the lobby closes only when none remains." +/// +/// This is fixed policy, not an open question — an ambiguous ordering would let two clients compute different hosts +/// from the same roster and disagree about who may start the match. The tie-break test is the important one: it is +/// the only thing standing between "deterministic" and "whatever order the list happened to be in". +/// +public class LobbyHostTransferTests +{ + private readonly FakeTimeProvider _clock = new(LobbyTestFactory.T0); + private DateTime Now => _clock.GetUtcNow().UtcDateTime; + + private readonly Guid _host = Guid.NewGuid(); + + [Fact] + public void WhenTheHostLeaves_TheLongestTenuredMemberBecomesHost() + { + var lobby = LobbyTestFactory.Open(_host, Now); + + var early = Guid.NewGuid(); + lobby.Join(early, Now); + + _clock.Advance(TimeSpan.FromMinutes(5)); + var late = Guid.NewGuid(); + lobby.Join(late, Now); + + var result = lobby.Leave(_host, Now); + + result.Outcome.Should().Be(LobbyOutcome.Ok); + result.NewHostUserId.Should().Be(early, "tenure is measured by JoinedAtUtc, and 'early' joined 5 minutes sooner"); + lobby.HostUserId.Should().Be(early); + result.ClosedReason.Should().BeNull(); + } + + [Fact] + public void WhenTwoMembersShareTheSameJoinInstant_TheLowerUserIdWinsTheTieBreak() + { + // Two joins inside the same clock tick is not exotic — it is what a batch invite-accept looks like. Without + // the user-id tie-break the winner would depend on list order, and two clients could disagree. + var lobby = LobbyTestFactory.Open(_host, Now); + + var lower = new Guid("00000000-0000-0000-0000-00000000000a"); + var higher = new Guid("00000000-0000-0000-0000-00000000000b"); + + // Join the HIGHER id first, so insertion order and the correct answer disagree. + lobby.Join(higher, Now); + lobby.Join(lower, Now); + + var result = lobby.Leave(_host, Now); + + result.NewHostUserId.Should().Be(lower, "identical tenure is tie-broken by the lower user id, not by join order"); + lobby.HostUserId.Should().Be(lower); + } + + [Fact] + public void TheNewHostBecomesImplicitlyReady() + { + var lobby = LobbyTestFactory.Open(_host, Now); + var successor = Guid.NewGuid(); + lobby.Join(successor, Now); + + lobby.FindJoinedMember(successor)!.IsReady.Should().BeFalse("a joiner starts un-ready"); + + lobby.Leave(_host, Now); + + lobby.FindJoinedMember(successor)!.IsReady.Should() + .BeTrue("the host is implicitly ready, and that must hold for a host who arrived by transfer"); + } + + [Fact] + public void AMemberWhoAlreadyLeftIsNotEligibleToInheritTheHost() + { + var lobby = LobbyTestFactory.Open(_host, Now); + + var departed = Guid.NewGuid(); + lobby.Join(departed, Now); + + _clock.Advance(TimeSpan.FromMinutes(1)); + var stayed = Guid.NewGuid(); + lobby.Join(stayed, Now); + + lobby.Leave(departed, Now); // the longest-tenured member leaves first + + var result = lobby.Leave(_host, Now); + + result.NewHostUserId.Should().Be(stayed, "only a *joined* member is eligible"); + } + + [Fact] + public void AKickedMemberIsNotEligibleToInheritTheHost() + { + var lobby = LobbyTestFactory.Open(_host, Now); + + var kicked = Guid.NewGuid(); + lobby.Join(kicked, Now); + + _clock.Advance(TimeSpan.FromMinutes(1)); + var stayed = Guid.NewGuid(); + lobby.Join(stayed, Now); + + lobby.Kick(_host, kicked, Now); + + var result = lobby.Leave(_host, Now); + + result.NewHostUserId.Should().Be(stayed); + } + + [Fact] + public void WhenTheLastMemberLeaves_TheLobbyClosesWithAnAuditableReason() + { + var lobby = LobbyTestFactory.Open(_host, Now); + + var result = lobby.Leave(_host, Now); + + result.Outcome.Should().Be(LobbyOutcome.Ok); + result.NewHostUserId.Should().BeNull(); + result.ClosedReason.Should().Be(LobbyClosedReason.NoEligibleHost); + lobby.State.Should().Be(LobbyState.Closed); + lobby.ClosedReason.Should().Be(LobbyClosedReason.NoEligibleHost); + } + + [Fact] + public void ANonHostLeaveDoesNotTransferTheHost() + { + var lobby = LobbyTestFactory.Open(_host, Now); + var member = Guid.NewGuid(); + lobby.Join(member, Now); + + var result = lobby.Leave(member, Now); + + result.NewHostUserId.Should().BeNull(); + lobby.HostUserId.Should().Be(_host); + lobby.State.Should().Be(LobbyState.Open); + } + + [Fact] + public void TheHostCannotKickThemselves() + { + var lobby = LobbyTestFactory.Open(_host, Now); + lobby.Join(Guid.NewGuid(), Now); + + var outcome = lobby.Kick(_host, _host, Now); + + outcome.Should().Be(LobbyOutcome.InvalidTarget, "the host leaves; they do not kick themselves"); + lobby.FindJoinedMember(_host).Should().NotBeNull(); + } + + [Fact] + public void ANonHostCannotKick() + { + var lobby = LobbyTestFactory.Open(_host, Now); + var alice = Guid.NewGuid(); + var bob = Guid.NewGuid(); + lobby.Join(alice, Now); + lobby.Join(bob, Now); + + var outcome = lobby.Kick(alice, bob, Now); + + outcome.Should().Be(LobbyOutcome.Forbidden); + lobby.FindJoinedMember(bob).Should().NotBeNull(); + } + + [Fact] + public void ANonMemberGetsTheePrivacySafeNotMemberOutcome_NotForbidden() + { + // A stranger must not be able to tell a host-only action apart from a lobby that does not exist: Forbidden + // would confirm the lobby id is real. The service maps NotMember to the privacy-safe not-found. + var lobby = LobbyTestFactory.Open(_host, Now); + var stranger = Guid.NewGuid(); + + lobby.Kick(stranger, _host, Now).Should().Be(LobbyOutcome.NotMember); + lobby.ChangeSettings(stranger, LobbyTestFactory.Settings(), Now).Should().Be(LobbyOutcome.NotMember); + lobby.BeginStarting(stranger, Now).Should().Be(LobbyOutcome.NotMember); + } +} diff --git a/tests/SimPle.UnitTests/Lobbies/LobbyInviteTests.cs b/tests/SimPle.UnitTests/Lobbies/LobbyInviteTests.cs new file mode 100644 index 0000000..2332b80 --- /dev/null +++ b/tests/SimPle.UnitTests/Lobbies/LobbyInviteTests.cs @@ -0,0 +1,198 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SimPle.Domain.Lobbies; + +namespace SimPle.UnitTests.Lobbies; + +/// +/// Invite lifecycle (Pending -> Accepted|Revoked|Expired) and its 30-minute expiry boundary. +/// An invite is not a membership: it grants the *option* to join, and accepting is what creates the seat. +/// +public class LobbyInviteTests +{ + private readonly FakeTimeProvider _clock = new(LobbyTestFactory.T0); + private DateTime Now => _clock.GetUtcNow().UtcDateTime; + + private readonly Guid _lobby = Guid.NewGuid(); + private readonly Guid _inviter = Guid.NewGuid(); + private readonly Guid _invitee = Guid.NewGuid(); + + private LobbyInvite Pending() => LobbyInvite.Create(_lobby, _inviter, _invitee, Now); + + [Fact] + public void AnInviteExpiresExactlyThirtyMinutesAfterItIsSent() + { + var invite = Pending(); + + invite.ExpiresAtUtc.Should().Be(LobbyTestFactory.T0.AddMinutes(30)); + } + + [Fact] + public void JustBeforeThirtyMinutes_TheInviteIsStillAcceptable() + { + var invite = Pending(); + _clock.Advance(TimeSpan.FromMinutes(30) - TimeSpan.FromMilliseconds(1)); + + invite.CanAccept(Now).Should().BeTrue(); + invite.Accept(Now).Should().Be(LobbyOutcome.Ok); + invite.State.Should().Be(LobbyInviteState.Accepted); + } + + [Fact] + public void ExactlyAtThirtyMinutes_TheInviteCanNoLongerBeAccepted() + { + var invite = Pending(); + _clock.Advance(TimeSpan.FromMinutes(30)); + + invite.CanAccept(Now).Should().BeFalse(); + invite.Accept(Now).Should().Be(LobbyOutcome.Expired); + invite.State.Should().Be(LobbyInviteState.Pending, "a failed accept does not itself resolve the invite"); + } + + [Fact] + public void ARevokedInviteCannotBeAccepted() + { + var invite = Pending(); + invite.Revoke(Now).Should().Be(LobbyOutcome.Ok); + + invite.Accept(Now).Should().Be(LobbyOutcome.Closed); + invite.State.Should().Be(LobbyInviteState.Revoked); + } + + [Fact] + public void AnAcceptedInviteCannotBeAcceptedTwice() + { + // Otherwise a replayed accept would try to create a second seat for the same user. + var invite = Pending(); + invite.Accept(Now).Should().Be(LobbyOutcome.Ok); + + invite.Accept(Now).Should().Be(LobbyOutcome.Closed); + } + + [Fact] + public void AnAcceptedInviteCannotBeRevoked() + { + var invite = Pending(); + invite.Accept(Now); + + invite.Revoke(Now).Should().Be(LobbyOutcome.Closed); + invite.State.Should().Be(LobbyInviteState.Accepted); + } + + [Fact] + public void TheExpirySweepIsIdempotent() + { + var invite = Pending(); + _clock.Advance(TimeSpan.FromMinutes(30)); + + invite.TryExpire(Now).Should().BeTrue(); + invite.State.Should().Be(LobbyInviteState.Expired); + + invite.TryExpire(Now).Should().BeFalse(); + } + + [Fact] + public void TheExpirySweepDoesNotResolveAnInviteThatIsNotYetDue() + { + var invite = Pending(); + _clock.Advance(TimeSpan.FromMinutes(29)); + + invite.TryExpire(Now).Should().BeFalse(); + invite.State.Should().Be(LobbyInviteState.Pending); + } + + [Fact] + public void TheExpirySweepDoesNotTouchAnAlreadyAcceptedInvite() + { + var invite = Pending(); + invite.Accept(Now); + + _clock.Advance(TimeSpan.FromMinutes(30)); + + invite.TryExpire(Now).Should().BeFalse(); + invite.State.Should().Be(LobbyInviteState.Accepted); + } + + [Fact] + public void AUserCannotInviteThemselves() + { + var act = () => LobbyInvite.Create(_lobby, _inviter, _inviter, Now); + + act.Should().Throw(); + } +} + +/// +/// Start-request bookkeeping. A committed request means the outbox row is durable — never that a match exists +/// (brief Risk #6). +/// +public class LobbyStartRequestTests +{ + private static readonly DateTime T0 = LobbyTestFactory.T0; + + private static LobbyStartRequest Open(int revision = 1) => + LobbyStartRequest.Open(Guid.NewGuid(), revision, Guid.NewGuid(), "idem-key-1", Guid.NewGuid()); + + [Fact] + public void ANewRequestIsOpenAndCarriesItsRevision() + { + var request = Open(revision: 7); + + request.IsOpen.Should().BeTrue(); + request.LobbyRevision.Should().Be(7, "the one-open-request-per-revision index keys on this"); + request.ResolvedAtUtc.Should().BeNull(); + } + + [Fact] + public void AnOpenRequestCanSucceed() + { + var request = Open(); + + request.MarkSucceeded(T0).Should().Be(LobbyOutcome.Ok); + + request.State.Should().Be(LobbyStartRequestState.Succeeded); + request.ResolvedAtUtc.Should().Be(T0); + request.FailureReason.Should().BeNull(); + } + + [Fact] + public void AnOpenRequestCanFailWithAReason() + { + var request = Open(); + + request.MarkFailed("Match runtime unavailable.", T0).Should().Be(LobbyOutcome.Ok); + + request.State.Should().Be(LobbyStartRequestState.Failed); + request.FailureReason.Should().Be("Match runtime unavailable."); + request.ResolvedAtUtc.Should().Be(T0); + } + + [Fact] + public void AResolvedRequestCannotBeResolvedAgain() + { + // M8's responses are delivered at-least-once, so a duplicate MatchCreatedV1 must be a no-op, not a second + // state change. + var request = Open(); + request.MarkSucceeded(T0); + + request.MarkSucceeded(T0).Should().Be(LobbyOutcome.Closed); + request.MarkFailed("late failure", T0).Should().Be(LobbyOutcome.Closed); + request.State.Should().Be(LobbyStartRequestState.Succeeded, "the first resolution wins"); + } + + [Fact] + public void ARequestCannotBeOpenedWithoutAnIdempotencyKey() + { + var act = () => LobbyStartRequest.Open(Guid.NewGuid(), 1, Guid.NewGuid(), "", Guid.NewGuid()); + + act.Should().Throw(); + } + + [Fact] + public void ARequestCannotBeOpenedWithoutAMatchRequestId() + { + var act = () => LobbyStartRequest.Open(Guid.NewGuid(), 1, Guid.Empty, "idem", Guid.NewGuid()); + + act.Should().Throw(); + } +} diff --git a/tests/SimPle.UnitTests/Lobbies/LobbyLifecycleTests.cs b/tests/SimPle.UnitTests/Lobbies/LobbyLifecycleTests.cs new file mode 100644 index 0000000..b6b17e4 --- /dev/null +++ b/tests/SimPle.UnitTests/Lobbies/LobbyLifecycleTests.cs @@ -0,0 +1,280 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SimPle.Domain.Lobbies; + +namespace SimPle.UnitTests.Lobbies; + +/// +/// Lobby lifecycle, capacity, and the 2-hour expiry — the expiry boundary probed at, just below, and just above, +/// which only an injected clock can do (brief Risk #8). +/// +public class LobbyLifecycleTests +{ + private readonly FakeTimeProvider _clock = new(LobbyTestFactory.T0); + private DateTime Now => _clock.GetUtcNow().UtcDateTime; + + private readonly Guid _host = Guid.NewGuid(); + private readonly Guid _alice = Guid.NewGuid(); + + // ── The 2-hour expiry boundary ─────────────────────────────────────────── + + [Fact] + public void AnOpenLobbyExpiresExactlyTwoHoursAfterCreation() + { + var lobby = LobbyTestFactory.Open(_host, Now); + + lobby.ExpiresAtUtc.Should().Be(LobbyTestFactory.T0.AddHours(2)); + } + + [Fact] + public void JustBeforeTwoHours_TheLobbyIsStillJoinable() + { + var lobby = LobbyTestFactory.Open(_host, Now); + _clock.Advance(TimeSpan.FromHours(2) - TimeSpan.FromMilliseconds(1)); + + lobby.IsExpired(Now).Should().BeFalse(); + lobby.Join(_alice, Now).Should().Be(LobbyOutcome.Ok); + } + + [Fact] + public void ExactlyAtTwoHours_TheLobbyIsExpiredAndRejectsMutations() + { + var lobby = LobbyTestFactory.Open(_host, Now); + _clock.Advance(TimeSpan.FromHours(2)); + + lobby.IsExpired(Now).Should().BeTrue(); + lobby.Join(_alice, Now).Should().Be(LobbyOutcome.Expired); + lobby.SetReadiness(_host, true, Now).Should().Be(LobbyOutcome.Expired); + lobby.ChangeSettings(_host, LobbyTestFactory.Settings(), Now).Should().Be(LobbyOutcome.Expired); + lobby.BeginStarting(_host, Now).Should().Be(LobbyOutcome.Expired); + } + + [Fact] + public void TheExpirySweepIsIdempotent() + { + // The worker will re-run this over the same rows; a second call must be a no-op, not a second state change. + var lobby = LobbyTestFactory.Open(_host, Now); + _clock.Advance(TimeSpan.FromHours(2)); + + lobby.TryExpire(Now).Should().BeTrue(); + lobby.State.Should().Be(LobbyState.Expired); + lobby.ClosedReason.Should().Be(LobbyClosedReason.Expired); + var revisionAfterFirst = lobby.Revision; + + lobby.TryExpire(Now).Should().BeFalse("the lobby is already expired"); + lobby.Revision.Should().Be(revisionAfterFirst); + } + + [Fact] + public void TheExpirySweepDoesNotTouchALobbyThatIsNotYetDue() + { + var lobby = LobbyTestFactory.Open(_host, Now); + _clock.Advance(TimeSpan.FromHours(1)); + + lobby.TryExpire(Now).Should().BeFalse(); + lobby.State.Should().Be(LobbyState.Open); + } + + // ── Capacity ───────────────────────────────────────────────────────────── + + [Fact] + public void TheHostOccupiesASeatFromCreation() + { + var lobby = LobbyTestFactory.Open(_host, Now, LobbyTestFactory.Settings(maxPlayers: 2)); + + lobby.JoinedCount.Should().Be(1); + } + + [Fact] + public void AJoinIntoAFullLobbyIsRejected() + { + var lobby = LobbyTestFactory.Open(_host, Now, LobbyTestFactory.Settings(maxPlayers: 2)); + lobby.Join(_alice, Now).Should().Be(LobbyOutcome.Ok); // lobby is now 2/2 + + lobby.Join(Guid.NewGuid(), Now).Should().Be(LobbyOutcome.Full); + lobby.JoinedCount.Should().Be(2); + } + + [Fact] + public void ASeatFreedByALeaveCanBeTakenAgain() + { + var lobby = LobbyTestFactory.Open(_host, Now, LobbyTestFactory.Settings(maxPlayers: 2)); + lobby.Join(_alice, Now); + + lobby.Leave(_alice, Now); + + lobby.Join(Guid.NewGuid(), Now).Should().Be(LobbyOutcome.Ok); + } + + [Fact] + public void JoiningTwiceIsRejected() + { + var lobby = LobbyTestFactory.Open(_host, Now); + lobby.Join(_alice, Now); + + lobby.Join(_alice, Now).Should().Be(LobbyOutcome.AlreadyJoined); + lobby.JoinedCount.Should().Be(2); + } + + [Fact] + public void SettingsCannotShrinkMaxPlayersBelowTheSeatedRoster() + { + // Shrinking under the roster would strand seated members with no defined eviction rule. + var lobby = LobbyTestFactory.Open(_host, Now, LobbyTestFactory.Settings(maxPlayers: 4)); + lobby.Join(_alice, Now); + lobby.Join(Guid.NewGuid(), Now); // 3 seated + + var outcome = lobby.ChangeSettings(_host, LobbyTestFactory.Settings(maxPlayers: 2), Now); + + outcome.Should().Be(LobbyOutcome.Full); + lobby.MaxPlayers.Should().Be(4); + } + + // ── Start preconditions ────────────────────────────────────────────────── + + [Fact] + public void AStartRequiresEveryoneReady() + { + var lobby = LobbyTestFactory.Open(_host, Now); + lobby.Join(_alice, Now); // alice is not ready + + lobby.CanStart(Now).Should().BeFalse(); + lobby.BeginStarting(_host, Now).Should().Be(LobbyOutcome.NotStartable); + } + + [Fact] + public void AStartRequiresAtLeastTwoPlayers() + { + var lobby = LobbyTestFactory.Open(_host, Now); // host alone, implicitly ready + + lobby.IsEveryoneReady.Should().BeTrue("the host is the only member and is implicitly ready"); + lobby.CanStart(Now).Should().BeFalse("a solo lobby is not a match"); + } + + [Fact] + public void AReadyLobbyCanStart() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + + lobby.CanStart(Now).Should().BeTrue(); + lobby.BeginStarting(_host, Now).Should().Be(LobbyOutcome.Ok); + lobby.State.Should().Be(LobbyState.Starting); + } + + [Fact] + public void ARatedLobbyCannotStartWhileAiFillIsRequested() + { + // M9 does not exist, so a "rated" match with an unfillable AI seat would either hang or silently become + // unranked. Refusing to start is the honest option. + var settings = LobbyTestFactory.Settings(rated: true, aiFillRequested: true); + var lobby = LobbyTestFactory.Open(_host, Now, settings); + lobby.Join(_alice, Now); + lobby.SetReadiness(_alice, true, Now); + + lobby.IsEveryoneReady.Should().BeTrue(); + lobby.CanStart(Now).Should().BeFalse(); + lobby.BeginStarting(_host, Now).Should().Be(LobbyOutcome.NotStartable); + } + + [Fact] + public void OnlyTheHostMayStart() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + + lobby.BeginStarting(_alice, Now).Should().Be(LobbyOutcome.Forbidden); + lobby.State.Should().Be(LobbyState.Open); + } + + // ── Starting -> Started / back to Open ─────────────────────────────────── + + [Fact] + public void OnlyAStartingLobbyCanBecomeStarted() + { + // A committed outbox row is a durable *request*, not a created match (Risk #6). Reaching Started requires + // M8's MatchCreatedV1 — an Open lobby cannot jump straight there. + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + + lobby.MarkStarted().Should().Be(LobbyOutcome.NotStartable); + + lobby.BeginStarting(_host, Now).Should().Be(LobbyOutcome.Ok); + lobby.MarkStarted().Should().Be(LobbyOutcome.Ok); + lobby.State.Should().Be(LobbyState.Started); + } + + [Fact] + public void AStartedLobbyIsTerminalAndRejectsEveryMutation() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + lobby.BeginStarting(_host, Now); + lobby.MarkStarted(); + + lobby.Join(Guid.NewGuid(), Now).Should().Be(LobbyOutcome.Closed); + lobby.Leave(_alice, Now).Outcome.Should().Be(LobbyOutcome.Closed); + lobby.Kick(_host, _alice, Now).Should().Be(LobbyOutcome.Closed); + lobby.SetReadiness(_alice, false, Now).Should().Be(LobbyOutcome.Closed); + lobby.ChangeSettings(_host, LobbyTestFactory.Settings(), Now).Should().Be(LobbyOutcome.Closed); + } + + [Fact] + public void ARecoverableFailureReturnsTheLobbyToOpenAndPreservesReadinessByDefault() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + lobby.BeginStarting(_host, Now); + + lobby.ReturnToOpen(resetReadiness: false, Now).Should().Be(LobbyOutcome.Ok); + + lobby.State.Should().Be(LobbyState.Open); + lobby.FindJoinedMember(_alice)!.IsReady.Should().BeTrue(); + } + + [Fact] + public void AFailureThatIdentifiesStaleStateResetsReadiness() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + lobby.BeginStarting(_host, Now); + + lobby.ReturnToOpen(resetReadiness: true, Now).Should().Be(LobbyOutcome.Ok); + + lobby.State.Should().Be(LobbyState.Open); + lobby.FindJoinedMember(_alice)!.IsReady.Should().BeFalse(); + } + + [Fact] + public void ALobbyThatExpiredWhileStartingDoesNotSilentlyReopen() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + lobby.BeginStarting(_host, Now); + + _clock.Advance(TimeSpan.FromHours(2)); + + lobby.ReturnToOpen(resetReadiness: false, Now).Should().Be(LobbyOutcome.Expired); + lobby.State.Should().Be(LobbyState.Expired); + } + + // ── Settings validation ────────────────────────────────────────────────── + + [Fact] + public void ALobbyCannotBeCreatedWithAnUnresolvedRegion() + { + // "Auto" is a request-time input; persisting it would partition the matchmaking queue on a non-region. + var act = () => LobbyTestFactory.Open(_host, Now, LobbyTestFactory.Settings(resolvedRegion: "Auto")); + + act.Should().Throw().WithMessage("*Auto*"); + } + + [Fact] + public void ALobbyCannotBeCreatedWithAnUnknownTimeControl() + { + var act = () => LobbyTestFactory.Open(_host, Now, LobbyTestFactory.Settings(timeControlId: "hyperbullet-0-1")); + + act.Should().Throw().WithMessage("*allow-list*"); + } + + [Fact] + public void ALobbyCannotBeCreatedWithAnUnknownTieBreakRule() + { + var act = () => LobbyTestFactory.Open(_host, Now, LobbyTestFactory.Settings(tieBreakRuleId: "coin-flip")); + + act.Should().Throw().WithMessage("*allow-list*"); + } +} diff --git a/tests/SimPle.UnitTests/Lobbies/LobbyReadinessResetTests.cs b/tests/SimPle.UnitTests/Lobbies/LobbyReadinessResetTests.cs new file mode 100644 index 0000000..15867a3 --- /dev/null +++ b/tests/SimPle.UnitTests/Lobbies/LobbyReadinessResetTests.cs @@ -0,0 +1,190 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SimPle.Domain.Lobbies; + +namespace SimPle.UnitTests.Lobbies; + +/// +/// Readiness-reset scope (brief Risk #4): "every match-affecting settings change and every join/leave/kick resets +/// readiness for all joined non-host humans; the host is implicitly ready." +/// +/// Missing a single reset trigger lets a stale-ready roster start on changed settings — which is exactly the bug +/// that is invisible until someone is dropped into a match they never agreed to. There is one test per trigger. +/// +public class LobbyReadinessResetTests +{ + private readonly FakeTimeProvider _clock = new(LobbyTestFactory.T0); + private DateTime Now => _clock.GetUtcNow().UtcDateTime; + + private readonly Guid _host = Guid.NewGuid(); + private readonly Guid _alice = Guid.NewGuid(); + private readonly Guid _bob = Guid.NewGuid(); + + private static bool ReadinessOf(Lobby lobby, Guid userId) => + lobby.FindJoinedMember(userId)!.IsReady; + + // ── The host is implicitly ready ───────────────────────────────────────── + + [Fact] + public void TheHostIsReadyFromTheMomentTheLobbyExists() + { + var lobby = LobbyTestFactory.Open(_host, Now); + + ReadinessOf(lobby, _host).Should().BeTrue(); + } + + [Fact] + public void TheHostCannotUnReadyThemselves() + { + // Letting the host clear their own readiness would create a lobby that can never satisfy IsEveryoneReady. + var lobby = LobbyTestFactory.Open(_host, Now); + + var outcome = lobby.SetReadiness(_host, false, Now); + + outcome.Should().Be(LobbyOutcome.InvalidTarget); + ReadinessOf(lobby, _host).Should().BeTrue(); + } + + [Fact] + public void AResetNeverClearsTheHostsReadiness() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + + lobby.Join(_bob, Now); // a reset trigger + + ReadinessOf(lobby, _host).Should().BeTrue("the host is implicitly ready and is never counted in a reset"); + ReadinessOf(lobby, _alice).Should().BeFalse(); + } + + // ── Trigger: join ──────────────────────────────────────────────────────── + + [Fact] + public void AJoinResetsEveryJoinedNonHostHuman() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + ReadinessOf(lobby, _alice).Should().BeTrue(); + + lobby.Join(_bob, Now); + + ReadinessOf(lobby, _alice).Should().BeFalse("a new member changes the roster the others agreed to"); + ReadinessOf(lobby, _bob).Should().BeFalse("a joiner starts un-ready"); + } + + // ── Trigger: leave ─────────────────────────────────────────────────────── + + [Fact] + public void ALeaveResetsTheRemainingNonHostMembers() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice, _bob); + + lobby.Leave(_bob, Now); + + ReadinessOf(lobby, _alice).Should().BeFalse(); + } + + // ── Trigger: kick ──────────────────────────────────────────────────────── + + [Fact] + public void AKickResetsTheRemainingNonHostMembers() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice, _bob); + + lobby.Kick(_host, _bob, Now); + + ReadinessOf(lobby, _alice).Should().BeFalse(); + } + + // ── Trigger: match-affecting settings changes ──────────────────────────── + + public static TheoryData MatchAffectingChanges() => new() + { + { "game", LobbyTestFactory.Settings(gameSlug: "checkers") }, + { "capability version", LobbyTestFactory.Settings(capabilityVersion: 2) }, + { "seat count", LobbyTestFactory.Settings(maxPlayers: 3) }, + { "time control", LobbyTestFactory.Settings(timeControlId: "rapid-10-0") }, + { "rated", LobbyTestFactory.Settings(rated: true) }, + { "region", LobbyTestFactory.Settings(resolvedRegion: "us-east") }, + { "tie-break rule", LobbyTestFactory.Settings(tieBreakRuleId: "sudden-death") }, + { "AI fill", LobbyTestFactory.Settings(aiFillRequested: true) }, + }; + + [Theory] + [MemberData(nameof(MatchAffectingChanges))] + public void AMatchAffectingSettingsChangeResetsReadiness(string changed, LobbySettings settings) + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + + var outcome = lobby.ChangeSettings(_host, settings, Now); + + outcome.Should().Be(LobbyOutcome.Ok); + ReadinessOf(lobby, _alice).Should() + .BeFalse($"changing the {changed} changes what match gets played, so a ready roster is stale"); + } + + // ── Non-triggers: access-control settings ──────────────────────────────── + + [Fact] + public void ChangingPrivacyAloneDoesNotResetReadiness() + { + // Privacy governs who may reach the lobby, not what is played — it cannot make a ready roster stale. + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + + lobby.ChangeSettings(_host, LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public), Now); + + ReadinessOf(lobby, _alice).Should().BeTrue(); + } + + [Fact] + public void ChangingSpectatorPolicyAloneDoesNotResetReadiness() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + + lobby.ChangeSettings(_host, LobbyTestFactory.Settings(spectatorPolicy: SpectatorPolicy.Disabled), Now); + + ReadinessOf(lobby, _alice).Should().BeTrue(); + } + + [Fact] + public void ReapplyingTheIdenticalSettingsDoesNotResetReadiness() + { + var lobby = LobbyTestFactory.ReadyLobby(_host, Now, _alice); + + lobby.ChangeSettings(_host, lobby.CurrentSettings, Now); + + ReadinessOf(lobby, _alice).Should().BeTrue("nothing about the match changed"); + } + + // ── Every mutation bumps the revision ──────────────────────────────────── + + [Fact] + public void EveryMutationBumpsTheRevision() + { + var lobby = LobbyTestFactory.Open(_host, Now); + lobby.Revision.Should().Be(1, "a freshly created lobby is at revision 1"); + + lobby.Join(_alice, Now); + lobby.Revision.Should().Be(2); + + lobby.SetReadiness(_alice, true, Now); + lobby.Revision.Should().Be(3); + + lobby.ChangeSettings(_host, LobbyTestFactory.Settings(timeControlId: "rapid-10-0"), Now); + lobby.Revision.Should().Be(4); + + lobby.Kick(_host, _alice, Now); + lobby.Revision.Should().Be(5); + } + + [Fact] + public void ARejectedMutationDoesNotBumpTheRevision() + { + // A stale-revision conflict is only meaningful if a *failed* command does not itself move the revision. + var lobby = LobbyTestFactory.Open(_host, Now); + lobby.Join(_alice, Now); + var revisionBefore = lobby.Revision; + + lobby.Kick(_alice, _host, Now).Should().Be(LobbyOutcome.Forbidden); // alice is not the host + + lobby.Revision.Should().Be(revisionBefore); + } +} diff --git a/tests/SimPle.UnitTests/Lobbies/LobbyTestFactory.cs b/tests/SimPle.UnitTests/Lobbies/LobbyTestFactory.cs new file mode 100644 index 0000000..eeee82d --- /dev/null +++ b/tests/SimPle.UnitTests/Lobbies/LobbyTestFactory.cs @@ -0,0 +1,96 @@ +using SimPle.Domain.Capabilities; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Matchmaking; + +namespace SimPle.UnitTests.Lobbies; + +/// +/// Valid-by-default fixtures. Every factory takes an explicit nowUtc so a test can only ever build state +/// against its own fake clock — there is no overload that quietly reaches for DateTime.UtcNow. +/// +public static class LobbyTestFactory +{ + public static readonly DateTime T0 = new(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc); + + public static LobbySettings Settings( + string gameSlug = "chess-lite", + int capabilityVersion = 1, + LobbyPrivacy privacy = LobbyPrivacy.Private, + int maxPlayers = 4, + string timeControlId = "blitz-3-2", + bool rated = false, + string resolvedRegion = "eu-west", + SpectatorPolicy spectatorPolicy = SpectatorPolicy.Anyone, + string tieBreakRuleId = "none", + bool aiFillRequested = false) => + new(gameSlug, capabilityVersion, privacy, maxPlayers, timeControlId, rated, + resolvedRegion, spectatorPolicy, tieBreakRuleId, aiFillRequested); + + public static Lobby Open(Guid hostUserId, DateTime nowUtc, LobbySettings? settings = null) => + Lobby.Create(hostUserId, settings ?? Settings(), Guid.NewGuid(), nowUtc); + + /// A lobby whose host and are all seated and ready. + public static Lobby ReadyLobby(Guid hostUserId, DateTime nowUtc, params Guid[] joiners) + { + var lobby = Open(hostUserId, nowUtc); + + foreach (var joiner in joiners) + lobby.Join(joiner, nowUtc); + + foreach (var joiner in joiners) + lobby.SetReadiness(joiner, true, nowUtc); + + return lobby; + } + + public static GameCapabilityProfile Profile( + string gameSlug = "chess-lite", + int capabilityVersion = 1, + int minPlayers = 2, + int maxPlayers = 4, + IEnumerable? allowedModes = null, + IEnumerable? timeControls = null, + IEnumerable? tieBreakRules = null, + IEnumerable? spectatorPolicies = null, + bool ratedEligible = true, + bool aiFillEligible = true) => + GameCapabilityProfile.Create( + gameSlug, + capabilityVersion, + minPlayers, + maxPlayers, + allowedModes ?? new[] { "multiplayer", "ranked", "ai" }, + timeControls ?? new[] { "blitz-3-2", "rapid-10-0" }, + tieBreakRules ?? new[] { "none", "sudden-death" }, + spectatorPolicies ?? new[] { "Anyone", "FriendsOnly", "Disabled" }, + ratedEligible, + aiFillEligible, + "test-1"); +} + +public static class TicketFactory +{ + public static MatchmakingTicket Queued( + DateTime nowUtc, + Guid? userId = null, + string gameSlug = "chess-lite", + string mode = "multiplayer", + int playerCount = 2, + string timeControlId = "blitz-3-2", + bool rated = false, + string resolvedRegion = "eu-west", + int rating = MatchmakingTicket.ProvisionalRating) => + MatchmakingTicket.Enqueue( + userId ?? Guid.NewGuid(), + gameSlug, + capabilityVersion: 1, + mode, + playerCount, + timeControlId, + rated, + resolvedRegion, + rating, + MatchmakingTicket.ProvisionalRatingSource, + Guid.NewGuid(), + nowUtc); +} diff --git a/tests/SimPle.UnitTests/Matchmaking/ExpirySweeperTests.cs b/tests/SimPle.UnitTests/Matchmaking/ExpirySweeperTests.cs new file mode 100644 index 0000000..a2bfc40 --- /dev/null +++ b/tests/SimPle.UnitTests/Matchmaking/ExpirySweeperTests.cs @@ -0,0 +1,196 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.Expiry; +using SimPle.Application.Lobbies.Outbox; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Outbox; +using SimPle.UnitTests.Lobbies; +using Xunit; + +namespace SimPle.UnitTests.Matchmaking; + +/// +/// The expiry sweep: 60-second tickets, 2-hour lobbies, 30-minute invites. +/// +/// Every deadline here is only provable with an injected clock (brief Risk #8) — a wall-clock test could not tell a +/// correct 2-hour lobby lifetime from one that never expires at all without running for two hours. +/// +public sealed class ExpirySweeperTests +{ + private static readonly DateTime T0 = LobbyTestFactory.T0; + + private readonly IMatchmakingRepository _tickets = Substitute.For(); + private readonly ILobbyRepository _lobbies = Substitute.For(); + private readonly FakeTimeProvider _clock = new(T0); + + private readonly ExpirySweeper _sut; + + public ExpirySweeperTests() + { + _tickets.GetExpiredTicketsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + _lobbies.GetExpiredLobbiesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + _lobbies.GetExpiredInvitesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + + _sut = new ExpirySweeper( + _tickets, _lobbies, new PassThroughWorkerTransaction(), + Options.Create(new ExpiryOptions()), + _clock, + NullLogger.Instance); + } + + // ── Tickets ────────────────────────────────────────────────────────────── + + [Fact] + public async Task ATicketPastItsSixtySecondDeadlineIsTimedOut() + { + var ticket = TicketFactory.Queued(T0); + GivenTickets(ticket); + + _clock.SetUtcNow(T0.AddSeconds(60)); // exactly at the deadline is already expired + var result = await _sut.SweepAsync(); + + result.TicketsExpired.Should().Be(1); + ticket.State.Should().Be(MatchmakingTicketState.TimedOut); + } + + [Fact] + public async Task TheSweepRunsWithoutModule8_SoAQueuedTicketAlwaysReachesAnHonestOutcome() + { + // The asymmetry with the matching worker, and the reason for it: matching without a match runtime would + // fabricate an opponent, but expiring without one fabricates nothing. Without this, a Phase-1 ticket would + // sit Queued forever, because the only thing that could ever have resolved it does not exist yet. + // + // The sweeper takes no IMatchRuntimeProbe at all — it *cannot* be gated on M8, by construction. + var ticket = TicketFactory.Queued(T0); + GivenTickets(ticket); + + _clock.SetUtcNow(T0.AddSeconds(61)); + var result = await _sut.SweepAsync(); + + result.TicketsExpired.Should().Be(1); + ticket.State.Should().Be(MatchmakingTicketState.TimedOut); + } + + [Fact] + public async Task TheSweepReportsHowLateItWas_WhichIsTheExpiryLagSignal() + { + var ticket = TicketFactory.Queued(T0); + GivenTickets(ticket); + + _clock.SetUtcNow(T0.AddSeconds(63)); // 3 seconds past the 60-second deadline + var result = await _sut.SweepAsync(); + + result.MaxTicketLag.Should().Be(TimeSpan.FromSeconds(3)); + } + + [Fact] + public async Task ASecondSweepOverTheSameTicketIsANoOp() + { + // Idempotent by construction: TryTimeOut returns false rather than transitioning twice, so an overlapping + // or re-run sweep cannot double-expire anything. + var ticket = TicketFactory.Queued(T0); + GivenTickets(ticket); + + _clock.SetUtcNow(T0.AddSeconds(61)); + (await _sut.SweepAsync()).TicketsExpired.Should().Be(1); + (await _sut.SweepAsync()).TicketsExpired.Should().Be(0); + + ticket.State.Should().Be(MatchmakingTicketState.TimedOut); + } + + // ── Lobbies ────────────────────────────────────────────────────────────── + + [Fact] + public async Task ALobbyPastItsTwoHourLifetimeIsExpired_AndEmitsAClosedEvent() + { + var lobby = LobbyTestFactory.Open(Guid.NewGuid(), T0); + GivenLobbies(lobby); + + _clock.SetUtcNow(T0.AddHours(2)); + var result = await _sut.SweepAsync(); + + result.LobbiesExpired.Should().Be(1); + lobby.State.Should().Be(LobbyState.Expired); + lobby.ClosedReason.Should().Be(LobbyClosedReason.Expired); + + // A consumer must never have to infer *why* a lobby ended from the absence of anything else. + await _tickets.Received(1).SaveAsync( + Arg.Is>(e => + e.Count == 1 && e[0].EventType == LobbyOutbox.LobbyClosed), + Arg.Any()); + } + + [Fact] + public async Task ALobbyJustShortOfTwoHoursSurvives() + { + var lobby = LobbyTestFactory.Open(Guid.NewGuid(), T0); + + // The repository query is what filters on the deadline; feed it the truth so this test exercises the + // boundary rather than the substitute's willingness to return whatever it is handed. + _lobbies.GetExpiredLobbiesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => lobby.IsExpired(call.Arg()) + ? new[] { lobby } + : Array.Empty()); + + _clock.SetUtcNow(T0.AddHours(2).AddSeconds(-1)); + var result = await _sut.SweepAsync(); + + result.LobbiesExpired.Should().Be(0); + lobby.State.Should().Be(LobbyState.Open); + } + + // ── Invites ────────────────────────────────────────────────────────────── + + [Fact] + public async Task AnInvitePastItsThirtyMinuteLifetimeIsExpired_AndEmitsNoEvent() + { + // Nobody acted. M11 has no notification to send for "an invite you ignored has quietly lapsed", so the + // state change alone is the record. + var invite = LobbyInvite.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), T0); + GivenInvites(invite); + + _clock.SetUtcNow(T0.AddMinutes(30)); + var result = await _sut.SweepAsync(); + + result.InvitesExpired.Should().Be(1); + invite.State.Should().Be(LobbyInviteState.Expired); + + await _tickets.Received(1).SaveAsync( + Arg.Is>(e => e.Count == 0), Arg.Any()); + } + + // ── Nothing to do ──────────────────────────────────────────────────────── + + [Fact] + public async Task AnIdleSweepWritesNothingAtAll() + { + var result = await _sut.SweepAsync(); + + result.Total.Should().Be(0); + await _tickets.DidNotReceive().SaveAsync( + Arg.Any>(), Arg.Any()); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private void GivenTickets(params MatchmakingTicket[] tickets) => + _tickets.GetExpiredTicketsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(tickets); + + private void GivenLobbies(params Lobby[] lobbies) => + _lobbies.GetExpiredLobbiesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(lobbies); + + private void GivenInvites(params LobbyInvite[] invites) => + _lobbies.GetExpiredInvitesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(invites); +} diff --git a/tests/SimPle.UnitTests/Matchmaking/LobbyBlockHandlerTests.cs b/tests/SimPle.UnitTests/Matchmaking/LobbyBlockHandlerTests.cs new file mode 100644 index 0000000..c397f06 --- /dev/null +++ b/tests/SimPle.UnitTests/Matchmaking/LobbyBlockHandlerTests.cs @@ -0,0 +1,197 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Friends.Outbox; +using SimPle.Application.Lobbies.Outbox; +using SimPle.Application.Outbox.Handlers; +using SimPle.Domain.Friends; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Outbox; +using SimPle.UnitTests.Lobbies; +using Xunit; + +namespace SimPle.UnitTests.Matchmaking; + +/// +/// The first outbox consumer (D3): applying a new Module 3 block to a lobby the two users share. +/// +/// The rule is asymmetric on purpose — a host who blocks someone is not evicted from the lobby they own, and a +/// member who blocks the host has no authority to remove them, so all they can do is leave. +/// +public sealed class LobbyBlockHandlerTests +{ + private static readonly DateTime T0 = LobbyTestFactory.T0; + + private readonly ILobbyRepository _lobbies = Substitute.For(); + private readonly FakeTimeProvider _clock = new(T0); + + private readonly Guid _host = Guid.NewGuid(); + private readonly Guid _member = Guid.NewGuid(); + + private readonly LobbyBlockHandler _sut; + + public LobbyBlockHandlerTests() + { + _lobbies.GetActiveLobbyForUserAsync(Arg.Any(), Arg.Any()).Returns((Lobby?)null); + + _sut = new LobbyBlockHandler( + _lobbies, new PassThroughCommandRunner(), _clock, NullLogger.Instance); + } + + [Fact] + public void ItConsumesUserBlockedV1() + { + _sut.EventTypes.Should().ContainSingle().Which.Should().Be(FriendOutbox.UserBlocked); + + // The handler name is persisted in every delivery row. Renaming it silently replays the entire block + // history against the new name, so it is a wire identifier — not a class name to be refactored freely. + _sut.HandlerName.Should().Be("lobby-block"); + } + + [Fact] + public async Task AHostWhoBlocksAMemberRemovesThem() + { + var lobby = GivenSharedLobby(); + + await _sut.HandleAsync(BlockEvent(blocker: _host, blocked: _member)); + + lobby.FindJoinedMember(_member).Should().BeNull(); + lobby.FindJoinedMember(_host).Should().NotBeNull("the host keeps the lobby they own"); + lobby.HostUserId.Should().Be(_host); + + await _lobbies.Received(1).SaveAsync( + Arg.Is>(e => e.Any(x => x.EventType == LobbyOutbox.LobbyMemberKicked)), + Arg.Any()); + } + + [Fact] + public async Task ANonHostWhoBlocksTheHostLeavesInstead() + { + // The blocker has no authority to remove the host, so the only thing they can do is take themselves out. + var lobby = GivenSharedLobby(blockerIsHost: false); + + await _sut.HandleAsync(BlockEvent(blocker: _member, blocked: _host)); + + lobby.FindJoinedMember(_member).Should().BeNull(); + lobby.FindJoinedMember(_host).Should().NotBeNull(); + + await _lobbies.Received(1).SaveAsync( + Arg.Is>(e => e.Any(x => x.EventType == LobbyOutbox.LobbyMemberLeft)), + Arg.Any()); + } + + [Fact] + public async Task WhenTheTwoUsersShareNoLobby_ItDoesNothing() + { + // This is what makes the handler idempotent and makes a historical backfill safe: it decides from *current* + // membership, never from the event's age. A year-old UserBlockedV1 replayed on a fresh deployment finds no + // shared lobby and is a no-op — which is why no activation watermark is needed. + await _sut.HandleAsync(BlockEvent(blocker: _host, blocked: _member)); + + await _lobbies.DidNotReceive().SaveAsync( + Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ADuplicateDeliveryIsANoOp_BecauseTheyAreAlreadySeparated() + { + // Delivery is at-least-once by construction, so this is not a hypothetical. + var lobby = GivenSharedLobby(); + var message = BlockEvent(blocker: _host, blocked: _member); + + await _sut.HandleAsync(message); + _lobbies.ClearReceivedCalls(); + + await _sut.HandleAsync(message); + + lobby.FindJoinedMember(_member).Should().BeNull(); + await _lobbies.DidNotReceive().SaveAsync( + Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task WhenOnlyTheBlockerIsInTheLobby_ItDoesNothing() + { + var lobby = LobbyTestFactory.Open(_host, T0); // the blocked user was never here + _lobbies.GetActiveLobbyForUserAsync(_host, Arg.Any()).Returns(lobby); + _lobbies.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + await _sut.HandleAsync(BlockEvent(blocker: _host, blocked: _member)); + + lobby.FindJoinedMember(_host).Should().NotBeNull(); + await _lobbies.DidNotReceive().SaveAsync( + Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ATerminalLobbyIsLeftAlone() + { + var lobby = GivenSharedLobby(); + lobby.Close(LobbyClosedReason.HostLeft); + + await _sut.HandleAsync(BlockEvent(blocker: _host, blocked: _member)); + + await _lobbies.DidNotReceive().SaveAsync( + Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ItReadsTheRealFriendOutboxPayload_WhichIsCamelCase() + { + // Regression guard. FriendOutbox serializes from an anonymous object, so the JSON keys are camelCase + // (`blockerId`) while the handler's payload record is PascalCase. System.Text.Json is case-sensitive by + // default — without PropertyNameCaseInsensitive every id would deserialize to Guid.Empty and the handler + // would silently treat every block as unreadable, with no exception and no failed write to notice it by. + // + // Built from the *real* FriendOutbox.UserBlockedEvent, not a hand-written JSON string, so this test breaks + // if the producer's payload shape ever drifts from the consumer's. + var lobby = GivenSharedLobby(); + var realEvent = FriendOutbox.UserBlockedEvent(Block.Create(_host, _member)); + + // Sanity: the producer really does emit camelCase, so the guard is guarding something. + realEvent.Payload.Should().Contain("blockerId"); + JsonSerializer.Deserialize(realEvent.Payload)!.BlockerId.Should().Be(Guid.Empty); + + await _sut.HandleAsync(realEvent); + + lobby.FindJoinedMember(_member).Should().BeNull("the handler read the camelCase ids correctly"); + } + + [Fact] + public async Task AnUnreadablePayloadIsNotRetried() + { + // Replaying it would produce the same nothing. Returning (rather than throwing) lets the dispatcher mark it + // processed instead of burning the retry budget and dead-lettering a row no retry could ever fix. + GivenSharedLobby(); + var malformed = OutboxMessage.Create("Block", Guid.NewGuid(), FriendOutbox.UserBlocked, 1, 1, 1, "{}"); + + var act = async () => await _sut.HandleAsync(malformed); + + await act.Should().NotThrowAsync(); + await _lobbies.DidNotReceive().SaveAsync( + Arg.Any>(), Arg.Any()); + } + + // ── Fixtures ───────────────────────────────────────────────────────────── + + /// A lobby where the host and one member are both seated. + private Lobby GivenSharedLobby(bool blockerIsHost = true) + { + var lobby = LobbyTestFactory.Open(_host, T0); + lobby.Join(_member, T0); + + var blocker = blockerIsHost ? _host : _member; + _lobbies.GetActiveLobbyForUserAsync(blocker, Arg.Any()).Returns(lobby); + _lobbies.GetForUpdateAsync(lobby.Id, Arg.Any()).Returns(lobby); + + return lobby; + } + + private static OutboxMessage BlockEvent(Guid blocker, Guid blocked) => + FriendOutbox.UserBlockedEvent(Block.Create(blocker, blocked)); + + private sealed record PascalCasePayload(Guid BlockId, Guid BlockerId, Guid BlockedId); +} diff --git a/tests/SimPle.UnitTests/Matchmaking/MatchProposalBuilderTests.cs b/tests/SimPle.UnitTests/Matchmaking/MatchProposalBuilderTests.cs new file mode 100644 index 0000000..f248ad3 --- /dev/null +++ b/tests/SimPle.UnitTests/Matchmaking/MatchProposalBuilderTests.cs @@ -0,0 +1,370 @@ +using FluentAssertions; +using SimPle.Application.Matchmaking.Services; +using SimPle.Domain.Matchmaking; +using SimPle.UnitTests.Lobbies; +using Xunit; + +namespace SimPle.UnitTests.Matchmaking; + +/// +/// The Phase-1 matchmaking algorithm (slice 6C): pool keys, band widening, mutual compatibility, the four-level +/// tie-break, blocks, and anti-starvation. +/// +/// +/// Every test drives an explicit clock. That is not a style preference — the bands are defined by ticket age +/// (±100 → ±200 → ±400 at 15s/30s), so a wall-clock test could not distinguish "the band widened correctly" from +/// "the test happened to take long enough", and an off-by-one at a boundary would be invisible (brief Risk #8). +/// +/// +public class MatchProposalBuilderTests +{ + private static readonly DateTime T0 = LobbyTestFactory.T0; + + private static IReadOnlyList Build( + IReadOnlyList tickets, DateTime nowUtc, BlockedPairs? blocked = null) => + MatchProposalBuilder.BuildProposals(tickets, nowUtc, blocked ?? BlockedPairs.None); + + // ── Pool key ───────────────────────────────────────────────────────────── + + [Fact] + public void TwoIdenticalTicketsArePaired() + { + var a = TicketFactory.Queued(T0); + var b = TicketFactory.Queued(T0); + + var proposals = Build(new[] { a, b }, T0); + + proposals.Should().ContainSingle(); + proposals[0].Tickets.Should().HaveCount(2); + proposals[0].Tickets.Select(t => t.Id).Should().BeEquivalentTo(new[] { a.Id, b.Id }); + } + + [Theory] + [InlineData("gameSlug")] + [InlineData("mode")] + [InlineData("timeControlId")] + [InlineData("rated")] + [InlineData("resolvedRegion")] + [InlineData("playerCount")] + public void TicketsThatDifferOnAnyPoolKeyFieldAreNeverPaired(string differingField) + { + // The pool key is an *exact* match on every field. A near-miss is not a worse match — it is a different + // game entirely, and pairing across it would produce a match neither player asked for. + var a = TicketFactory.Queued(T0); + var b = differingField switch + { + "gameSlug" => TicketFactory.Queued(T0, gameSlug: "checkers"), + "mode" => TicketFactory.Queued(T0, mode: "ranked"), + "timeControlId" => TicketFactory.Queued(T0, timeControlId: "rapid-10-0"), + "rated" => TicketFactory.Queued(T0, rated: true), + "resolvedRegion" => TicketFactory.Queued(T0, resolvedRegion: "us-east"), + "playerCount" => TicketFactory.Queued(T0, playerCount: 4), + _ => throw new ArgumentOutOfRangeException(nameof(differingField)), + }; + + Build(new[] { a, b }, T0).Should().BeEmpty(); + } + + // ── Compatibility is mutual, not anchor-centric ────────────────────────── + + [Fact] + public void AWideBandedAnchorCannotDragAYoungTicketBeyondItsOwnNarrowBand() + { + // The bug this exists to catch: checking only the anchor's window. A 30-second-old anchor accepts ±400, so + // a naive implementation happily pairs it with a ticket 300 points away — but that ticket enqueued one + // second ago and has only agreed to ±100. It never consented to that spread, and its own band says so. + var anchor = TicketFactory.Queued(T0, rating: 1200); + var young = TicketFactory.Queued(T0.AddSeconds(29), rating: 1500); + + var now = T0.AddSeconds(30); // anchor is 30s old (±400); young is 1s old (±100) + + anchor.CurrentBand(now).Should().Be(400); + young.CurrentBand(now).Should().Be(100); + + Build(new[] { anchor, young }, now).Should().BeEmpty(); + } + + [Fact] + public void OnceBothBandsReachEachOtherThePairIsFormed() + { + var anchor = TicketFactory.Queued(T0, rating: 1200); + var other = TicketFactory.Queued(T0, rating: 1350); + + // At 0s both are ±100: 150 apart, so neither reaches the other. + Build(new[] { anchor, other }, T0).Should().BeEmpty(); + + // At 15s both are ±200, which now spans the 150-point gap from both sides. + Build(new[] { anchor, other }, T0.AddSeconds(15)).Should().ContainSingle(); + } + + [Fact] + public void AGroupOfThreeIsRejectedWhenTwoNonAnchorMembersAreTooFarApartFromEachOther() + { + // The gap that the whole-group range check exists to close, and the reason the candidate filter alone is not + // enough. The filter only asks "does this ticket mutually accept the *anchor*" — so an anchor sitting in the + // middle happily admits one candidate 300 below it and another 300 above, while those two are 600 apart and + // neither would ever have accepted the other. Checking the group's full range against every member's window + // is what rejects it. + var anchor = TicketFactory.Queued(T0, rating: 1200, playerCount: 3); + var low = TicketFactory.Queued(T0, rating: 900, playerCount: 3); + var high = TicketFactory.Queued(T0, rating: 1500, playerCount: 3); + + var now = T0.AddSeconds(30); // all three are ±400 + + // The anchor reaches both, in both directions — so both survive the candidate filter. + anchor.RatingWindow(now).Should().Be((800, 1600)); + low.RatingWindow(now).Should().Be((500, 1300)); + high.RatingWindow(now).Should().Be((1100, 1900)); + + // But the group's range is 900..1500, and `low` agreed to nothing above 1300. + Build(new[] { anchor, low, high }, now).Should().BeEmpty(); + } + + [Fact] + public void AGroupOfThreeFormsOnceItsFullRangeFitsEveryMembersWindow() + { + // The same shape as above with `high` pulled in to 1300: the range becomes 900..1300, which now fits inside + // all three windows. Proves the rejection above is the range check doing its job, not some unrelated + // incompatibility in the three-player path. + var anchor = TicketFactory.Queued(T0, rating: 1200, playerCount: 3); + var low = TicketFactory.Queued(T0, rating: 900, playerCount: 3); + var high = TicketFactory.Queued(T0, rating: 1300, playerCount: 3); + + var proposals = Build(new[] { anchor, low, high }, T0.AddSeconds(30)); + + // Which of the three anchors is decided by the Guid tie-break here (they share a timestamp) and does not + // matter: the group is compatible whichever one leads, which is the property being asserted. + proposals.Should().ContainSingle(); + proposals[0].Tickets.Should().HaveCount(3); + } + + // ── Tie-break ──────────────────────────────────────────────────────────── + + // Each tie-break test enqueues its anchor one second earlier than the candidates. Without that, all three + // tickets would share a timestamp and the anchor would be chosen by the Guid tie-break — i.e. at random — so the + // test would be asserting against a candidate that is sometimes the anchor itself. Evaluated at T0+5s, every + // ticket is still inside the first band, so the bands stay equal and only the tie-break under test can decide. + + [Fact] + public void TheSmallestRatingRangeWins() + { + var anchor = TicketFactory.Queued(T0, rating: 1200); + var near = TicketFactory.Queued(T0.AddSeconds(1), rating: 1220); + var far = TicketFactory.Queued(T0.AddSeconds(1), rating: 1280); + + var proposals = Build(new[] { anchor, near, far }, T0.AddSeconds(5)); + + // Both are inside the anchor's ±100, and both reach back. The nearer one makes the tighter match. + proposals.Should().ContainSingle(); + proposals[0].Anchor.Id.Should().Be(anchor.Id); + proposals[0].Tickets.Select(t => t.Id).Should().Contain(near.Id); + proposals[0].Tickets.Select(t => t.Id).Should().NotContain(far.Id); + } + + [Fact] + public void OnAnIdenticalRange_TheEarlierTicketWins() + { + // Same rating, so range and distance are both tied for either candidate. Level 3 — earliest creation — + // decides, and it is what keeps the queue fair: the player who has been waiting longer is served first. + var anchor = TicketFactory.Queued(T0, rating: 1200); + var early = TicketFactory.Queued(T0.AddSeconds(1), rating: 1250); + var late = TicketFactory.Queued(T0.AddSeconds(2), rating: 1250); + + var proposals = Build(new[] { anchor, late, early }, T0.AddSeconds(5)); // input deliberately out of order + + proposals.Should().ContainSingle(); + proposals[0].Anchor.Id.Should().Be(anchor.Id); + proposals[0].Tickets.Select(t => t.Id).Should().Contain(early.Id); + proposals[0].Tickets.Select(t => t.Id).Should().NotContain(late.Id); + } + + [Fact] + public void OnAnIdenticalRangeAndTime_TheLowestTicketIdWins_SoTheChoiceIsAlwaysDeterministic() + { + // Rating, range, distance, and creation time all tie. Only level 4 separates them — and it must, or the + // selection would depend on enumeration order, which is exactly how two workers reading the same rows in a + // different order end up disagreeing about who plays whom. + var anchor = TicketFactory.Queued(T0, rating: 1200); + var x = TicketFactory.Queued(T0.AddSeconds(1), rating: 1250); + var y = TicketFactory.Queued(T0.AddSeconds(1), rating: 1250); + + var expected = x.Id.CompareTo(y.Id) < 0 ? x.Id : y.Id; + + // Same input, both orderings — the answer must not move. + var forwards = Build(new[] { anchor, x, y }, T0.AddSeconds(5)); + var backwards = Build(new[] { anchor, y, x }, T0.AddSeconds(5)); + + forwards[0].Tickets.Select(t => t.Id).Should().Contain(expected); + backwards[0].Tickets.Select(t => t.Id).Should().Contain(expected); + } + + // ── Anchoring and anti-starvation ──────────────────────────────────────── + + [Fact] + public void TheOldestTicketAnchorsTheProposal() + { + var oldest = TicketFactory.Queued(T0, rating: 1200); + var newer = TicketFactory.Queued(T0.AddSeconds(5), rating: 1200); + var newest = TicketFactory.Queued(T0.AddSeconds(9), rating: 1200); + + var proposals = Build(new[] { newest, newer, oldest }, T0.AddSeconds(10)); + + // Three identical tickets, so only one pair can form — and it must contain the one that has waited longest. + proposals.Should().ContainSingle(); + proposals[0].Anchor.Id.Should().Be(oldest.Id); + proposals[0].Tickets.Select(t => t.Id).Should().Contain(newer.Id); + } + + [Fact] + public void AnUnmatchableAnchorDoesNotBlockTheTicketsBehindIt() + { + // Anti-starvation from the *other* side: a lonely oldest ticket must not hold up a pair that can match. It + // stays Queued (this only drops it from *this cycle*) and is re-anchored next cycle with a wider band. + var lonely = TicketFactory.Queued(T0, gameSlug: "checkers"); + var a = TicketFactory.Queued(T0.AddSeconds(1)); + var b = TicketFactory.Queued(T0.AddSeconds(2)); + + var proposals = Build(new[] { lonely, a, b }, T0.AddSeconds(3)); + + proposals.Should().ContainSingle(); + proposals[0].Tickets.Select(t => t.Id).Should().BeEquivalentTo(new[] { a.Id, b.Id }); + proposals.SelectMany(p => p.Tickets).Should().NotContain(lonely); + } + + [Fact] + public void ATicketAppearsInAtMostOneProposal() + { + var tickets = Enumerable.Range(0, 4).Select(_ => TicketFactory.Queued(T0)).ToList(); + + var proposals = Build(tickets, T0); + + proposals.Should().HaveCount(2); + + var used = proposals.SelectMany(p => p.Tickets.Select(t => t.Id)).ToList(); + used.Should().HaveCount(4); + used.Should().OnlyHaveUniqueItems(); + } + + [Fact] + public void AnOddTicketIsLeftForTheNextCycleRatherThanForcedIntoAGroup() + { + var tickets = Enumerable.Range(0, 3).Select(_ => TicketFactory.Queued(T0)).ToList(); + + var proposals = Build(tickets, T0); + + proposals.Should().ContainSingle(); + proposals[0].Tickets.Should().HaveCount(2); + } + + // ── Expiry ─────────────────────────────────────────────────────────────── + + [Fact] + public void AnExpiredTicketIsNeverProposed_TheSweepOwnsItNow() + { + var expired = TicketFactory.Queued(T0); + var fresh = TicketFactory.Queued(T0.AddSeconds(59)); + + var now = T0.AddSeconds(60); // the first ticket is exactly at its deadline, which is already expired + + expired.IsExpired(now).Should().BeTrue(); + + Build(new[] { expired, fresh }, now).Should().BeEmpty(); + } + + [Fact] + public void AClaimedTicketIsNeverReproposed() + { + // The builder only ever considers Queued rows. A ticket another worker already claimed is not a candidate, + // which is the first of the two defences against double assignment (the partial unique index is the one + // that actually guarantees it). + var claimed = TicketFactory.Queued(T0); + claimed.Claim("worker-1", T0); + + var queued = TicketFactory.Queued(T0); + + Build(new[] { claimed, queued }, T0).Should().BeEmpty(); + } + + // ── Blocks ─────────────────────────────────────────────────────────────── + + [Fact] + public void TwoUsersWithABlockBetweenThemAreNeverProposedTogether() + { + var alice = Guid.NewGuid(); + var bob = Guid.NewGuid(); + + var a = TicketFactory.Queued(T0, userId: alice); + var b = TicketFactory.Queued(T0, userId: bob); + + var blocked = new BlockedPairs(new[] { (alice, bob) }); + + Build(new[] { a, b }, T0, blocked).Should().BeEmpty(); + } + + [Fact] + public void ABlockIsSymmetric_ItDoesNotMatterWhoBlockedWhom() + { + var alice = Guid.NewGuid(); + var bob = Guid.NewGuid(); + + var a = TicketFactory.Queued(T0, userId: alice); + var b = TicketFactory.Queued(T0, userId: bob); + + // Recorded in the opposite direction to the pairing order. + var blocked = new BlockedPairs(new[] { (bob, alice) }); + + Build(new[] { a, b }, T0, blocked).Should().BeEmpty(); + } + + [Fact] + public void ABlockedPairIsExcludedFromTheCandidatePool_SoBothStillMatchWithSomeoneElse() + { + // The reason blocks are filtered *before* selection rather than vetoing a finished group: if a blocked pair + // could still be proposed and then rejected, the same two tickets would be re-proposed every cycle and both + // players would sit at the head of the queue until they timed out. + var alice = Guid.NewGuid(); + var bob = Guid.NewGuid(); + + var a = TicketFactory.Queued(T0, userId: alice); + var b = TicketFactory.Queued(T0.AddSeconds(1), userId: bob); + var c = TicketFactory.Queued(T0.AddSeconds(2)); + var d = TicketFactory.Queued(T0.AddSeconds(3)); + + var blocked = new BlockedPairs(new[] { (alice, bob) }); + + var proposals = Build(new[] { a, b, c, d }, T0.AddSeconds(4), blocked); + + proposals.Should().HaveCount(2); + + // Nobody was starved: all four matched, just not with the person they blocked. + proposals.SelectMany(p => p.Tickets.Select(t => t.Id)).Should().HaveCount(4); + + foreach (var proposal in proposals) + { + var users = proposal.Tickets.Select(t => t.UserId).ToList(); + users.Should().NotBeEquivalentTo(new[] { alice, bob }); + } + } + + [Fact] + public void ABlockBetweenTwoNonAnchorMembersStillRejectsTheGroup() + { + // A three-player group where the anchor gets along with both, but the other two blocked each other. Filtering + // only against the anchor would seat them together. + var anchorUser = Guid.NewGuid(); + var alice = Guid.NewGuid(); + var bob = Guid.NewGuid(); + + var anchor = TicketFactory.Queued(T0, userId: anchorUser, playerCount: 3); + var a = TicketFactory.Queued(T0, userId: alice, playerCount: 3); + var b = TicketFactory.Queued(T0, userId: bob, playerCount: 3); + + var blocked = new BlockedPairs(new[] { (alice, bob) }); + + Build(new[] { anchor, a, b }, T0, blocked).Should().BeEmpty(); + + // Sanity: without the block the same three do form a group, so the rejection above is the block's doing and + // not some unrelated incompatibility. + Build(new[] { anchor, a, b }, T0, BlockedPairs.None).Should().ContainSingle(); + } +} diff --git a/tests/SimPle.UnitTests/Matchmaking/MatchmakingBandTests.cs b/tests/SimPle.UnitTests/Matchmaking/MatchmakingBandTests.cs new file mode 100644 index 0000000..b099bb5 --- /dev/null +++ b/tests/SimPle.UnitTests/Matchmaking/MatchmakingBandTests.cs @@ -0,0 +1,166 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SimPle.Domain.Matchmaking; +using SimPle.UnitTests.Lobbies; + +namespace SimPle.UnitTests.Matchmaking; + +/// +/// The band boundaries the brief makes mandatory (Risk #8): exactly 15s, 30s, and 60s, each probed just below, at, +/// and just above. These are the tests a wall-clock suite cannot write — a real clock cannot be parked on the +/// instant a boundary flips, so an off-by-one in the comparison would pass a wall-clock test forever. +/// +/// The boundaries are half-open: [0,15) is +/-100, [15,30) is +/-200, [30,60) is +/-400, and >= 60 is expired. +/// "At" therefore always means the NEW band, never the old one. +/// +public class MatchmakingBandTests +{ + private static readonly DateTime T0 = new(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc); + + private static (MatchmakingTicket Ticket, FakeTimeProvider Clock) Enqueued() + { + var clock = new FakeTimeProvider(T0); + var ticket = TicketFactory.Queued(clock.GetUtcNow().UtcDateTime); + return (ticket, clock); + } + + private static int? BandAfter(TimeSpan elapsed) + { + var (ticket, clock) = Enqueued(); + clock.Advance(elapsed); + return ticket.CurrentBand(clock.GetUtcNow().UtcDateTime); + } + + // ── The 15-second boundary: +/-100 -> +/-200 ───────────────────────────── + + [Fact] + public void AtEnqueue_TheBandIsNarrow() + { + BandAfter(TimeSpan.Zero).Should().Be(MatchmakingBands.NarrowBand); + } + + [Fact] + public void JustBefore15Seconds_TheBandIsStillNarrow() + { + BandAfter(TimeSpan.FromSeconds(15) - TimeSpan.FromMilliseconds(1)) + .Should().Be(MatchmakingBands.NarrowBand); + } + + [Fact] + public void ExactlyAt15Seconds_TheBandHasAlreadyWidenedToMedium() + { + // Half-open [15,30): the boundary instant belongs to the NEW band. + BandAfter(TimeSpan.FromSeconds(15)).Should().Be(MatchmakingBands.MediumBand); + } + + // ── The 30-second boundary: +/-200 -> +/-400 ───────────────────────────── + + [Fact] + public void JustBefore30Seconds_TheBandIsStillMedium() + { + BandAfter(TimeSpan.FromSeconds(30) - TimeSpan.FromMilliseconds(1)) + .Should().Be(MatchmakingBands.MediumBand); + } + + [Fact] + public void ExactlyAt30Seconds_TheBandHasAlreadyWidenedToWide() + { + BandAfter(TimeSpan.FromSeconds(30)).Should().Be(MatchmakingBands.WideBand); + } + + // ── The 60-second deadline: +/-400 -> expired ──────────────────────────── + + [Fact] + public void JustBefore60Seconds_TheTicketStillHasAWideBand() + { + BandAfter(TimeSpan.FromSeconds(60) - TimeSpan.FromMilliseconds(1)) + .Should().Be(MatchmakingBands.WideBand); + } + + [Fact] + public void ExactlyAt60Seconds_TheTicketHasNoBandBecauseItHasExpired() + { + // Expiry, not a wider band, is the terminal outcome. A ticket at its deadline must not be matchable. + BandAfter(TimeSpan.FromSeconds(60)).Should().BeNull(); + } + + [Fact] + public void After60Seconds_TheTicketStillHasNoBand() + { + BandAfter(TimeSpan.FromSeconds(90)).Should().BeNull(); + } + + [Fact] + public void ExactlyAt60Seconds_TheTicketReportsExpired() + { + var (ticket, clock) = Enqueued(); + clock.Advance(TimeSpan.FromSeconds(60)); + + ticket.IsExpired(clock.GetUtcNow().UtcDateTime).Should().BeTrue(); + } + + [Fact] + public void JustBefore60Seconds_TheTicketIsNotYetExpired() + { + var (ticket, clock) = Enqueued(); + clock.Advance(TimeSpan.FromSeconds(60) - TimeSpan.FromMilliseconds(1)); + + ticket.IsExpired(clock.GetUtcNow().UtcDateTime).Should().BeFalse(); + } + + // ── Monotonicity: the anti-starvation guarantee ────────────────────────── + + [Fact] + public void TheBandNeverNarrowsAsATicketAges() + { + // This is the property the anti-starvation argument rests on: a waiting ticket's acceptance window only + // ever grows, so it can never become *harder* to match by waiting longer. + var (ticket, clock) = Enqueued(); + var previous = 0; + + for (var second = 0; second < 60; second++) + { + var band = ticket.CurrentBand(clock.GetUtcNow().UtcDateTime); + band.Should().NotBeNull($"a ticket {second}s old is still inside its 60s deadline"); + band!.Value.Should().BeGreaterThanOrEqualTo(previous, "the band must never narrow as the ticket ages"); + + previous = band.Value; + clock.Advance(TimeSpan.FromSeconds(1)); + } + } + + // ── The rating window derived from the band ────────────────────────────── + + [Fact] + public void TheRatingWindowIsTheRatingPlusOrMinusTheCurrentBand() + { + var (ticket, clock) = Enqueued(); // provisional rating 1200 + + ticket.RatingWindow(clock.GetUtcNow().UtcDateTime).Should().Be((1100, 1300)); + + clock.Advance(TimeSpan.FromSeconds(15)); + ticket.RatingWindow(clock.GetUtcNow().UtcDateTime).Should().Be((1000, 1400)); + + clock.Advance(TimeSpan.FromSeconds(15)); + ticket.RatingWindow(clock.GetUtcNow().UtcDateTime).Should().Be((800, 1600)); + } + + [Fact] + public void AnExpiredTicketHasNoRatingWindow() + { + var (ticket, clock) = Enqueued(); + clock.Advance(TimeSpan.FromSeconds(60)); + + ticket.RatingWindow(clock.GetUtcNow().UtcDateTime).Should().BeNull(); + } + + [Fact] + public void ANegativeAgeIsRejectedRatherThanSilentlyBandedAsNarrow() + { + // A clock that went backwards is a bug, not a ticket that is "very new". Failing loudly here beats + // handing the worker a plausible-looking +/-100 band computed from nonsense. + var act = () => MatchmakingBands.BandFor(TimeSpan.FromSeconds(-1)); + + act.Should().Throw(); + } +} diff --git a/tests/SimPle.UnitTests/Matchmaking/MatchmakingCoordinatorTests.cs b/tests/SimPle.UnitTests/Matchmaking/MatchmakingCoordinatorTests.cs new file mode 100644 index 0000000..0ad4810 --- /dev/null +++ b/tests/SimPle.UnitTests/Matchmaking/MatchmakingCoordinatorTests.cs @@ -0,0 +1,241 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.Lobbies.Services; +using SimPle.Application.Matchmaking.Outbox; +using SimPle.Application.Matchmaking.Services; +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Outbox; +using SimPle.UnitTests.Lobbies; +using Xunit; + +namespace SimPle.UnitTests.Matchmaking; + +/// +/// One matching cycle: the Module 8 gate, the atomic assignment+event commit, and the one-event-per-group rule. +/// +/// The claim race is not asserted here and cannot be — FOR UPDATE SKIP LOCKED and the partial unique +/// index on active assignment are database behavior, and a substituted repository would prove only that the +/// substitute works. Those are proven in MatchmakingPostgresConcurrencyTests against real PostgreSQL. +/// +public sealed class MatchmakingCoordinatorTests +{ + private static readonly DateTime T0 = LobbyTestFactory.T0; + + private readonly IMatchmakingRepository _tickets = Substitute.For(); + private readonly IMatchRuntimeProbe _matchRuntime = Substitute.For(); + private readonly FakeTimeProvider _clock = new(T0); + + private readonly MatchmakingCoordinator _sut; + + public MatchmakingCoordinatorTests() + { + _tickets.GetBlockedPairsAsync(Arg.Any>(), Arg.Any()) + .Returns(Array.Empty<(Guid, Guid)>()); + _tickets.GetOldestQueuedAgeAsync(Arg.Any(), Arg.Any()) + .Returns((TimeSpan?)null); + _tickets.ClaimQueuedTicketsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + + _matchRuntime.IsInActiveMatchAsync(Arg.Any(), Arg.Any()).Returns(false); + + // The honest default: no match runtime, because Module 8 does not exist. + _matchRuntime.IsAvailableAsync(Arg.Any()).Returns(false); + + _sut = new MatchmakingCoordinator( + _tickets, new PassThroughWorkerTransaction(), _matchRuntime, + Options.Create(new MatchmakingOptions()), + _clock, + NullLogger.Instance); + } + + // ── The Module 8 gate ──────────────────────────────────────────────────── + + [Fact] + public async Task WithNoMatchRuntime_TheCycleDoesNotRunAtAll_AndClaimsNothing() + { + // The single most important assertion in this slice. A cycle that ran without M8 would mark tickets Matched + // and emit match requests nobody consumes — the queue UI would show a found opponent and a room the player + // could never enter. That is the fabrication the brief forbids (Risk #6). Before M8 the queue honestly does + // nothing but widen and time out. + Given(TicketFactory.Queued(T0), TicketFactory.Queued(T0)); + + var result = await _sut.RunCycleAsync("worker-1"); + + result.Executed.Should().BeFalse(); + result.TicketsMatched.Should().Be(0); + + await _tickets.DidNotReceive().ClaimQueuedTicketsAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + await _tickets.DidNotReceive().AddAssignmentsAsync( + Arg.Any>(), Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task WithNoMatchRuntime_QueueAgeIsStillReported_SoAStalledQueueIsVisible() + { + _tickets.GetOldestQueuedAgeAsync(Arg.Any(), Arg.Any()) + .Returns(TimeSpan.FromSeconds(42)); + + var result = await _sut.RunCycleAsync("worker-1"); + + result.Executed.Should().BeFalse(); + result.OldestQueuedAge.Should().Be(TimeSpan.FromSeconds(42)); + } + + // ── A live cycle ───────────────────────────────────────────────────────── + + [Fact] + public async Task WithAMatchRuntime_ACompatiblePairIsMatchedAndCommittedAtomically() + { + WithMatchRuntime(); + var a = TicketFactory.Queued(T0); + var b = TicketFactory.Queued(T0); + Given(a, b); + + IReadOnlyList? assignments = null; + IReadOnlyList? events = null; + await _tickets.AddAssignmentsAsync( + Arg.Do>(x => assignments = x), + Arg.Do>(x => events = x), + Arg.Any()); + + var result = await _sut.RunCycleAsync("worker-1"); + + result.Executed.Should().BeTrue(); + result.ProposalsFormed.Should().Be(1); + result.TicketsMatched.Should().Be(2); + + a.State.Should().Be(MatchmakingTicketState.Matched); + b.State.Should().Be(MatchmakingTicketState.Matched); + + // The worker id survives onto the terminal rows — that attribution is what backs the + // matchmaking-worker-failure signal. + a.ClaimedByWorker.Should().Be("worker-1"); + b.ClaimedByWorker.Should().Be("worker-1"); + + assignments.Should().HaveCount(2); + assignments!.Select(x => x.GroupId).Distinct().Should().ContainSingle("both tickets share one group"); + assignments.Select(x => x.MatchRequestId).Distinct().Should().ContainSingle("one group, one match request"); + assignments.Select(x => x.TicketId).Should().BeEquivalentTo(new[] { a.Id, b.Id }); + } + + [Fact] + public async Task AGroupEmitsExactlyOneMatchRequestedEvent_NotOnePerTicket() + { + // The group is what M8 creates a match from. One event per ticket would either make M8 build two matches for + // one proposal, or force it to de-duplicate them itself. + WithMatchRuntime(); + Given(TicketFactory.Queued(T0), TicketFactory.Queued(T0)); + + IReadOnlyList? events = null; + await _tickets.AddAssignmentsAsync( + Arg.Any>(), + Arg.Do>(x => events = x), + Arg.Any()); + + await _sut.RunCycleAsync("worker-1"); + + events.Should().ContainSingle(); + events![0].EventType.Should().Be(MatchmakingOutbox.MatchRequested); + + // Ids only — no rating, no region, no profile snapshot. + events[0].Payload.Should().NotContain("1200"); + events[0].Payload.Should().Contain("matchmaking"); + } + + [Fact] + public async Task ATicketWhoseOwnerEnteredALiveMatchIsSkipped_AndLeftQueued() + { + // The cross-cutting active-participation rule, re-asked at assignment. A player can enter a live match in + // the sixty seconds their ticket is queued; a single up-front check at enqueue would happily assign them a + // second one. + WithMatchRuntime(); + + var busy = TicketFactory.Queued(T0); + var free = TicketFactory.Queued(T0); + Given(busy, free); + + _matchRuntime.IsInActiveMatchAsync(busy.UserId, Arg.Any()).Returns(true); + + var result = await _sut.RunCycleAsync("worker-1"); + + result.TicketsMatched.Should().Be(0); + busy.State.Should().Be(MatchmakingTicketState.Queued); // still in the queue, not punished + free.State.Should().Be(MatchmakingTicketState.Queued); + + await _tickets.DidNotReceive().AddAssignmentsAsync( + Arg.Any>(), Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task BlockedTicketOwnersAreNeverAssignedToEachOther() + { + WithMatchRuntime(); + + var a = TicketFactory.Queued(T0); + var b = TicketFactory.Queued(T0); + Given(a, b); + + _tickets.GetBlockedPairsAsync(Arg.Any>(), Arg.Any()) + .Returns(new[] { (a.UserId, b.UserId) }); + + var result = await _sut.RunCycleAsync("worker-1"); + + result.TicketsMatched.Should().Be(0); + a.State.Should().Be(MatchmakingTicketState.Queued); + b.State.Should().Be(MatchmakingTicketState.Queued); + } + + [Fact] + public async Task AnEmptyQueueCommitsNothing() + { + WithMatchRuntime(); + + var result = await _sut.RunCycleAsync("worker-1"); + + result.Executed.Should().BeTrue(); + result.TicketsClaimed.Should().Be(0); + + await _tickets.DidNotReceive().AddAssignmentsAsync( + Arg.Any>(), Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task AnUnmatchableLoneTicketCommitsNothingAndStaysQueued() + { + WithMatchRuntime(); + var lonely = TicketFactory.Queued(T0); + Given(lonely); + + var result = await _sut.RunCycleAsync("worker-1"); + + result.TicketsClaimed.Should().Be(1); + result.TicketsMatched.Should().Be(0); + lonely.State.Should().Be(MatchmakingTicketState.Queued); + } + + [Fact] + public async Task AnEmptyWorkerIdIsRejected_BecauseAnUnattributedClaimIsUntraceable() + { + var act = async () => await _sut.RunCycleAsync(" "); + + await act.Should().ThrowAsync(); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private void WithMatchRuntime() => + _matchRuntime.IsAvailableAsync(Arg.Any()).Returns(true); + + private void Given(params MatchmakingTicket[] tickets) => + _tickets.ClaimQueuedTicketsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(tickets); +} diff --git a/tests/SimPle.UnitTests/Matchmaking/MatchmakingServiceTests.cs b/tests/SimPle.UnitTests/Matchmaking/MatchmakingServiceTests.cs new file mode 100644 index 0000000..eb333f7 --- /dev/null +++ b/tests/SimPle.UnitTests/Matchmaking/MatchmakingServiceTests.cs @@ -0,0 +1,358 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.Lobbies.Services; +using SimPle.Application.Matchmaking.DTOs; +using SimPle.Application.Matchmaking.Services; +using SimPle.Domain.Games; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Matchmaking; +using SimPle.Domain.Users; +using SimPle.UnitTests.Lobbies; +using Xunit; + +namespace SimPle.UnitTests.Matchmaking; + +/// +/// The Quick Match ticket surface: validation, the cross-table one-active-lobby-or-ticket invariant, privacy-safe +/// not-found, honest rating provenance, and the cancel-versus-claim race. +/// +public sealed class MatchmakingServiceTests +{ + private static readonly DateTime T0 = LobbyTestFactory.T0; + + private readonly IMatchmakingRepository _tickets = Substitute.For(); + private readonly ILobbyRepository _lobbies = Substitute.For(); + private readonly IMatchRuntimeProbe _matchRuntime = Substitute.For(); + private readonly IChatRuntimeProbe _chatRuntime = Substitute.For(); + private readonly IAiParticipantProbe _aiProbe = Substitute.For(); + private readonly IUserRepository _users = Substitute.For(); + private readonly FakeTimeProvider _clock = new(T0); + + private readonly Guid _actor = Guid.NewGuid(); + private readonly Guid _stranger = Guid.NewGuid(); + + private readonly MatchmakingService _sut; + + public MatchmakingServiceTests() + { + // The true state of the platform at Module 6: nothing queued, no lobby, no match runtime. + _tickets.GetActiveTicketForUserAsync(Arg.Any(), Arg.Any()) + .Returns((MatchmakingTicket?)null); + _lobbies.GetActiveLobbyForUserAsync(Arg.Any(), Arg.Any()).Returns((Lobby?)null); + _lobbies.GetCapabilityProfileAsync("chess-lite", 1, Arg.Any()) + .Returns(LobbyTestFactory.Profile()); + _lobbies.GetGameAsync("chess-lite", Arg.Any()).Returns(AvailableGame()); + _lobbies.GetGameModesAsync(Arg.Any(), Arg.Any()) + .Returns(new[] { "multiplayer", "ranked", "ai" }); + + _users.GetByIdAsync(Arg.Any(), Arg.Any()) + .Returns(call => MakeUser(call.Arg())); + + _matchRuntime.IsAvailableAsync(Arg.Any()).Returns(false); + _matchRuntime.IsInActiveMatchAsync(Arg.Any(), Arg.Any()).Returns(false); + _chatRuntime.IsAvailableAsync(Arg.Any()).Returns(false); + _aiProbe.IsAvailableAsync(Arg.Any()).Returns(false); + + _sut = new MatchmakingService( + _tickets, _lobbies, new PassThroughCommandRunner(), + _matchRuntime, _chatRuntime, _aiProbe, _users, + Options.Create(new LobbyCredentialOptions { Key = new string('k', 32), DefaultRegion = "eu-west" }), + _clock, + NullLogger.Instance); + } + + // ── Enqueue works before Module 8 ──────────────────────────────────────── + + [Fact] + public async Task Enqueue_SucceedsWithNoMatchRuntime_AndSaysSoInDependencyReadiness() + { + // The spec is explicit that ticket create/status/cancel and expiry remain fully functional before M8. The + // honesty lives in dependencyReadiness, not in a 503: the player queues, watches the band widen, and times + // out truthfully. Refusing to enqueue would be a different lie — that the feature does not exist. + var result = await _sut.EnqueueAsync(_actor, Request()); + + result.IsSuccess.Should().BeTrue(); + result.Value!.State.Should().Be("Queued"); + result.Value.DependencyReadiness.MatchRuntime.Should().BeFalse(); + + await _tickets.Received(1).AddTicketAsync( + Arg.Is(t => t.UserId == _actor && t.GameSlug == "chess-lite"), + Arg.Any()); + } + + [Fact] + public async Task Enqueue_AlwaysSnapshotsTheProvisionalRating_NeverAClientSuppliedOne() + { + // M10 does not exist. The request DTO has no rating field at all — a client that could name its own rating + // could choose its own opponents. + var result = await _sut.EnqueueAsync(_actor, Request()); + + result.Value!.Rating.Should().Be(1200); + result.Value.RatingSourceVersion.Should().Be("provisional-1200-v1"); + } + + [Fact] + public async Task Enqueue_ResolvesTheRegion_AndNeverPersistsAuto() + { + var result = await _sut.EnqueueAsync(_actor, Request() with { Region = "Auto" }); + + result.IsSuccess.Should().BeTrue(); + result.Value!.ResolvedRegion.Should().Be("eu-west"); + } + + // ── Validation ─────────────────────────────────────────────────────────── + + [Theory] + [InlineData(1)] + [InlineData(9)] + public async Task Enqueue_RejectsAPlayerCountOutsideTheSupportedRange(int playerCount) + { + // Quick Match is a multiplayer queue by definition: a one-player "match" has nobody to find. + var result = await _sut.EnqueueAsync(_actor, Request() with { PlayerCount = playerCount }); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(LobbyErrors.ValidationFailed); + } + + [Fact] + public async Task Enqueue_RejectsAnUnknownMode() + { + var result = await _sut.EnqueueAsync(_actor, Request() with { Mode = "not-a-mode" }); + + result.Error!.Code.Should().Be(LobbyErrors.ValidationFailed); + } + + [Fact] + public async Task Enqueue_RejectsAnUnknownTimeControl() + { + var result = await _sut.EnqueueAsync(_actor, Request() with { TimeControlId = "not-a-time-control" }); + + result.Error!.Code.Should().Be(LobbyErrors.ValidationFailed); + } + + // ── Capability ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Enqueue_FailsClosedWhenThePinnedCapabilityVersionDoesNotExist() + { + _lobbies.GetCapabilityProfileAsync("chess-lite", 99, Arg.Any()) + .Returns((SimPle.Domain.Capabilities.GameCapabilityProfile?)null); + + var result = await _sut.EnqueueAsync(_actor, Request() with { CapabilityVersion = 99 }); + + result.Error!.Code.Should().Be(LobbyErrors.CapabilityDisabled); + } + + [Fact] + public async Task Enqueue_FailsClosedWhenTheProfileIsDeactivatedUnderneathIt() + { + var profile = LobbyTestFactory.Profile(); + profile.Deactivate(); + _lobbies.GetCapabilityProfileAsync("chess-lite", 1, Arg.Any()).Returns(profile); + + var result = await _sut.EnqueueAsync(_actor, Request()); + + result.Error!.Code.Should().Be(LobbyErrors.CapabilityDisabled); + } + + [Fact] + public async Task Enqueue_FailsClosedWhenTheGameIsNotAvailable() + { + _lobbies.GetGameAsync("chess-lite", Arg.Any()).Returns(RetiredGame()); + + var result = await _sut.EnqueueAsync(_actor, Request()); + + result.Error!.Code.Should().Be(LobbyErrors.CapabilityDisabled); + } + + [Fact] + public async Task Enqueue_RejectsARatedTicketForAGameThatIsNotRatedEligible() + { + _lobbies.GetCapabilityProfileAsync("chess-lite", 1, Arg.Any()) + .Returns(LobbyTestFactory.Profile( + allowedModes: new[] { "multiplayer" }, ratedEligible: false, aiFillEligible: false)); + + var result = await _sut.EnqueueAsync(_actor, Request() with { Rated = true }); + + result.Error!.Code.Should().Be(LobbyErrors.CapabilityDisabled); + } + + // ── One active lobby OR one active ticket ──────────────────────────────── + + [Fact] + public async Task Enqueue_IsIdempotent_AnIdenticalLiveTicketIsReturnedRatherThanASecondBeingMinted() + { + // The ticket entity has no idempotency-key column by design (the spec's data model gives one only to + // LobbyStartRequest), so the pool key *is* the key. A retried request must not be punished for a flaky + // network by being told it is "already queued". + var live = TicketFactory.Queued(T0, userId: _actor); + _tickets.GetActiveTicketForUserAsync(_actor, Arg.Any()).Returns(live); + + var result = await _sut.EnqueueAsync(_actor, Request()); + + result.IsSuccess.Should().BeTrue(); + result.Value!.TicketId.Should().Be(live.Id); + + await _tickets.DidNotReceive().AddTicketAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Enqueue_RejectsADifferentTicketWhileOneIsLive() + { + var live = TicketFactory.Queued(T0, userId: _actor, gameSlug: "chess-lite"); + _tickets.GetActiveTicketForUserAsync(_actor, Arg.Any()).Returns(live); + + var result = await _sut.EnqueueAsync(_actor, Request() with { TimeControlId = "rapid-10-0" }); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(MatchmakingErrors.AlreadyQueued); + } + + [Fact] + public async Task Enqueue_RejectsAUserWhoIsAlreadyInALobby() + { + // The cross-table half of the invariant. The two filtered unique indexes cannot see each other, so this + // check inside the command is what covers the gap (Risk #2). + _lobbies.GetActiveLobbyForUserAsync(_actor, Arg.Any()) + .Returns(LobbyTestFactory.Open(_actor, T0)); + + var result = await _sut.EnqueueAsync(_actor, Request()); + + result.Error!.Code.Should().Be(LobbyErrors.AlreadyActive); + await _tickets.DidNotReceive().AddTicketAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Enqueue_RejectsAUserAlreadyInALiveMatch() + { + _matchRuntime.IsInActiveMatchAsync(_actor, Arg.Any()).Returns(true); + + var result = await _sut.EnqueueAsync(_actor, Request()); + + result.Error!.Code.Should().Be(LobbyErrors.AlreadyActive); + } + + // ── Status: privacy-safe not-found (OWASP API1:2023) ───────────────────── + + [Fact] + public async Task GetTicket_AnotherUsersTicketIdIsIndistinguishableFromOneThatNeverExisted() + { + // A 403 here would confirm the id exists. That is the BOLA leak the 404 closes. + var theirs = TicketFactory.Queued(T0, userId: _stranger); + _tickets.GetTicketAsync(theirs.Id, Arg.Any()).Returns(theirs); + _tickets.GetTicketAsync(Arg.Is(id => id != theirs.Id), Arg.Any()) + .Returns((MatchmakingTicket?)null); + + var foreign = await _sut.GetTicketAsync(_actor, theirs.Id); + var missing = await _sut.GetTicketAsync(_actor, Guid.NewGuid()); + + foreign.Error!.Code.Should().Be(MatchmakingErrors.TicketNotFound); + missing.Error!.Code.Should().Be(MatchmakingErrors.TicketNotFound); + foreign.Error.Code.Should().Be(missing.Error.Code); + } + + [Fact] + public async Task GetTicket_ReportsTheBandWideningAsTheTicketAges() + { + var ticket = TicketFactory.Queued(T0, userId: _actor); + _tickets.GetTicketAsync(ticket.Id, Arg.Any()).Returns(ticket); + + (await _sut.GetTicketAsync(_actor, ticket.Id)).Value!.CurrentBand.Should().Be(100); + + _clock.SetUtcNow(T0.AddSeconds(15)); + (await _sut.GetTicketAsync(_actor, ticket.Id)).Value!.CurrentBand.Should().Be(200); + + _clock.SetUtcNow(T0.AddSeconds(30)); + (await _sut.GetTicketAsync(_actor, ticket.Id)).Value!.CurrentBand.Should().Be(400); + + // At the deadline there is no band — there is a terminal outcome. + _clock.SetUtcNow(T0.AddSeconds(60)); + (await _sut.GetTicketAsync(_actor, ticket.Id)).Value!.CurrentBand.Should().BeNull(); + } + + // ── Cancel ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Cancel_WhileQueued_Commits() + { + var ticket = TicketFactory.Queued(T0, userId: _actor); + _tickets.GetTicketForUpdateAsync(ticket.Id, Arg.Any()).Returns(ticket); + + var result = await _sut.CancelAsync(_actor, ticket.Id); + + result.IsSuccess.Should().BeTrue(); + result.Value!.State.Should().Be("Cancelled"); + ticket.State.Should().Be(MatchmakingTicketState.Cancelled); + } + + [Fact] + public async Task Cancel_AfterAWorkerClaim_IsNotAnError_ItReturnsTheCurrentStatus() + { + // Matchmaking.CancelTooLate is a 200, not a 4xx. The user pressed cancel in good faith and the queue simply + // got there first — reporting that as a failure would be blaming them for losing a race they cannot see. + var ticket = TicketFactory.Queued(T0, userId: _actor); + ticket.Claim("worker-1", T0); + _tickets.GetTicketForUpdateAsync(ticket.Id, Arg.Any()).Returns(ticket); + + var result = await _sut.CancelAsync(_actor, ticket.Id); + + result.IsSuccess.Should().BeTrue(); + result.Value!.State.Should().Be("Claimed"); + ticket.State.Should().Be(MatchmakingTicketState.Claimed); // unchanged + + await _tickets.DidNotReceive().SaveAsync( + Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task Cancel_AnotherUsersTicketIsAPrivacySafeNotFound() + { + var theirs = TicketFactory.Queued(T0, userId: _stranger); + _tickets.GetTicketForUpdateAsync(theirs.Id, Arg.Any()).Returns(theirs); + + var result = await _sut.CancelAsync(_actor, theirs.Id); + + result.Error!.Code.Should().Be(MatchmakingErrors.TicketNotFound); + theirs.State.Should().Be(MatchmakingTicketState.Queued); // untouched + } + + // ── Fixtures ───────────────────────────────────────────────────────────── + + private static CreateTicketRequestDto Request() => new( + GameSlug: "chess-lite", + CapabilityVersion: 1, + Mode: "multiplayer", + PlayerCount: 2, + TimeControlId: "blitz-3-2", + Rated: false, + Region: null); + + private static Game AvailableGame() => GameWithLifecycle(GameLifecycle.Available); + + private static Game RetiredGame() => GameWithLifecycle(GameLifecycle.Retired); + + private static Game GameWithLifecycle(GameLifecycle lifecycle) => Game.Create( + slug: "chess-lite", name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: GameDifficulty.Medium, + estimatedDurationMinMinutes: 10, estimatedDurationMaxMinutes: 20, + minPlayers: 2, maxPlayers: 4, initialLifecycle: lifecycle, + featuredRank: null, sortOrder: 1, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", + tags: new[] { "classic", "logic" }, + modes: new[] { "multiplayer", "ranked", "ai" }); + + private static User MakeUser(Guid id) + { + var user = User.Create($"user{id:N}"[..12], $"{id:N}@example.com", "hash", "Test Player"); + typeof(SimPle.Domain.Common.Entity) + .GetProperty(nameof(SimPle.Domain.Common.Entity.Id))! + .SetValue(user, id); + return user; + } +} diff --git a/tests/SimPle.UnitTests/Matchmaking/MatchmakingTestDoubles.cs b/tests/SimPle.UnitTests/Matchmaking/MatchmakingTestDoubles.cs new file mode 100644 index 0000000..f7e0ba5 --- /dev/null +++ b/tests/SimPle.UnitTests/Matchmaking/MatchmakingTestDoubles.cs @@ -0,0 +1,24 @@ +using SimPle.Application.Common.Interfaces; +using SimPle.Shared.Common; + +namespace SimPle.UnitTests.Matchmaking; + +/// +/// A pass-through . +/// +/// The retry and the advisory lock it performs only mean anything against a database that actually enforces unique +/// indexes and row versions, so they are proven in the real-PostgreSQL suite. Substituting a fake that "simulated" +/// contention here would prove only that the fake works. +/// +public sealed class PassThroughCommandRunner : ILobbyCommandRunner +{ + public Task> RunAsync( + Guid actorUserId, Func>> command, CancellationToken ct = default) => + command(ct); +} + +/// Pass-through , for the same reason. +public sealed class PassThroughWorkerTransaction : IWorkerTransaction +{ + public Task RunAsync(Func> work, CancellationToken ct = default) => work(ct); +} diff --git a/tests/SimPle.UnitTests/Matchmaking/MatchmakingTicketTests.cs b/tests/SimPle.UnitTests/Matchmaking/MatchmakingTicketTests.cs new file mode 100644 index 0000000..0f5ce65 --- /dev/null +++ b/tests/SimPle.UnitTests/Matchmaking/MatchmakingTicketTests.cs @@ -0,0 +1,318 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SimPle.Domain.Matchmaking; +using SimPle.UnitTests.Lobbies; + +namespace SimPle.UnitTests.Matchmaking; + +/// +/// Ticket lifecycle: Queued -> Claimed -> Matched|Requeued|Failed, or +/// Queued -> Cancelled|TimedOut, plus the honest provisional rating snapshot. +/// +public class MatchmakingTicketTests +{ + private readonly FakeTimeProvider _clock = new(LobbyTestFactory.T0); + private DateTime Now => _clock.GetUtcNow().UtcDateTime; + + // ── Rating provenance ──────────────────────────────────────────────────── + + [Fact] + public void EveryTicketRecordsTheProvisionalRatingSource() + { + // M10 does not exist. The legacy global User.Elo is deliberately NOT substituted: it is one cross-game + // number, so presenting it as a per-game rating would be a fabricated signal. + var ticket = TicketFactory.Queued(Now); + + ticket.Rating.Should().Be(1200); + ticket.RatingSourceVersion.Should().Be("provisional-1200-v1"); + } + + [Fact] + public void ATicketSnapshotsItsCandidatePoolKeyAtEnqueue() + { + var ticket = TicketFactory.Queued(Now, gameSlug: "checkers", mode: "ranked", timeControlId: "rapid-10-0", + rated: true, resolvedRegion: "us-east"); + + ticket.GameSlug.Should().Be("checkers"); + ticket.CapabilityVersion.Should().Be(1); + ticket.Mode.Should().Be("ranked"); + ticket.TimeControlId.Should().Be("rapid-10-0"); + ticket.Rated.Should().BeTrue(); + ticket.ResolvedRegion.Should().Be("us-east"); + ticket.EnqueuedAtUtc.Should().Be(LobbyTestFactory.T0); + ticket.DeadlineAtUtc.Should().Be(LobbyTestFactory.T0.AddSeconds(60)); + } + + [Fact] + public void ATicketCannotBeEnqueuedWithAnUnresolvedRegion() + { + var act = () => TicketFactory.Queued(Now, resolvedRegion: "Auto"); + + act.Should().Throw().WithMessage("*Auto*"); + } + + // ── Claim ──────────────────────────────────────────────────────────────── + + [Fact] + public void AQueuedTicketCanBeClaimed() + { + var ticket = TicketFactory.Queued(Now); + + ticket.Claim("worker-1", Now).Should().Be(MatchmakingOutcome.Ok); + + ticket.State.Should().Be(MatchmakingTicketState.Claimed); + ticket.ClaimedByWorker.Should().Be("worker-1"); + ticket.ClaimedAtUtc.Should().Be(Now); + } + + [Fact] + public void AnAlreadyClaimedTicketCannotBeClaimedAgain() + { + var ticket = TicketFactory.Queued(Now); + ticket.Claim("worker-1", Now); + + ticket.Claim("worker-2", Now).Should().Be(MatchmakingOutcome.AlreadyClaimed); + + ticket.ClaimedByWorker.Should().Be("worker-1", "the first claim holds"); + } + + [Fact] + public void AnExpiredTicketCannotBeClaimed() + { + // The expiry sweep owns it now; claiming it would hand a worker a ticket that has already run out the clock. + var ticket = TicketFactory.Queued(Now); + _clock.Advance(TimeSpan.FromSeconds(60)); + + ticket.Claim("worker-1", Now).Should().Be(MatchmakingOutcome.Expired); + ticket.State.Should().Be(MatchmakingTicketState.Queued); + } + + // ── Matched ────────────────────────────────────────────────────────────── + + [Fact] + public void AClaimedTicketCanBeMatched() + { + var ticket = TicketFactory.Queued(Now); + ticket.Claim("worker-1", Now); + + ticket.MarkMatched(Now).Should().Be(MatchmakingOutcome.Ok); + + ticket.State.Should().Be(MatchmakingTicketState.Matched); + ticket.ResolvedAtUtc.Should().Be(Now); + } + + [Fact] + public void AMatchedTicketKeepsItsWorkerAttribution() + { + // The database CHECK only forbids a worker id on a *Queued* row. Terminal rows keep it, because it is the + // attribution behind the matchmaking-worker-failure signal. + var ticket = TicketFactory.Queued(Now); + ticket.Claim("worker-1", Now); + ticket.MarkMatched(Now); + + ticket.ClaimedByWorker.Should().Be("worker-1"); + } + + [Fact] + public void AQueuedTicketCannotJumpStraightToMatched() + { + var ticket = TicketFactory.Queued(Now); + + ticket.MarkMatched(Now).Should().Be(MatchmakingOutcome.InvalidTransition); + } + + // ── Requeue ────────────────────────────────────────────────────────────── + + [Fact] + public void ARequeueReturnsTheTicketToTheQueueAndClearsTheWorker() + { + var ticket = TicketFactory.Queued(Now); + ticket.Claim("worker-1", Now); + + _clock.Advance(TimeSpan.FromSeconds(5)); + ticket.Requeue(Now).Should().Be(MatchmakingOutcome.Ok); + + ticket.State.Should().Be(MatchmakingTicketState.Queued); + ticket.ClaimedByWorker.Should().BeNull("a queued ticket naming a worker would mean a claim leaked"); + ticket.ClaimedAtUtc.Should().BeNull(); + ticket.RetryBudget.Should().Be(MatchmakingTicket.DefaultRetryBudget - 1); + } + + [Fact] + public void ARequeueNeverExtendsTheOriginalDeadline() + { + // "A failed M8 handoff requeues before the original deadline" — a retry must not buy the ticket more time, + // or a persistently failing handoff could keep one ticket alive indefinitely. + var ticket = TicketFactory.Queued(Now); + var originalDeadline = ticket.DeadlineAtUtc; + + ticket.Claim("worker-1", Now); + _clock.Advance(TimeSpan.FromSeconds(10)); + ticket.Requeue(Now); + + ticket.DeadlineAtUtc.Should().Be(originalDeadline); + } + + [Fact] + public void WhenTheRetryBudgetRunsOut_TheTicketFails() + { + var ticket = TicketFactory.Queued(Now); + + for (var attempt = 0; attempt < MatchmakingTicket.DefaultRetryBudget; attempt++) + { + ticket.Claim($"worker-{attempt}", Now).Should().Be(MatchmakingOutcome.Ok); + ticket.Requeue(Now).Should().Be(MatchmakingOutcome.Ok); + } + + ticket.RetryBudget.Should().Be(0); + + ticket.Claim("worker-final", Now).Should().Be(MatchmakingOutcome.Ok); + ticket.Requeue(Now).Should().Be(MatchmakingOutcome.RetryBudgetExhausted); + + ticket.State.Should().Be(MatchmakingTicketState.Failed); + } + + [Fact] + public void ARequeuePastTheDeadlineTimesOutInsteadOfReturningToTheQueue() + { + var ticket = TicketFactory.Queued(Now); + ticket.Claim("worker-1", Now); + + _clock.Advance(TimeSpan.FromSeconds(60)); + + ticket.Requeue(Now).Should().Be(MatchmakingOutcome.Expired); + ticket.State.Should().Be(MatchmakingTicketState.TimedOut, "there is nothing left to retry into"); + } + + // ── Cancel ─────────────────────────────────────────────────────────────── + + [Fact] + public void AQueuedTicketCanBeCancelled() + { + var ticket = TicketFactory.Queued(Now); + + ticket.Cancel(Now).Should().Be(MatchmakingOutcome.Ok); + + ticket.State.Should().Be(MatchmakingTicketState.Cancelled); + } + + [Fact] + public void ACancelAfterAClaimIsTooLateButIsNotAnError() + { + // Per the error catalogue, Matchmaking.CancelTooLate is a 200: the caller returns the ticket's current + // status rather than failing. The user is already being matched; there is nothing honest to cancel. + var ticket = TicketFactory.Queued(Now); + ticket.Claim("worker-1", Now); + + ticket.Cancel(Now).Should().Be(MatchmakingOutcome.AlreadyClaimed); + + ticket.State.Should().Be(MatchmakingTicketState.Claimed, "the cancel did not take effect"); + } + + [Fact] + public void CancellingATerminalTicketIsRejected() + { + var ticket = TicketFactory.Queued(Now); + ticket.Cancel(Now); + + ticket.Cancel(Now).Should().Be(MatchmakingOutcome.Terminal); + } + + // ── Expiry sweep ───────────────────────────────────────────────────────── + + [Fact] + public void TheExpirySweepTimesOutADueTicket() + { + var ticket = TicketFactory.Queued(Now); + _clock.Advance(TimeSpan.FromSeconds(60)); + + ticket.TryTimeOut(Now).Should().BeTrue(); + + ticket.State.Should().Be(MatchmakingTicketState.TimedOut); + ticket.ResolvedAtUtc.Should().Be(Now); + } + + [Fact] + public void TheExpirySweepIsIdempotent() + { + var ticket = TicketFactory.Queued(Now); + _clock.Advance(TimeSpan.FromSeconds(60)); + ticket.TryTimeOut(Now).Should().BeTrue(); + + ticket.TryTimeOut(Now).Should().BeFalse("a re-run over an already-terminal ticket is a no-op"); + } + + [Fact] + public void TheExpirySweepDoesNotTouchATicketThatIsNotYetDue() + { + var ticket = TicketFactory.Queued(Now); + _clock.Advance(TimeSpan.FromSeconds(59)); + + ticket.TryTimeOut(Now).Should().BeFalse(); + ticket.State.Should().Be(MatchmakingTicketState.Queued); + } + + [Fact] + public void TheExpirySweepDoesNotResurrectACancelledTicket() + { + var ticket = TicketFactory.Queued(Now); + ticket.Cancel(Now); + + _clock.Advance(TimeSpan.FromSeconds(60)); + + ticket.TryTimeOut(Now).Should().BeFalse(); + ticket.State.Should().Be(MatchmakingTicketState.Cancelled, "a cancelled ticket must not become TimedOut"); + } +} + +public class MatchmakingAssignmentTests +{ + private static readonly DateTime T0 = LobbyTestFactory.T0; + + [Fact] + public void AnAssignmentStartsActive() + { + var assignment = MatchmakingAssignment.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), T0); + + assignment.IsActive.Should().BeTrue(); + assignment.CreatedAtUtc.Should().Be(T0); + } + + [Fact] + public void SupersedingReleasesTheActiveSlotSoTheTicketCanBeAssignedAgain() + { + // The partial unique index only counts Active rows, so superseding is what makes a legitimate re-assignment + // possible without ever permitting two live assignments for one ticket. + var assignment = MatchmakingAssignment.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), T0); + + assignment.Supersede(T0).Should().Be(MatchmakingOutcome.Ok); + + assignment.State.Should().Be(MatchmakingAssignmentState.Superseded); + assignment.IsActive.Should().BeFalse(); + assignment.ResolvedAtUtc.Should().Be(T0); + } + + [Fact] + public void ATerminalAssignmentCannotBeResolvedTwice() + { + var assignment = MatchmakingAssignment.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), T0); + assignment.Supersede(T0); + + assignment.Supersede(T0).Should().Be(MatchmakingOutcome.InvalidTransition); + assignment.MarkFailed(T0).Should().Be(MatchmakingOutcome.InvalidTransition); + assignment.State.Should().Be(MatchmakingAssignmentState.Superseded); + } + + [Fact] + public void EveryTicketInOneProposalSharesAGroupId() + { + var groupId = Guid.NewGuid(); + var matchRequestId = Guid.NewGuid(); + + var a = MatchmakingAssignment.Create(Guid.NewGuid(), matchRequestId, groupId, T0); + var b = MatchmakingAssignment.Create(Guid.NewGuid(), matchRequestId, groupId, T0); + + a.GroupId.Should().Be(b.GroupId); + a.MatchRequestId.Should().Be(b.MatchRequestId, "one proposal hands off as one match request"); + } +} diff --git a/tests/SimPle.UnitTests/Matchmaking/OutboxProcessorTests.cs b/tests/SimPle.UnitTests/Matchmaking/OutboxProcessorTests.cs new file mode 100644 index 0000000..d952b9c --- /dev/null +++ b/tests/SimPle.UnitTests/Matchmaking/OutboxProcessorTests.cs @@ -0,0 +1,217 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.Friends.Outbox; +using SimPle.Application.Outbox; +using SimPle.Domain.Outbox; +using SimPle.UnitTests.Lobbies; +using Xunit; + +namespace SimPle.UnitTests.Matchmaking; + +/// +/// The outbox dispatcher (D3) — the codebase's first: leasing, at-least-once delivery, the bounded +/// retry budget, and dead-lettering. +/// +/// The concurrent-lease behavior (FOR UPDATE SKIP LOCKED across two dispatchers) is database behavior and is +/// proven against real PostgreSQL. What is proven here is the bookkeeping that decides whether an event is ever +/// delivered twice, lost, or retried forever. +/// +public sealed class OutboxProcessorTests +{ + private static readonly DateTime T0 = LobbyTestFactory.T0; + + private readonly IOutboxRepository _outbox = Substitute.For(); + private readonly FakeTimeProvider _clock = new(T0); + private readonly OutboxOptions _options = new() { MaxAttempts = 3 }; + + private readonly OutboxProcessor _sut; + + public OutboxProcessorTests() + { + _outbox.LeaseAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Array.Empty<(OutboxMessage, OutboxDelivery)>()); + + _outbox.GetOldestPendingAgeAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), + Arg.Any()) + .Returns((TimeSpan?)null); + + _sut = new OutboxProcessor( + _outbox, new PassThroughWorkerTransaction(), Options.Create(_options), _clock, + NullLogger.Instance); + } + + [Fact] + public async Task ASuccessfullyHandledEventIsMarkedProcessed() + { + var (message, delivery) = Leased(); + var handler = new SpyHandler(); + + var result = await _sut.DispatchAsync(handler); + + handler.Handled.Should().ContainSingle().Which.Should().Be(message.Id); + delivery.Processed.Should().BeTrue(); + delivery.DeadLettered.Should().BeFalse(); + + result.Processed.Should().Be(1); + result.Failed.Should().Be(0); + + await _outbox.Received(1).SaveAsync(Arg.Any()); + } + + [Fact] + public async Task AHandlerThatThrowsIsRetried_NotDeadLettered_WhileItsBudgetRemains() + { + var (_, delivery) = Leased(); // AcquireLease already counted attempt 1 + var handler = new ThrowingHandler(); + + var result = await _sut.DispatchAsync(handler); + + delivery.Processed.Should().BeFalse(); + delivery.DeadLettered.Should().BeFalse(); + result.Failed.Should().Be(1); + result.DeadLettered.Should().Be(0); + } + + [Fact] + public async Task AHandlerThatKeepsThrowingIsDeadLetteredOnceTheBudgetIsSpent() + { + // Bounded on purpose: a permanently-broken handler retried forever would crowd out every other event in the + // batch, which is the starvation the per-handler delivery row exists to prevent. + var (_, delivery) = Leased(attemptsAlreadyMade: _options.MaxAttempts - 1); + var handler = new ThrowingHandler(); + + var result = await _sut.DispatchAsync(handler); + + delivery.AttemptCount.Should().Be(_options.MaxAttempts); + delivery.DeadLettered.Should().BeTrue(); + result.DeadLettered.Should().Be(1); + } + + [Fact] + public async Task ADeadLetteredDeliveryNeverRecordsTheExceptionMessage() + { + // A dead-letter row is long-lived and widely read — exactly the kind of place PII quietly accumulates. The + // handler's exception message could carry an event body or a user id; only its type and the attempt count + // are safe to keep. + var (_, delivery) = Leased(attemptsAlreadyMade: _options.MaxAttempts - 1); + + await _sut.DispatchAsync(new ThrowingHandler("user bob@example.com is in lobby 7")); + + delivery.LastError.Should().NotBeNull(); + delivery.LastError.Should().NotContain("bob@example.com"); + delivery.LastError.Should().Contain(nameof(InvalidOperationException)); + } + + [Fact] + public async Task OneEventFailingDoesNotPreventAnotherFromBeingProcessed() + { + var good = Message(); + var bad = Message(); + var goodDelivery = OutboxDelivery.Create(good.Id, "spy"); + var badDelivery = OutboxDelivery.Create(bad.Id, "spy"); + goodDelivery.AcquireLease(T0.AddMinutes(1)); + badDelivery.AcquireLease(T0.AddMinutes(1)); + + GivenLeased((good, goodDelivery), (bad, badDelivery)); + + var handler = new SelectivelyThrowingHandler(bad.Id); + + var result = await _sut.DispatchAsync(handler); + + goodDelivery.Processed.Should().BeTrue(); + badDelivery.Processed.Should().BeFalse(); + result.Processed.Should().Be(1); + result.Failed.Should().Be(1); + } + + [Fact] + public async Task AHandlerWithNoEventTypesIsNeverLeasedFor() + { + var result = await _sut.DispatchAsync(new NoTypesHandler()); + + result.Should().Be(OutboxDispatchResult.Idle); + await _outbox.DidNotReceive().LeaseAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); + } + + // ── Fixtures ───────────────────────────────────────────────────────────── + + private (OutboxMessage Message, OutboxDelivery Delivery) Leased(int attemptsAlreadyMade = 0) + { + var message = Message(); + var delivery = OutboxDelivery.Create(message.Id, "spy"); + + // Every prior attempt left its mark. AcquireLease is what increments the count, so replaying it is exactly + // how a delivery that has already failed N times arrives at this pass. + for (var i = 0; i < attemptsAlreadyMade; i++) delivery.AcquireLease(T0); + + delivery.AcquireLease(T0.AddMinutes(1)); // this pass's lease + + GivenLeased((message, delivery)); + return (message, delivery); + } + + private void GivenLeased(params (OutboxMessage, OutboxDelivery)[] rows) => + _outbox.LeaseAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(rows); + + private static OutboxMessage Message() => OutboxMessage.Create( + "Block", Guid.NewGuid(), FriendOutbox.UserBlocked, 1, 1, 1, "{}"); + + private class SpyHandler : IOutboxHandler + { + public List Handled { get; } = new(); + public string HandlerName => "spy"; + public IReadOnlyList EventTypes { get; } = new[] { FriendOutbox.UserBlocked }; + + public Task HandleAsync(OutboxMessage message, CancellationToken ct = default) + { + Handled.Add(message.Id); + return Task.CompletedTask; + } + } + + private sealed class ThrowingHandler : IOutboxHandler + { + private readonly string _message; + public ThrowingHandler(string message = "boom") => _message = message; + + public string HandlerName => "spy"; + public IReadOnlyList EventTypes { get; } = new[] { FriendOutbox.UserBlocked }; + + public Task HandleAsync(OutboxMessage message, CancellationToken ct = default) => + throw new InvalidOperationException(_message); + } + + private sealed class SelectivelyThrowingHandler : IOutboxHandler + { + private readonly Guid _failOn; + public SelectivelyThrowingHandler(Guid failOn) => _failOn = failOn; + + public string HandlerName => "spy"; + public IReadOnlyList EventTypes { get; } = new[] { FriendOutbox.UserBlocked }; + + public Task HandleAsync(OutboxMessage message, CancellationToken ct = default) => + message.Id == _failOn + ? throw new InvalidOperationException("boom") + : Task.CompletedTask; + } + + private sealed class NoTypesHandler : IOutboxHandler + { + public string HandlerName => "none"; + public IReadOnlyList EventTypes { get; } = Array.Empty(); + public Task HandleAsync(OutboxMessage message, CancellationToken ct = default) => Task.CompletedTask; + } +} diff --git a/tests/SimPle.UnitTests/SimPle.UnitTests.csproj b/tests/SimPle.UnitTests/SimPle.UnitTests.csproj index f47d6b6..bd44798 100644 --- a/tests/SimPle.UnitTests/SimPle.UnitTests.csproj +++ b/tests/SimPle.UnitTests/SimPle.UnitTests.csproj @@ -13,6 +13,9 @@ + +