creating/joining/leaving works now finally

This commit is contained in:
Exil Productions
2025-09-12 19:15:37 +02:00
parent 3f46c6f5a5
commit 1e8b375bb9
2 changed files with 164 additions and 56 deletions

View File

@@ -8,26 +8,18 @@ using Microsoft.OpenApi.Models;
using PurrLobby.Services;
using PurrLobby.Models;
// boot app
var builder = WebApplication.CreateBuilder(args);
// problem details
builder.Services.AddProblemDetails();
// basic http logging
builder.Services.AddHttpLogging(o =>
{
o.LoggingFields = HttpLoggingFields.RequestScheme | HttpLoggingFields.RequestMethod | HttpLoggingFields.RequestPath | HttpLoggingFields.ResponseStatusCode;
});
// compression brotli gzip
builder.Services.AddResponseCompression(o =>
{
o.Providers.Add<BrotliCompressionProvider>();
o.Providers.Add<GzipCompressionProvider>();
});
// rate limit per ip
builder.Services.AddRateLimiter(o =>
{
o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
@@ -42,17 +34,18 @@ builder.Services.AddRateLimiter(o =>
});
});
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.OnRejected = async (context, ct) =>
{
context.HttpContext.Response.Headers.RetryAfter = "60";
await context.HttpContext.Response.WriteAsync("Woah there partner calm down, you sending to much info :O", ct);
};
});
// trust proxy headers
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
// swagger setup
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(o =>
{
@@ -72,6 +65,14 @@ builder.Services.AddSwaggerGen(o =>
Description = "Game scope cookie set by POST /session/game."
});
o.AddSecurityDefinition("userIdCookie", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Cookie,
Name = "userId",
Description = "Player identity cookie generated by the server (POST /session/user)."
});
o.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
@@ -83,14 +84,9 @@ builder.Services.AddSwaggerGen(o =>
}
});
});
// service singletons
builder.Services.AddSingleton<ILobbyEventHub, LobbyEventHub>();
builder.Services.AddSingleton<ILobbyService, LobbyService>();
var app = builder.Build();
// prod vs dev
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler();
@@ -101,39 +97,32 @@ else
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
// middleware order
app.UseForwardedHeaders();
app.UseResponseCompression();
app.UseHttpLogging();
app.UseRateLimiter();
app.UseWebSockets();
// static files
app.UseDefaultFiles();
app.UseStaticFiles();
// swagger ui
app.UseSwagger();
app.UseSwaggerUI();
// set gameId cookie
app.MapPost("/session/game", (HttpContext http, SetGameRequest req) =>
static CookieOptions BuildStdCookieOptions() => new()
{
var gameId = req.GameId;
if (gameId == Guid.Empty)
return Results.BadRequest("Invalid GameId");
var opts = new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
IsEssential = true,
Expires = DateTimeOffset.UtcNow.AddDays(7),
Domain = "purrlobby.exil.dev"
};
http.Response.Cookies.Append("gameId", gameId.ToString(), opts);
};
// set / update gameId cookie
app.MapPost("/session/game", (HttpContext http, SetGameRequest req) =>
{
var gameId = req.GameId;
if (gameId == Guid.Empty)
return Results.BadRequest("Invalid GameId");
http.Response.Cookies.Append("gameId", gameId.ToString(), BuildStdCookieOptions());
return Results.Ok(new { message = "GameId stored" });
})
.WithTags("Session")
@@ -149,7 +138,27 @@ app.MapPost("/session/game", (HttpContext http, SetGameRequest req) =>
.Produces(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status400BadRequest);
// cookie helper
// create / ensure userId cookie
app.MapPost("/session/user", (HttpContext http) =>
{
if (!http.Request.Cookies.TryGetValue("userId", out var existing) || string.IsNullOrWhiteSpace(existing))
{
existing = Guid.NewGuid().ToString("N");
http.Response.Cookies.Append("userId", existing, BuildStdCookieOptions());
}
return Results.Ok(new { userId = existing });
})
.WithTags("Session")
.WithOpenApi(op =>
{
op.Summary = "Ensure a server-generated user id";
op.Description = "Creates (if absent) and returns the server-generated 'userId' cookie used to identify the player. No input required.";
op.Security.Clear();
return op;
})
.Produces(StatusCodes.Status200OK);
// cookie helpers
static bool TryGetGameIdFromCookie(HttpRequest request, out Guid gameId)
{
gameId = Guid.Empty;
@@ -157,20 +166,38 @@ static bool TryGetGameIdFromCookie(HttpRequest request, out Guid gameId)
return Guid.TryParse(v, out gameId);
}
static string EnsureUserIdCookie(HttpContext http)
{
if (http.Request.Cookies.TryGetValue("userId", out var v) && !string.IsNullOrWhiteSpace(v))
return v;
var newId = Guid.NewGuid().ToString("N");
http.Response.Cookies.Append("userId", newId, BuildStdCookieOptions());
return newId;
}
// create lobby
app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLobbyRequest req, CancellationToken ct) =>
{
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie");
if (req.MaxPlayers <= 0) return Results.BadRequest("MaxPlayers must be > 0");
var lobby = await service.CreateLobbyAsync(gameId, req.OwnerUserId, req.OwnerDisplayName, req.MaxPlayers, req.Properties, ct);
if (string.IsNullOrWhiteSpace(req.OwnerDisplayName)) return Results.BadRequest("OwnerDisplayName required");
try
{
var ownerUserId = EnsureUserIdCookie(http);
var lobby = await service.CreateLobbyAsync(gameId, ownerUserId, req.OwnerDisplayName, req.MaxPlayers, req.Properties, ct);
return Results.Ok(lobby);
}
catch (ArgumentException ax)
{
return Results.BadRequest(ax.Message);
}
})
.WithTags("Lobbies")
.WithOpenApi(op =>
{
op.Summary = "Create a lobby";
op.Description = "Creates a new lobby for the current game (scoped by the 'gameId' cookie). The creator is added as the owner and first member.";
op.Description = "Creates a new lobby for the current game. Owner user id is server generated (cookie 'userId').";
return op;
})
.Accepts<CreateLobbyRequest>("application/json")
@@ -183,13 +210,15 @@ app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService se
{
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie");
var lobby = await service.JoinLobbyAsync(gameId, lobbyId, req.UserId, req.DisplayName, ct);
if (string.IsNullOrWhiteSpace(req.DisplayName)) return Results.BadRequest("DisplayName required");
var userId = EnsureUserIdCookie(http);
var lobby = await service.JoinLobbyAsync(gameId, lobbyId, userId, req.DisplayName, ct);
return lobby is null ? Results.NotFound() : Results.Ok(lobby);
}).WithTags("Lobbies")
.WithOpenApi(op =>
{
op.Summary = "Join a lobby";
op.Description = "Adds the user to the specified lobby if it belongs to the current game and has capacity.";
op.Description = "Adds the current cookie user (server generated userId) to the specified lobby.";
return op;
})
.Accepts<JoinLobbyRequest>("application/json")
@@ -199,20 +228,20 @@ app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService se
.ProducesProblem(StatusCodes.Status400BadRequest);
// leave lobby by id
app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService service, string lobbyId, LeaveLobbyRequest req, CancellationToken ct) =>
app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) =>
{
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie");
var ok = await service.LeaveLobbyAsync(gameId, lobbyId, req.UserId, ct);
var userId = EnsureUserIdCookie(http);
var ok = await service.LeaveLobbyAsync(gameId, lobbyId, userId, ct);
return ok ? Results.Ok() : Results.NotFound();
}).WithTags("Lobbies")
.WithOpenApi(op =>
{
op.Summary = "Leave a lobby";
op.Description = "Removes the user from the lobby. If the owner leaves, ownership transfers to the first remaining member. Empty lobbies are deleted.";
op.Description = "Removes the current cookie user from the lobby.";
return op;
})
.Accepts<LeaveLobbyRequest>("application/json")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest)
@@ -229,7 +258,7 @@ app.MapPost("/users/{userId}/leave", async (HttpContext http, ILobbyService serv
.WithOpenApi(op =>
{
op.Summary = "Force a user to leave their lobby";
op.Description = "Removes the user from whichever lobby they are currently in for the current game.";
op.Description = "Removes the specified user from whichever lobby they are currently in for the current game.";
return op;
})
.Produces(StatusCodes.Status200OK)
@@ -264,6 +293,26 @@ app.MapGet("/lobbies/search", async (HttpContext http, ILobbyService service, in
.Produces(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status400BadRequest);
// get lobby by id
app.MapGet("/lobbies/{lobbyId}", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) =>
{
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie");
var userId = EnsureUserIdCookie(http);
var lobby = await service.GetLobbyAsync(gameId, lobbyId, userId, ct);
return lobby is null ? Results.NotFound() : Results.Ok(lobby);
})
.WithTags("Lobbies")
.WithOpenApi(op =>
{
op.Summary = "Get lobby details";
op.Description = "Returns the lobby object including ownership flag relative to current cookie user.";
return op;
})
.Produces<Lobby>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
// lobby members
app.MapGet("/lobbies/{lobbyId}/members", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) =>
{
@@ -331,7 +380,7 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s
{
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie");
if (string.IsNullOrWhiteSpace(req.UserId)) return Results.BadRequest("Id is required");
if (string.IsNullOrWhiteSpace(req.UserId)) return Results.BadRequest("UserId required");
var ok = await service.SetIsReadyAsync(gameId, lobbyId, req.UserId, req.IsReady, ct);
return ok ? Results.Ok() : Results.NotFound();
})
@@ -339,7 +388,7 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s
.WithOpenApi(op =>
{
op.Summary = "Set member ready state";
op.Description = "Sets the readiness of a member in the specified lobby within the current game. Broadcasts a member_ready event to subscribers.";
op.Description = "Sets readiness for the specified user in the lobby. Request body must include userId and isReady. Broadcasts a member_ready event.";
return op;
})
.Accepts<ReadyRequest>("application/json")
@@ -348,6 +397,55 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s
.Produces(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status400BadRequest);
// set all ready
app.MapPost("/lobbies/{lobbyId}/ready/all", async (HttpContext http, ILobbyService service, string lobbyId, AllReadyRequest req, CancellationToken ct) =>
{
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie");
if (string.IsNullOrWhiteSpace(req.UserId)) return Results.BadRequest("UserId required");
var lobby = await service.GetLobbyAsync(gameId, lobbyId, req.UserId, ct);
if (lobby is null) return Results.NotFound();
if (!lobby.IsOwner) return Results.StatusCode(StatusCodes.Status403Forbidden);
var ok = await service.SetAllReadyAsync(gameId, lobbyId, ct);
return ok ? Results.Ok() : Results.BadRequest();
})
.WithTags("Lobbies")
.WithOpenApi(op =>
{
op.Summary = "Mark all members ready";
op.Description = "Owner only. Sets all members' ready state to true and broadcasts all_ready. Requires userId in body identifying the acting owner.";
return op;
})
.Accepts<AllReadyRequest>("application/json")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
// start lobby
app.MapPost("/lobbies/{lobbyId}/start", async (HttpContext http, ILobbyService service, string lobbyId, CancellationToken ct) =>
{
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie");
var userId = EnsureUserIdCookie(http);
var lobby = await service.GetLobbyAsync(gameId, lobbyId, userId, ct);
if (lobby is null) return Results.NotFound();
if (!lobby.IsOwner) return Results.StatusCode(StatusCodes.Status403Forbidden);
var ok = await service.SetLobbyStartedAsync(gameId, lobbyId, ct);
return ok ? Results.Ok() : Results.BadRequest();
})
.WithTags("Lobbies")
.WithOpenApi(op =>
{
op.Summary = "Start lobby";
op.Description = "Owner only. Marks lobby as started and broadcasts lobby_started.";
return op;
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
// lobby websocket
app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobbyEventHub hub, CancellationToken ct) =>
{
@@ -357,9 +455,7 @@ app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobby
if (!TryGetGameIdFromCookie(http.Request, out var gameId))
return Results.BadRequest("Missing or invalid gameId cookie");
var userId = http.Request.Query["userId"].ToString();
if (string.IsNullOrWhiteSpace(userId))
return Results.BadRequest("Missing userId query parameter");
var userId = EnsureUserIdCookie(http);
using var socket = await http.WebSockets.AcceptWebSocketAsync();
await hub.HandleConnectionAsync(gameId, lobbyId, userId, socket, ct);
@@ -367,7 +463,7 @@ app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobby
}).WithTags("Lobbies").WithOpenApi(op =>
{
op.Summary = "Lobby Websocket";
op.Description = "The Websocket that is used to recive lobby specifc updates";
op.Description = "WebSocket for lobby-specific updates. Uses server-generated 'userId' cookie.";
return op;
});
@@ -406,8 +502,8 @@ app.Run();
// dto records
public record SetGameRequest(Guid GameId);
public record CreateLobbyRequest(string OwnerUserId, string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties);
public record JoinLobbyRequest(string UserId, string DisplayName);
public record LeaveLobbyRequest(string UserId);
public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties);
public record JoinLobbyRequest(string DisplayName);
public record ReadyRequest(string UserId, bool IsReady);
public record AllReadyRequest(string UserId);
public record LobbyDataRequest(string Key, string Value);

View File

@@ -17,6 +17,7 @@ public interface ILobbyService
Task<List<LobbyUser>> GetLobbyMembersAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
Task<bool> SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default);
// stats
Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default);
@@ -363,6 +364,17 @@ public class LobbyService : ILobbyService
return Task.FromResult(true);
}
public Task<Lobby?> GetLobbyAsync(Guid gameId, string lobbyId, string currentUserId, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(currentUserId))
return Task.FromResult<Lobby?>(null);
if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult<Lobby?>(null);
if (state.GameId != gameId)
return Task.FromResult<Lobby?>(null);
return Task.FromResult<Lobby?>(Project(state, currentUserId));
}
public Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default)
{
var total = 0;