From 3f46c6f5a5aae71d5aac1f4333445bbcfb4121af Mon Sep 17 00:00:00 2001 From: Exil Productions Date: Fri, 12 Sep 2025 15:20:46 +0200 Subject: [PATCH] Add project files. --- PurrLobby.sln | 25 ++ PurrLobby/ILobbyProvider.cs | 354 +++++++++++++++++++ PurrLobby/Models/Lobby.cs | 23 ++ PurrLobby/Program.cs | 413 +++++++++++++++++++++++ PurrLobby/Properties/launchSettings.json | 12 + PurrLobby/PurrLobby.csproj | 29 ++ PurrLobby/Services/LobbyEventHub.cs | 300 ++++++++++++++++ PurrLobby/Services/LobbyService.cs | 408 ++++++++++++++++++++++ PurrLobby/appsettings.Production.json | 14 + PurrLobby/wwwroot/favicon.ico | Bin 0 -> 9662 bytes PurrLobby/wwwroot/index.html | 63 ++++ PurrLobby/wwwroot/main.js | 80 +++++ PurrLobby/wwwroot/purrlogo.png | Bin 0 -> 4821 bytes PurrLobby/wwwroot/styles.css | 115 +++++++ 14 files changed, 1836 insertions(+) create mode 100644 PurrLobby.sln create mode 100644 PurrLobby/ILobbyProvider.cs create mode 100644 PurrLobby/Models/Lobby.cs create mode 100644 PurrLobby/Program.cs create mode 100644 PurrLobby/Properties/launchSettings.json create mode 100644 PurrLobby/PurrLobby.csproj create mode 100644 PurrLobby/Services/LobbyEventHub.cs create mode 100644 PurrLobby/Services/LobbyService.cs create mode 100644 PurrLobby/appsettings.Production.json create mode 100644 PurrLobby/wwwroot/favicon.ico create mode 100644 PurrLobby/wwwroot/index.html create mode 100644 PurrLobby/wwwroot/main.js create mode 100644 PurrLobby/wwwroot/purrlogo.png create mode 100644 PurrLobby/wwwroot/styles.css 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 0000000000000000000000000000000000000000..e8c2c72672249ef9edfcb39e03e856add70ed0b0 GIT binary patch literal 9662 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 C$9C5M literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0b721a525e2ebfee41580bdb5a987b344f588cb0 GIT binary patch literal 4821 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 literal 0 HcmV?d00001 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); +}