diff --git a/PurrLobby.sln b/PurrLobby.sln new file mode 100644 index 0000000..12e0520 --- /dev/null +++ b/PurrLobby.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36414.22 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PurrLobby", "PurrLobby\PurrLobby.csproj", "{C35670F9-C25D-4E8D-B301-D15B4BCE2F7F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C35670F9-C25D-4E8D-B301-D15B4BCE2F7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C35670F9-C25D-4E8D-B301-D15B4BCE2F7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C35670F9-C25D-4E8D-B301-D15B4BCE2F7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C35670F9-C25D-4E8D-B301-D15B4BCE2F7F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CB9BCFBF-0A3B-4C31-A17D-10BD76661490} + EndGlobalSection +EndGlobal diff --git a/PurrLobby/ILobbyProvider.cs b/PurrLobby/ILobbyProvider.cs new file mode 100644 index 0000000..4e73789 --- /dev/null +++ b/PurrLobby/ILobbyProvider.cs @@ -0,0 +1,354 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.Networking; +using Newtonsoft.Json; +using WebSocketSharp; +using PurrNet.Logging; +using PurrLobby; + +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace PurrLobby.Providers +{ + public class PurrLobbyProvider : MonoBehaviour, ILobbyProvider + { + [Header("API Configuration")] + public string apiBaseUrl = "https://purrlobby.exil.dev"; + public string wsBaseUrl = "wss://purrlobby.exil.dev"; + public float requestTimeout = 10f; + + [Tooltip("Must be a valid GUID that identifies your game")] + public string gameId = ""; + + [Header("Local Player")] + public string playerName = "Player"; + + private string localUserId; + private Lobby? currentLobby; + private string gameCookie; + private WebSocket ws; + + // ---- Unity Events ---- + public event UnityAction OnLobbyJoinFailed; + public event UnityAction OnLobbyLeft; + public event UnityAction OnLobbyUpdated; + public event UnityAction> OnLobbyPlayerListUpdated; + public event UnityAction> OnFriendListPulled; + public event UnityAction OnError; + + // ---- Initialization ---- + public async Task InitializeAsync() + { + if (string.IsNullOrEmpty(localUserId)) + localUserId = Guid.NewGuid().ToString(); + + if (string.IsNullOrEmpty(gameId) || !Guid.TryParse(gameId, out _)) + { + OnError?.Invoke("Invalid Game ID. Please set a valid GUID."); + return; + } + + var req = new SetGameRequest { GameId = Guid.Parse(gameId) }; + var resp = await PostRequestRaw("/session/game", req, includeCookie: false); + + if (!resp.success) + { + OnError?.Invoke($"Failed to start session: {resp.error}"); + return; + } + + if (resp.headers.TryGetValue("SET-COOKIE", out string cookieHeader)) + gameCookie = cookieHeader.Split(';')[0].Trim(); + else + { + OnError?.Invoke("Server did not return a gameId cookie."); + return; + } + + PurrLogger.Log("PurrLobbyProvider initialized with session cookie"); + } + + public void Shutdown() => CloseWebSocket(); + + // ---- Lobby API ---- + public async Task CreateLobbyAsync(int maxPlayers, Dictionary lobbyProperties = null) + { + var request = new CreateLobbyRequest + { + OwnerUserId = localUserId, + OwnerDisplayName = playerName, + MaxPlayers = maxPlayers, + Properties = lobbyProperties + }; + + var response = await PostRequest("/lobbies", request); + if (response != null) + { + currentLobby = ConvertServerLobbyToClientLobby(response); + OpenWebSocket(currentLobby.Value.LobbyId); + return currentLobby.Value; + } + + OnError?.Invoke("Failed to create lobby"); + return new Lobby { IsValid = false }; + } + + public async Task JoinLobbyAsync(string lobbyId) + { + var request = new JoinLobbyRequest { UserId = localUserId, DisplayName = playerName }; + var response = await PostRequest($"/lobbies/{lobbyId}/join", request); + if (response != null) + { + currentLobby = ConvertServerLobbyToClientLobby(response); + OpenWebSocket(lobbyId); + return currentLobby.Value; + } + + OnLobbyJoinFailed?.Invoke($"Failed to join lobby {lobbyId}"); + return new Lobby { IsValid = false }; + } + + public async Task LeaveLobbyAsync() => currentLobby.HasValue ? await LeaveLobbyAsync(currentLobby.Value.LobbyId) : Task.CompletedTask; + + public async Task LeaveLobbyAsync(string lobbyId) + { + var request = new LeaveLobbyRequest { UserId = localUserId }; + var success = await PostRequest($"/lobbies/{lobbyId}/leave", request); + if (!success) await PostRequest($"/users/{localUserId}/leave", null); + + CloseWebSocket(); + currentLobby = null; + OnLobbyLeft?.Invoke(); + } + + public async Task> SearchLobbiesAsync(int maxRoomsToFind = 10, Dictionary filters = null) + { + var response = await GetRequest>($"/lobbies/search?maxRoomsToFind={maxRoomsToFind}"); + var result = new List(); + if (response != null) + response.ForEach(s => result.Add(ConvertServerLobbyToClientLobby(s))); + return result; + } + + public async Task SetIsReadyAsync(string userId, bool isReady) + { + if (!currentLobby.HasValue) return; + await PostRequest($"/lobbies/{currentLobby.Value.LobbyId}/ready", new ReadyRequest { UserId = userId, IsReady = isReady }); + } + + public async Task SetLobbyDataAsync(string key, string value) + { + if (!currentLobby.HasValue) return; + await PostRequest($"/lobbies/{currentLobby.Value.LobbyId}/data", new LobbyDataRequest { Key = key, Value = value }); + } + + public async Task GetLobbyDataAsync(string key) + { + if (!currentLobby.HasValue) return string.Empty; + var response = await GetRequest($"/lobbies/{currentLobby.Value.LobbyId}/data/{key}"); + return response ?? string.Empty; + } + + public async Task> GetLobbyMembersAsync() + { + if (!currentLobby.HasValue) return new List(); + var response = await GetRequest>($"/lobbies/{currentLobby.Value.LobbyId}/members"); + + var list = new List(); + if (response != null) + response.ForEach(s => list.Add(new LobbyUser { Id = s.UserId, DisplayName = s.DisplayName, IsReady = s.IsReady })); + return list; + } + + public Task GetLocalUserIdAsync() => Task.FromResult(localUserId); + + public async Task SetAllReadyAsync() + { + if (!currentLobby.HasValue) return; + await PostRequest($"/lobbies/{currentLobby.Value.LobbyId}/ready/all", null); + } + + public async Task SetLobbyStartedAsync() + { + if (!currentLobby.HasValue) return; + await PostRequest($"/lobbies/{currentLobby.Value.LobbyId}/started", null); + } + + public Task> GetFriendsAsync(LobbyManager.FriendFilter filter) + { + return Task.FromResult(new List()); + } + + public Task InviteFriendAsync(FriendUser user) + { + return Task.CompletedTask; + } + + // ---- WebSocket ---- + private void OpenWebSocket(string lobbyId) + { + CloseWebSocket(); + var wsUrl = $"{wsBaseUrl}/ws/lobbies/{lobbyId}"; + ws = new WebSocket(wsUrl); + if (!string.IsNullOrEmpty(gameCookie)) ws.SetCookie(new WebSocketSharp.Net.Cookie("gameId", gameId, "/")); + + ws.OnOpen += (s, e) => PurrLogger.Log($"WebSocket connected to {lobbyId}"); + ws.OnMessage += (s, e) => + { + try + { + var msg = JsonConvert.DeserializeObject(e.Data); + HandleWebSocketMessage(msg); + } + catch { } + }; + ws.OnError += (s, e) => OnError?.Invoke($"WebSocket error: {e.Message}"); + ws.OnClose += (s, e) => PurrLogger.Log("WebSocket closed"); + + ws.ConnectAsync(); + } + + private void CloseWebSocket() + { + if (ws != null) + { + ws.CloseAsync(); + ws = null; + } + } + + private void HandleWebSocketMessage(LobbyWebSocketMessage msg) + { + switch (msg.Type) + { + case "lobby.updated": + var lobby = JsonConvert.DeserializeObject(msg.Payload.ToString()); + currentLobby = ConvertServerLobbyToClientLobby(lobby); + OnLobbyUpdated?.Invoke(currentLobby.Value); + break; + case "player.list": + var users = JsonConvert.DeserializeObject>(msg.Payload.ToString()); + OnLobbyPlayerListUpdated?.Invoke(users); + break; + case "error": + OnError?.Invoke(msg.Payload.ToString()); + break; + } + } + + // ---- HTTP helpers ---- + private async Task GetRequest(string endpoint) + { + using var request = UnityWebRequest.Get(apiBaseUrl + endpoint); + request.timeout = (int)requestTimeout; + if (!string.IsNullOrEmpty(gameCookie)) request.SetRequestHeader("Cookie", gameCookie); + + var op = request.SendWebRequest(); + while (!op.isDone) await Task.Yield(); + + if (request.result != UnityWebRequest.Result.Success) + { + PurrLogger.LogError($"GET {endpoint} failed: {request.error}"); + return default; + } + return JsonConvert.DeserializeObject(request.downloadHandler.text); + } + + private async Task PostRequest(string endpoint, object data) + { + await PostRequestRaw(endpoint, data, true); + } + + private async Task PostRequest(string endpoint, object data) + { + var resp = await PostRequestRaw(endpoint, data, true); + return resp.success ? JsonConvert.DeserializeObject(resp.body) : default; + } + + private async Task<(bool success, string body, string error, Dictionary headers)> PostRequestRaw(string endpoint, object data, bool includeCookie) + { + var json = data != null ? JsonConvert.SerializeObject(data) : "{}"; + using var request = new UnityWebRequest(apiBaseUrl + endpoint, "POST") + { + downloadHandler = new DownloadHandlerBuffer(), + uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json)) + }; + request.SetRequestHeader("Content-Type", "application/json"); + request.timeout = (int)requestTimeout; + if (includeCookie && !string.IsNullOrEmpty(gameCookie)) + request.SetRequestHeader("Cookie", gameCookie); + + var op = request.SendWebRequest(); + while (!op.isDone) await Task.Yield(); + var headers = request.GetResponseHeaders() ?? new Dictionary(); + if (request.result != UnityWebRequest.Result.Success) + return (false, null, request.error, headers); + return (true, request.downloadHandler.text, null, headers); + } + + // ---- Helpers ---- + private Lobby ConvertServerLobbyToClientLobby(ServerLobby s) + { + return new Lobby + { + LobbyId = s.LobbyId, + MaxPlayers = s.MaxPlayers, + Properties = s.Properties, + IsValid = true + }; + } + + // ---- DTOs ---- + [Serializable] private class SetGameRequest { public Guid GameId; } + [Serializable] private class CreateLobbyRequest { public string OwnerUserId; public string OwnerDisplayName; public int MaxPlayers; public Dictionary Properties; } + [Serializable] private class JoinLobbyRequest { public string UserId; public string DisplayName; } + [Serializable] private class LeaveLobbyRequest { public string UserId; } + [Serializable] private class ReadyRequest { public string UserId; public bool IsReady; } + [Serializable] private class LobbyDataRequest { public string Key; public string Value; } + + [Serializable] + private class ServerLobby + { + public string LobbyId; + public int MaxPlayers; + public Dictionary Properties; + } + + [Serializable] + private class ServerLobbyUser + { + public string UserId; + public string DisplayName; + public bool IsReady; + } + + [Serializable] + private class LobbyWebSocketMessage + { + public string Type; + public object Payload; + } + } + +#if UNITY_EDITOR + [CustomEditor(typeof(PurrLobbyProvider))] + public class PurrLobbyProviderEditor : Editor + { + public override void OnInspectorGUI() + { + DrawDefaultInspector(); + var provider = (PurrLobbyProvider)target; + if (GUILayout.Button("Generate New GameId")) + { + provider.gameId = Guid.NewGuid().ToString(); + EditorUtility.SetDirty(provider); + } + } + } +#endif +} diff --git a/PurrLobby/Models/Lobby.cs b/PurrLobby/Models/Lobby.cs new file mode 100644 index 0000000..6c36ad6 --- /dev/null +++ b/PurrLobby/Models/Lobby.cs @@ -0,0 +1,23 @@ +namespace PurrLobby.Models; + +// user in lobby +public class LobbyUser +{ + public required string Id { get; init; } + public required string DisplayName { get; init; } + public bool IsReady { get; set; } +} + +// lobby model +public class Lobby +{ + public string Name { get; set; } = string.Empty; + public bool IsValid { get; set; } = true; + public string LobbyId { get; set; } = string.Empty; + public string LobbyCode { get; set; } = string.Empty; + public int MaxPlayers { get; set; } + public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase); + public bool IsOwner { get; set; } + public List Members { get; } = new(); + public object? ServerObject { get; set; } +} diff --git a/PurrLobby/Program.cs b/PurrLobby/Program.cs new file mode 100644 index 0000000..73d7161 --- /dev/null +++ b/PurrLobby/Program.cs @@ -0,0 +1,413 @@ +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpLogging; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.ResponseCompression; +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(); + o.Providers.Add(); +}); + +// rate limit per ip +builder.Services.AddRateLimiter(o => +{ + o.GlobalLimiter = PartitionedRateLimiter.Create(context => + { + var key = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 300, + Window = TimeSpan.FromMinutes(1), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 100 + }); + }); + o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; +}); + +// trust proxy headers +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); +}); + +// swagger setup +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(o => +{ + o.SwaggerDoc("v1", new OpenApiInfo + { + Title = "PurrLobby API", + Version = "v1", + Description = "PurrLobby is a lightweight lobby service. Many endpoints require a 'gameId' cookie to scope requests to your game. Obtain it by calling POST /session/game.", + Contact = new OpenApiContact { Name = "PurrLobby", Url = new Uri("https://purrlobby.exil.dev") } + }); + + o.AddSecurityDefinition("gameIdCookie", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Cookie, + Name = "gameId", + Description = "Game scope cookie set by POST /session/game." + }); + + o.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "gameIdCookie" } + }, + Array.Empty() + } + }); +}); + +// service singletons +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// prod vs dev +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler(); + app.UseHsts(); +} +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) => +{ + 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); + return Results.Ok(new { message = "GameId stored" }); +}) +.WithTags("Session") +.WithOpenApi(op => +{ + op.Summary = "Identify game by setting a cookie"; + op.Description = "Sets a 'gameId' cookie used by lobby endpoints. Provide your game GUID in the request body."; + op.Security.Clear(); + return op; +}) +.Accepts("application/json") +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status400BadRequest) +.ProducesProblem(StatusCodes.Status400BadRequest); + +// cookie helper +static bool TryGetGameIdFromCookie(HttpRequest request, out Guid gameId) +{ + gameId = Guid.Empty; + if (!request.Cookies.TryGetValue("gameId", out var v)) return false; + return Guid.TryParse(v, out gameId); +} + +// 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); + return Results.Ok(lobby); +}) +.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."; + return op; +}) +.Accepts("application/json") +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status400BadRequest) +.ProducesProblem(StatusCodes.Status400BadRequest); + +// join lobby +app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService service, string lobbyId, JoinLobbyRequest req, CancellationToken ct) => +{ + 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); + 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."; + return op; +}) +.Accepts("application/json") +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status400BadRequest) +.ProducesProblem(StatusCodes.Status400BadRequest); + +// leave lobby by id +app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService service, string lobbyId, LeaveLobbyRequest req, 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); + 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."; + return op; +}) +.Accepts("application/json") +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status400BadRequest) +.ProducesProblem(StatusCodes.Status400BadRequest); + +// leave any lobby by user id +app.MapPost("/users/{userId}/leave", async (HttpContext http, ILobbyService service, string userId, CancellationToken ct) => +{ + if (!TryGetGameIdFromCookie(http.Request, out var gameId)) + return Results.BadRequest("Missing or invalid gameId cookie"); + var ok = await service.LeaveLobbyAsync(gameId, userId, ct); + return ok ? Results.Ok() : Results.NotFound(); +}).WithTags("Lobbies") +.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."; + return op; +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status400BadRequest) +.ProducesProblem(StatusCodes.Status400BadRequest); + +// search lobbies +app.MapGet("/lobbies/search", async (HttpContext http, ILobbyService service, int maxRoomsToFind = 10, CancellationToken ct = default) => +{ + if (!TryGetGameIdFromCookie(http.Request, out var gameId)) + return Results.BadRequest("Missing or invalid gameId cookie"); + var lobbies = await service.SearchLobbiesAsync(gameId, maxRoomsToFind, null, ct); + return Results.Ok(lobbies); +}) +.WithTags("Lobbies") +.WithOpenApi(op => +{ + op.Summary = "Search available lobbies"; + op.Description = "Finds joinable lobbies for the current game. Excludes started or full lobbies."; + op.Parameters.Add(new OpenApiParameter + { + Name = "maxRoomsToFind", + In = ParameterLocation.Query, + Required = false, + Description = "Max rooms to return (default 10)", + Schema = new OpenApiSchema { Type = "integer", Format = "int32" } + }); + return op; +}) +.Produces>(StatusCodes.Status200OK) +.Produces(StatusCodes.Status400BadRequest) +.ProducesProblem(StatusCodes.Status400BadRequest); + +// lobby members +app.MapGet("/lobbies/{lobbyId}/members", 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 members = await service.GetLobbyMembersAsync(gameId, lobbyId, ct); + return members.Count == 0 ? Results.NotFound() : Results.Ok(members); +}) +.WithTags("Lobbies") +.WithOpenApi(op => +{ + op.Summary = "Get members of a lobby"; + op.Description = "Returns current members of the lobby in the current game, including readiness state."; + return op; +}) +.Produces>(StatusCodes.Status200OK) +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status400BadRequest) +.ProducesProblem(StatusCodes.Status400BadRequest); + +// lobby data get +app.MapGet("/lobbies/{lobbyId}/data/{key}", async (HttpContext http, ILobbyService service, string lobbyId, string key, CancellationToken ct) => +{ + if (!TryGetGameIdFromCookie(http.Request, out var gameId)) + return Results.BadRequest("Missing or invalid gameId cookie"); + var v = await service.GetLobbyDataAsync(gameId, lobbyId, key, ct); + return v is null ? Results.NotFound() : Results.Ok(v); +}) +.WithTags("Lobbies") +.WithOpenApi(op => +{ + op.Summary = "Get a lobby data value"; + op.Description = "Retrieves a single property value for the lobby within the current game."; + return op; +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status400BadRequest) +.ProducesProblem(StatusCodes.Status400BadRequest); + +// lobby data set +app.MapPost("/lobbies/{lobbyId}/data", async (HttpContext http, ILobbyService service, string lobbyId, LobbyDataRequest req, CancellationToken ct) => +{ + if (!TryGetGameIdFromCookie(http.Request, out var gameId)) + return Results.BadRequest("Missing or invalid gameId cookie"); + if (string.IsNullOrWhiteSpace(req.Key)) return Results.BadRequest("Key is required"); + var ok = await service.SetLobbyDataAsync(gameId, lobbyId, req.Key, req.Value, ct); + return ok ? Results.Ok() : Results.NotFound(); +}) +.WithTags("Lobbies") +.WithOpenApi(op => +{ + op.Summary = "Set a lobby data value"; + op.Description = "Sets or updates a single property on the lobby within the current game. Broadcasts a lobby_data event to subscribers."; + return op; +}) +.Accepts("application/json") +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status400BadRequest) +.ProducesProblem(StatusCodes.Status400BadRequest); + +// ready toggle +app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService service, string lobbyId, ReadyRequest 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("Id is required"); + var ok = await service.SetIsReadyAsync(gameId, lobbyId, req.UserId, req.IsReady, ct); + return ok ? Results.Ok() : Results.NotFound(); +}) +.WithTags("Lobbies") +.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."; + return op; +}) +.Accepts("application/json") +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status404NotFound) +.Produces(StatusCodes.Status400BadRequest) +.ProducesProblem(StatusCodes.Status400BadRequest); + +// lobby websocket +app.Map("/ws/lobbies/{lobbyId}", async (HttpContext http, string lobbyId, ILobbyEventHub hub, CancellationToken ct) => +{ + if (!http.WebSockets.IsWebSocketRequest) + return Results.BadRequest("Expected WebSocket"); + + 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"); + + using var socket = await http.WebSockets.AcceptWebSocketAsync(); + await hub.HandleConnectionAsync(gameId, lobbyId, userId, socket, ct); + return Results.Empty; +}).WithTags("Lobbies").WithOpenApi(op => +{ + op.Summary = "Lobby Websocket"; + op.Description = "The Websocket that is used to recive lobby specifc updates"; + return op; +}); + +// health +app.MapGet("/health", () => Results.Ok(new { status = "healthy" })) + .WithTags("Health") + .WithOpenApi(op => + { + op.Summary = "Service health"; + op.Description = "Returns a 200 response to indicate the service is running."; + op.Security.Clear(); + return op; + }) + .Produces(StatusCodes.Status200OK); + +// stats +app.MapGet("/stats/global/players", async (ILobbyService service, CancellationToken ct) => Results.Ok(await service.GetGlobalPlayerCountAsync(ct))) + .WithTags("Stats").WithSummary("Get total active players globally").WithDescription("Counts all players across all lobbies (all games).") + .WithOpenApi(op => { op.Security.Clear(); return op; }) + .Produces(StatusCodes.Status200OK); +app.MapGet("/stats/global/lobbies", async (ILobbyService service, CancellationToken ct) => Results.Ok(await service.GetGlobalLobbyCountAsync(ct))) + .WithTags("Stats").WithSummary("Get total lobbies globally").WithDescription("Counts all lobbies across all games, including started ones.") + .WithOpenApi(op => { op.Security.Clear(); return op; }) + .Produces(StatusCodes.Status200OK); +app.MapGet("/stats/{gameId:guid}/lobbies", async (ILobbyService service, Guid gameId, CancellationToken ct) => Results.Ok(await service.GetLobbyCountByGameAsync(gameId, ct))) + .WithTags("Stats").WithSummary("Get lobby count for a game").WithDescription("Counts all lobbies for the specified game.") + .WithOpenApi() + .Produces(StatusCodes.Status200OK); +app.MapGet("/stats/{gameId:guid}/players", async (ILobbyService service, Guid gameId, CancellationToken ct) => Results.Ok(await service.GetActivePlayersByGameAsync(gameId, ct))) + .WithTags("Stats").WithSummary("Get active players for a game").WithDescription("Returns distinct active players across all lobbies for the specified game.") + .WithOpenApi() + .Produces>(StatusCodes.Status200OK); + +// run +app.Run(); + +// dto records +public record SetGameRequest(Guid GameId); +public record CreateLobbyRequest(string OwnerUserId, string OwnerDisplayName, int MaxPlayers, Dictionary? Properties); +public record JoinLobbyRequest(string UserId, string DisplayName); +public record LeaveLobbyRequest(string UserId); +public record ReadyRequest(string UserId, bool IsReady); +public record LobbyDataRequest(string Key, string Value); diff --git a/PurrLobby/Properties/launchSettings.json b/PurrLobby/Properties/launchSettings.json new file mode 100644 index 0000000..55745b6 --- /dev/null +++ b/PurrLobby/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "PurrLobby": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:63036;http://localhost:63037" + } + } +} \ No newline at end of file diff --git a/PurrLobby/PurrLobby.csproj b/PurrLobby/PurrLobby.csproj new file mode 100644 index 0000000..819bfa0 --- /dev/null +++ b/PurrLobby/PurrLobby.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/PurrLobby/Services/LobbyEventHub.cs b/PurrLobby/Services/LobbyEventHub.cs new file mode 100644 index 0000000..9f1a4bb --- /dev/null +++ b/PurrLobby/Services/LobbyEventHub.cs @@ -0,0 +1,300 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; + +namespace PurrLobby.Services; + +public interface ILobbyEventHub +{ + Task HandleConnectionAsync(Guid gameId, string lobbyId, string userId, WebSocket socket, CancellationToken ct); + Task BroadcastAsync(Guid gameId, string lobbyId, object evt, CancellationToken ct = default); + Task CloseLobbyAsync(Guid gameId, string lobbyId, CancellationToken ct = default); +} + +public class LobbyEventHub : ILobbyEventHub +{ + private sealed record LobbyKey(Guid GameId, string LobbyId) + { + public override string ToString() => $"{GameId:N}:{LobbyId}"; + } + + private sealed class Subscriber + { + public required string UserId { get; init; } + public DateTime LastPongUtc { get; set; } = DateTime.UtcNow; + } + + // subs per lobby + private readonly ConcurrentDictionary> _subscribers = new(); + // idle cleanup flags + private readonly ConcurrentDictionary _idleCleanupPending = new(); + // active ping loops + private readonly ConcurrentDictionary _pingLoopsActive = new(); + + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + private readonly IServiceScopeFactory _scopeFactory; + + // ping settings + private const int PingIntervalSeconds = 10; + private const int PongTimeoutSeconds = 15; + + // idle cleanup delay + private const int IdleLobbyCleanupDelaySeconds = 45; + + public LobbyEventHub(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory; + } + + public async Task HandleConnectionAsync(Guid gameId, string lobbyId, string userId, WebSocket socket, CancellationToken ct) + { + var key = new LobbyKey(gameId, lobbyId); + var bag = _subscribers.GetOrAdd(key, _ => new()); + var sub = new Subscriber { UserId = userId, LastPongUtc = DateTime.UtcNow }; + bag.TryAdd(socket, sub); + + EnsurePingLoopStarted(key); + + try + { + var buffer = new byte[8 * 1024]; + var segment = new ArraySegment(buffer); + while (!ct.IsCancellationRequested && socket.State == WebSocketState.Open) + { + var result = await socket.ReceiveAsync(segment, ct); + if (result.MessageType == WebSocketMessageType.Close) + break; + if (result.MessageType == WebSocketMessageType.Text) + { + var text = Encoding.UTF8.GetString(buffer, 0, result.Count); + if (IsPong(text)) + { + if (bag.TryGetValue(socket, out var s)) + s.LastPongUtc = DateTime.UtcNow; + continue; + } + } + } + } + catch { } + finally + { + bag.TryRemove(socket, out _); + try + { + if (socket.State == WebSocketState.Open || socket.State == WebSocketState.CloseReceived) + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); + } + catch { } + + if (bag.IsEmpty) + ScheduleIdleCleanup(key); + } + } + + private void EnsurePingLoopStarted(LobbyKey key) + { + if (!_pingLoopsActive.TryAdd(key, 1)) return; + _ = Task.Run(() => PingLoopAsync(key)); + } + + private async Task PingLoopAsync(LobbyKey key) + { + try + { + while (true) + { + if (!_subscribers.TryGetValue(key, out var bag) || bag.IsEmpty) + break; + + var pingSentAt = DateTime.UtcNow; + var ping = JsonSerializer.SerializeToUtf8Bytes(new { type = "ping", ts = pingSentAt.Ticks }, _jsonOptions); + + var sockets = bag.Keys.ToList(); + foreach (var ws in sockets) + { + if (ws.State != WebSocketState.Open) continue; + try { await ws.SendAsync(ping, WebSocketMessageType.Text, true, CancellationToken.None); } catch { } + } + + try { await Task.Delay(TimeSpan.FromSeconds(PongTimeoutSeconds)); } catch { } + + if (!_subscribers.TryGetValue(key, out bag) || bag.IsEmpty) + break; + + var responders = new List<(WebSocket ws, Subscriber sub)>(); + var nonResponders = new List<(WebSocket ws, Subscriber sub)>(); + foreach (var kv in bag) + { + if (kv.Value.LastPongUtc >= pingSentAt) + responders.Add((kv.Key, kv.Value)); + else + nonResponders.Add((kv.Key, kv.Value)); + } + + if (responders.Count == 0) + { + await ForceCloseLobbyAsync(key); + break; + } + + if (nonResponders.Count > 0) + { + foreach (var (ws, sub) in nonResponders) + { + bag.TryRemove(ws, out _); + try { if (ws.State == WebSocketState.Open) await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation, "pong timeout", CancellationToken.None); } catch { } + try + { + using var scope = _scopeFactory.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, sub.UserId, CancellationToken.None); + } + catch { } + } + } + + try { await Task.Delay(TimeSpan.FromSeconds(PingIntervalSeconds)); } catch { } + } + } + finally + { + _pingLoopsActive.TryRemove(key, out _); + } + } + + private async Task ForceCloseLobbyAsync(LobbyKey key) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + var members = await svc.GetLobbyMembersAsync(key.GameId, key.LobbyId, CancellationToken.None); + if (members != null) + { + foreach (var m in members) + { + try { await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, m.Id, CancellationToken.None); } catch { } + } + } + } + catch { } + finally + { + await CloseLobbyAsync(key.GameId, key.LobbyId, CancellationToken.None); + } + } + + public async Task BroadcastAsync(Guid gameId, string lobbyId, object evt, CancellationToken ct = default) + { + var key = new LobbyKey(gameId, lobbyId); + if (!_subscribers.TryGetValue(key, out var bag) || bag.IsEmpty) + { + ScheduleIdleCleanup(key); + return; + } + + var payload = JsonSerializer.SerializeToUtf8Bytes(evt, _jsonOptions); + var toRemove = new List(); + foreach (var kv in bag) + { + var ws = kv.Key; + if (ws.State != WebSocketState.Open) + { + toRemove.Add(ws); + continue; + } + try { await ws.SendAsync(payload, WebSocketMessageType.Text, true, ct); } catch { toRemove.Add(ws); } + } + foreach (var ws in toRemove) + { + bag.TryRemove(ws, out _); + try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "removed", CancellationToken.None); } catch { } + } + + if (bag.IsEmpty) + ScheduleIdleCleanup(key); + else + EnsurePingLoopStarted(key); + } + + public async Task CloseLobbyAsync(Guid gameId, string lobbyId, CancellationToken ct = default) + { + var key = new LobbyKey(gameId, lobbyId); + if (!_subscribers.TryRemove(key, out var bag)) return; + + var evt = new { type = "lobby_deleted", lobbyId, gameId }; + var payload = JsonSerializer.SerializeToUtf8Bytes(evt, _jsonOptions); + foreach (var kv in bag) + { + var ws = kv.Key; + try + { + if (ws.State == WebSocketState.Open) + { + await ws.SendAsync(payload, WebSocketMessageType.Text, true, ct); + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "lobby deleted", ct); + } + } + catch { } + } + } + + private static bool IsPong(string text) + { + var t = text.Trim().ToLowerInvariant(); + if (t == "pong" || t == "hb" || t == "heartbeat") return true; + try + { + using var doc = JsonDocument.Parse(text); + if (doc.RootElement.TryGetProperty("type", out var typeProp)) + { + var v = typeProp.GetString()?.Trim().ToLowerInvariant(); + return v == "pong" || v == "hb" || v == "heartbeat"; + } + } + catch { } + return false; + } + + private void ScheduleIdleCleanup(LobbyKey key) + { + if (!_idleCleanupPending.TryAdd(key, 1)) return; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(TimeSpan.FromSeconds(IdleLobbyCleanupDelaySeconds)); + + if (_subscribers.TryGetValue(key, out var bag) && !bag.IsEmpty) + return; + + using var scope = _scopeFactory.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + + var members = await svc.GetLobbyMembersAsync(key.GameId, key.LobbyId, CancellationToken.None); + if (members != null && members.Count > 0) + { + foreach (var m in members) + { + try { await svc.LeaveLobbyAsync(key.GameId, key.LobbyId, m.Id, CancellationToken.None); } catch { } + } + } + + await CloseLobbyAsync(key.GameId, key.LobbyId, CancellationToken.None); + } + catch { } + finally + { + _idleCleanupPending.TryRemove(key, out _); + } + }); + } +} diff --git a/PurrLobby/Services/LobbyService.cs b/PurrLobby/Services/LobbyService.cs new file mode 100644 index 0000000..89cab8a --- /dev/null +++ b/PurrLobby/Services/LobbyService.cs @@ -0,0 +1,408 @@ +using System.Collections.Concurrent; +using PurrLobby.Models; + +namespace PurrLobby.Services; + +// lobby service core logic +public interface ILobbyService +{ + Task CreateLobbyAsync(Guid gameId, string ownerUserId, string ownerDisplayName, int maxPlayers, Dictionary? properties, CancellationToken ct = default); + Task JoinLobbyAsync(Guid gameId, string lobbyId, string userId, string displayName, CancellationToken ct = default); + Task LeaveLobbyAsync(Guid gameId, string lobbyId, string userId, CancellationToken ct = default); + Task LeaveLobbyAsync(Guid gameId, string userId, CancellationToken ct = default); + Task> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary? filters, CancellationToken ct = default); + Task SetIsReadyAsync(Guid gameId, string lobbyId, string userId, bool isReady, CancellationToken ct = default); + Task SetLobbyDataAsync(Guid gameId, string lobbyId, string key, string value, CancellationToken ct = default); + Task GetLobbyDataAsync(Guid gameId, string lobbyId, string key, CancellationToken ct = default); + Task> GetLobbyMembersAsync(Guid gameId, string lobbyId, CancellationToken ct = default); + Task SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default); + Task SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default); + + // stats + Task GetGlobalPlayerCountAsync(CancellationToken ct = default); + Task GetGlobalLobbyCountAsync(CancellationToken ct = default); + Task GetLobbyCountByGameAsync(Guid gameId, CancellationToken ct = default); + Task> GetActivePlayersByGameAsync(Guid gameId, CancellationToken ct = default); +} + +// internal lobby state +internal class LobbyState +{ + public required string Id { get; init; } + public required Guid GameId { get; init; } + public required string OwnerUserId { get; set; } + public int MaxPlayers { get; init; } + public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow; + public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase); + public List Members { get; } = new(); + public bool Started { get; set; } + public string Name { get; set; } = string.Empty; + public string LobbyCode { get; set; } = string.Empty; +} + +public class LobbyService : ILobbyService +{ + private const int MinPlayers = 2; + private const int MaxPlayersLimit = 64; + private const int NameMaxLength = 64; + private const int DisplayNameMaxLength = 64; + private const int PropertyKeyMaxLength = 64; + private const int PropertyValueMaxLength = 256; + private const int MaxPropertyCount = 32; + + private readonly ConcurrentDictionary _lobbies = new(); + // user index key gameIdN:userId -> lobbyId + private readonly ConcurrentDictionary _userLobbyIndexByGame = new(); + private readonly ILobbyEventHub _events; + + public LobbyService(ILobbyEventHub events) + { + _events = events; + } + + private static string SanitizeString(string? s, int maxLen) + => string.IsNullOrWhiteSpace(s) ? string.Empty : (s.Length <= maxLen ? s : s.Substring(0, maxLen)).Trim(); + + private static bool IsInvalidId(string? id) => string.IsNullOrWhiteSpace(id) || id.Length > 128; + + private static Lobby Project(LobbyState s, string? currentUserId = null) + { + var lobby = new Lobby + { + Name = !string.IsNullOrWhiteSpace(s.Name) ? s.Name : (s.Properties.TryGetValue("Name", out var n) ? n : string.Empty), + IsValid = true, + LobbyId = s.Id, + LobbyCode = s.LobbyCode, + MaxPlayers = s.MaxPlayers, + IsOwner = currentUserId != null && string.Equals(s.OwnerUserId, currentUserId, StringComparison.Ordinal) + }; + foreach (var kv in s.Properties) + lobby.Properties[kv.Key] = kv.Value; + foreach (var m in s.Members) + lobby.Members.Add(new LobbyUser { Id = m.Id, DisplayName = m.DisplayName, IsReady = m.IsReady }); + return lobby; + } + + public async Task CreateLobbyAsync(Guid gameId, string ownerUserId, string ownerDisplayName, int maxPlayers, Dictionary? properties, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(ownerUserId)) + throw new ArgumentException("Invalid gameId or ownerUserId"); + + var display = SanitizeString(ownerDisplayName, DisplayNameMaxLength); + var clampedPlayers = Math.Clamp(maxPlayers, MinPlayers, MaxPlayersLimit); + + string GenerateLobbyCode() + { + const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + var rng = Random.Shared; + for (int attempt = 0; attempt < 10; attempt++) + { + Span s = stackalloc char[6]; + for (int i = 0; i < s.Length; i++) s[i] = chars[rng.Next(chars.Length)]; + var code = new string(s); + if (!_lobbies.Values.Any(l => string.Equals(l.LobbyCode, code, StringComparison.OrdinalIgnoreCase))) + return code; + } + // fallback + return Guid.NewGuid().ToString("N").Substring(0, 6).ToUpperInvariant(); + } + + var state = new LobbyState + { + Id = Guid.NewGuid().ToString("N"), + GameId = gameId, + OwnerUserId = ownerUserId, + MaxPlayers = clampedPlayers, + Name = properties != null && properties.TryGetValue("Name", out var n) ? SanitizeString(n, NameMaxLength) : string.Empty, + LobbyCode = GenerateLobbyCode() + }; + + if (properties != null) + { + foreach (var kv in properties) + { + if (state.Properties.Count >= MaxPropertyCount) break; + var key = SanitizeString(kv.Key, PropertyKeyMaxLength); + if (string.IsNullOrEmpty(key)) continue; + var val = SanitizeString(kv.Value, PropertyValueMaxLength); + state.Properties[key] = val; + } + } + + state.Members.Add(new LobbyUser + { + Id = ownerUserId, + DisplayName = display, + IsReady = false + }); + + _lobbies[state.Id] = state; + _userLobbyIndexByGame[$"{gameId:N}:{ownerUserId}"] = state.Id; + + await _events.BroadcastAsync(gameId, state.Id, new { type = "lobby_created", lobbyId = state.Id, ownerUserId, ownerDisplayName = display, maxPlayers = state.MaxPlayers }, ct); + + return Project(state, ownerUserId); + } + + public Task JoinLobbyAsync(Guid gameId, string lobbyId, string userId, string displayName, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(userId)) + return Task.FromResult(null); + + if (!_lobbies.TryGetValue(lobbyId, out var state)) + return Task.FromResult(null); + if (state.GameId != gameId) + return Task.FromResult(null); + + // prevent multi lobby join per game + if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{userId}", out var existingLobbyId) && existingLobbyId != lobbyId) + return Task.FromResult(null); + + var name = SanitizeString(displayName, DisplayNameMaxLength); + + lock (state) + { + if (state.Started) return Task.FromResult(null); + if (state.Members.Any(m => m.Id == userId)) + return Task.FromResult(Project(state, userId)); + if (state.Members.Count >= state.MaxPlayers) + return Task.FromResult(null); + state.Members.Add(new LobbyUser { Id = userId, DisplayName = name, IsReady = false }); + } + _userLobbyIndexByGame[$"{gameId:N}:{userId}"] = lobbyId; + _ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_joined", userId, displayName = name }, ct); + return Task.FromResult(Project(state, userId)); + } + + public Task LeaveLobbyAsync(Guid gameId, string lobbyId, string userId, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(userId)) + return Task.FromResult(false); + + if (!_lobbies.TryGetValue(lobbyId, out var state)) + return Task.FromResult(false); + if (state.GameId != gameId) + return Task.FromResult(false); + var removed = false; + string? newOwner = null; + lock (state) + { + var idx = state.Members.FindIndex(m => m.Id == userId); + if (idx >= 0) + { + state.Members.RemoveAt(idx); + removed = true; + if (state.OwnerUserId == userId && state.Members.Count > 0) + { + state.OwnerUserId = state.Members[0].Id; // promote first + newOwner = state.OwnerUserId; + } + } + } + _userLobbyIndexByGame.TryRemove($"{gameId:N}:{userId}", out _); + if (removed) + { + // remove lobby if empty + if (state.Members.Count == 0) + { + _lobbies.TryRemove(lobbyId, out _); + _ = _events.BroadcastAsync(gameId, lobbyId, new { type = "lobby_empty" }, ct); + _ = _events.CloseLobbyAsync(gameId, lobbyId, ct); + } + else + { + _ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_left", userId, newOwnerUserId = newOwner }, ct); + } + } + return Task.FromResult(removed); + } + + public Task LeaveLobbyAsync(Guid gameId, string userId, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(userId)) + return Task.FromResult(false); + + if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{userId}", out var lobbyId)) + { + return LeaveLobbyAsync(gameId, lobbyId, userId, ct); + } + return Task.FromResult(false); + } + + public Task> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary? filters, CancellationToken ct = default) + { + if (gameId == Guid.Empty) + return Task.FromResult(new List()); + + var take = Math.Clamp(maxRoomsToFind, 1, 100); + IEnumerable query = _lobbies.Values.Where(l => l.GameId == gameId && !l.Started && l.Members.Count < l.MaxPlayers); + if (filters != null) + { + foreach (var kv in filters) + { + var k = SanitizeString(kv.Key, PropertyKeyMaxLength); + var v = SanitizeString(kv.Value, PropertyValueMaxLength); + if (string.IsNullOrEmpty(k)) continue; + query = query.Where(l => l.Properties.TryGetValue(k, out var pv) && string.Equals(pv, v, StringComparison.OrdinalIgnoreCase)); + } + } + var list = query.OrderByDescending(l => l.CreatedAtUtc).Take(take).Select(s => Project(s)).ToList(); + return Task.FromResult(list); + } + + public Task SetIsReadyAsync(Guid gameId, string lobbyId, string userId, bool isReady, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(userId)) + return Task.FromResult(false); + + if (!_lobbies.TryGetValue(lobbyId, out var state)) + return Task.FromResult(false); + if (state.GameId != gameId) + return Task.FromResult(false); + lock (state) + { + if (state.Started) return Task.FromResult(false); + var m = state.Members.FirstOrDefault(x => x.Id == userId); + if (m is null) return Task.FromResult(false); + m.IsReady = isReady; + } + _ = _events.BroadcastAsync(gameId, lobbyId, new { type = "member_ready", userId, isReady }, ct); + return Task.FromResult(true); + } + + public Task SetLobbyDataAsync(Guid gameId, string lobbyId, string key, string value, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(lobbyId)) + return Task.FromResult(false); + if (string.IsNullOrWhiteSpace(key)) + return Task.FromResult(false); + + if (!_lobbies.TryGetValue(lobbyId, out var state)) + return Task.FromResult(false); + if (state.GameId != gameId) + return Task.FromResult(false); + lock (state) + { + var k = SanitizeString(key, PropertyKeyMaxLength); + if (string.IsNullOrEmpty(k)) return Task.FromResult(false); + var v = SanitizeString(value, PropertyValueMaxLength); + if (!state.Properties.ContainsKey(k) && state.Properties.Count >= MaxPropertyCount) + return Task.FromResult(false); + + state.Properties[k] = v; + if (string.Equals(k, "Name", StringComparison.OrdinalIgnoreCase)) + state.Name = SanitizeString(v, NameMaxLength); + } + _ = _events.BroadcastAsync(gameId, lobbyId, new { type = "lobby_data", key, value }, ct); + return Task.FromResult(true); + } + + public Task GetLobbyDataAsync(Guid gameId, string lobbyId, string key, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(lobbyId) || string.IsNullOrWhiteSpace(key)) + return Task.FromResult(null); + + if (!_lobbies.TryGetValue(lobbyId, out var state)) + return Task.FromResult(null); + if (state.GameId != gameId) + return Task.FromResult(null); + return Task.FromResult(state.Properties.TryGetValue(key, out var v) ? v : null); + } + + public Task> GetLobbyMembersAsync(Guid gameId, string lobbyId, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(lobbyId)) + return Task.FromResult(new List()); + + if (!_lobbies.TryGetValue(lobbyId, out var state)) + return Task.FromResult(new List()); + if (state.GameId != gameId) + return Task.FromResult(new List()); + lock (state) + { + return Task.FromResult(state.Members.Select(m => new LobbyUser + { + Id = m.Id, + DisplayName = m.DisplayName, + IsReady = m.IsReady + }).ToList()); + } + } + + public Task SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(lobbyId)) + return Task.FromResult(false); + + if (!_lobbies.TryGetValue(lobbyId, out var state)) + return Task.FromResult(false); + if (state.GameId != gameId) + return Task.FromResult(false); + lock (state) + { + if (state.Started) return Task.FromResult(false); + foreach (var m in state.Members) + m.IsReady = true; + } + _ = _events.BroadcastAsync(gameId, lobbyId, new { type = "all_ready" }, ct); + return Task.FromResult(true); + } + + public Task SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default) + { + if (gameId == Guid.Empty || IsInvalidId(lobbyId)) + return Task.FromResult(false); + + if (!_lobbies.TryGetValue(lobbyId, out var state)) + return Task.FromResult(false); + if (state.GameId != gameId) + return Task.FromResult(false); + if (state.Started) return Task.FromResult(false); + state.Started = true; + _ = _events.BroadcastAsync(gameId, lobbyId, new { type = "lobby_started" }, ct); + return Task.FromResult(true); + } + + public Task GetGlobalPlayerCountAsync(CancellationToken ct = default) + { + var total = 0; + foreach (var state in _lobbies.Values) + { + lock (state) + { + total += state.Members.Count; + } + } + return Task.FromResult(total); + } + + public Task GetGlobalLobbyCountAsync(CancellationToken ct = default) + { + var count = _lobbies.Count; + return Task.FromResult(count); + } + + public Task GetLobbyCountByGameAsync(Guid gameId, CancellationToken ct = default) + { + var count = _lobbies.Values.Count(l => l.GameId == gameId); + return Task.FromResult(count); + } + + public Task> GetActivePlayersByGameAsync(Guid gameId, CancellationToken ct = default) + { + var players = new Dictionary(); + foreach (var state in _lobbies.Values) + { + if (state.GameId != gameId) continue; + lock (state) + { + foreach (var m in state.Members) + { + // unique per user id in game + players[m.Id] = new LobbyUser { Id = m.Id, DisplayName = m.DisplayName, IsReady = m.IsReady }; + } + } + } + return Task.FromResult(players.Values.ToList()); + } +} diff --git a/PurrLobby/appsettings.Production.json b/PurrLobby/appsettings.Production.json new file mode 100644 index 0000000..ee659bf --- /dev/null +++ b/PurrLobby/appsettings.Production.json @@ -0,0 +1,14 @@ +{ + "Kestrel": { + "Endpoints": { + "Https": { + "Url": "https://0.0.0.0:443", + "Certificate": { + "Path": "C:\\certs\\purrlobby.pem", + "KeyPath": "C:\\certs\\purrlobby.key" + } + } + } + }, + "AllowedHosts": "purrlobby.exil.dev" +} diff --git a/PurrLobby/wwwroot/favicon.ico b/PurrLobby/wwwroot/favicon.ico new file mode 100644 index 0000000..e8c2c72 Binary files /dev/null and b/PurrLobby/wwwroot/favicon.ico differ diff --git a/PurrLobby/wwwroot/index.html b/PurrLobby/wwwroot/index.html new file mode 100644 index 0000000..60914e8 --- /dev/null +++ b/PurrLobby/wwwroot/index.html @@ -0,0 +1,63 @@ + + + + + + PurrLobby + + + + +
+
+ +

PurrLobby

+
+ +

Global Stats

+
+
+
-
+
Players
+
+
+
-
+
Lobbies
+
+
+ +

Track Your Game

+
+ + +
+ + +

Game Stats

+
+
+
-
+
Players
+
+
+
-
+
Lobbies
+
+
+ + + +

+ This Service is does not (yet) belong to purrnet, it's just something I put together for a alternative to the Steam Lobby Service and Unity Service. + Since it's running on my own setup, it might go down or act weird sometimes. + If that happens, no worries, it'll probably be back soon. + Please don't contact the actual PurrNet devs about this, it's all on me. + If there are any proplem's contact me on discord: exil_s +

+
+ + + + diff --git a/PurrLobby/wwwroot/main.js b/PurrLobby/wwwroot/main.js new file mode 100644 index 0000000..d150578 --- /dev/null +++ b/PurrLobby/wwwroot/main.js @@ -0,0 +1,80 @@ +async function fetchJson(url) { + const r = await fetch(url, { credentials: 'include' }); + if (!r.ok) throw new Error(`Request failed: ${r.status}`); + return r.json(); +} + +async function loadGlobal() { + try { + const [players, lobbies] = await Promise.all([ + fetchJson('/stats/global/players'), + fetchJson('/stats/global/lobbies') + ]); + document.getElementById('globalPlayers').textContent = players; + document.getElementById('globalLobbies').textContent = lobbies; + } catch (e) { + console.error(e); + } +} + +async function loadGame(gameId) { + try { + const [lobbies, players] = await Promise.all([ + fetchJson(`/stats/${gameId}/lobbies`), + fetchJson(`/stats/${gameId}/players`) + ]); + document.getElementById('gameLobbies').textContent = lobbies; + document.getElementById('gamePlayers').textContent = players.length ?? players; // endpoint returns list + } catch (e) { + console.error(e); + } +} + +async function setGameCookie(gameId) { + const r = await fetch('/session/game', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gameId }), + credentials: 'include' + }); + if (!r.ok) throw new Error('Failed to set game cookie'); + return r.json(); +} + +function getCookie(name) { + const m = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return m ? decodeURIComponent(m[2]) : null; +} + +(async () => { + await loadGlobal(); + + const gameForm = document.getElementById('gameForm'); + const gameIdInput = document.getElementById('gameIdInput'); + const cookieStatus = document.getElementById('cookieStatus'); + + //if cookie exists prefill and load stats + const existing = getCookie('gameId'); + if (existing) { + gameIdInput.value = existing; + loadGame(existing); + cookieStatus.textContent = `Using gameId from cookie.`; + } + + gameForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const gameId = gameIdInput.value.trim(); + if (!/^\{?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\}?$/.test(gameId)) { + alert('Please enter a valid GUID'); + return; + } + try { + await setGameCookie(gameId); + cookieStatus.textContent = 'GameId stored in cookie.'; + await loadGame(gameId); + } catch (err) { + cookieStatus.textContent = 'Failed to set cookie'; + console.error(err); + } + }); +})(); diff --git a/PurrLobby/wwwroot/purrlogo.png b/PurrLobby/wwwroot/purrlogo.png new file mode 100644 index 0000000..0b721a5 Binary files /dev/null and b/PurrLobby/wwwroot/purrlogo.png differ diff --git a/PurrLobby/wwwroot/styles.css b/PurrLobby/wwwroot/styles.css new file mode 100644 index 0000000..36c9453 --- /dev/null +++ b/PurrLobby/wwwroot/styles.css @@ -0,0 +1,115 @@ +:root { + --bg: #000; + --text: #fff; + --muted: #aaa; + --border: #333; + --accent: #2aa9ff; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: monospace, sans-serif; + line-height: 1.5; +} + +.container { + max-width: 800px; + margin: 20px auto; + padding: 0 12px; +} + +.hero { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.logo { + width: 64px; + height: 64px; + object-fit: contain; +} + +.title { + font-size: 32px; +} + +.section-title { + font-size: 14px; + margin: 20px 0 8px; + text-transform: uppercase; +} + +.center { + text-align: center; +} + +.bigstats { + display: flex; + gap: 20px; + justify-content: center; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.bigstat { + min-width: 120px; + display: flex; + flex-direction: column; + align-items: center; +} + +.bignum { + font-size: 32px; + font-weight: bold; + text-align: center; +} + +.biglabel { + font-size: 12px; + color: var(--muted); + text-align: center; +} + +.stack { + display: grid; + gap: 8px; + margin-bottom: 20px; +} + +#gameIdInput { + padding: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} + +button { + padding: 8px; + border: 1px solid var(--border); + background: #111; + color: var(--text); + cursor: pointer; +} + + button:hover { + background: #222; + } + +.disclaimer { + font-size: 13px; + color: var(--muted); + margin-top: 20px; +} + +a { + color: var(--accent); +}