creating/joining/leaving works now finally
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user