Add project files.

This commit is contained in:
Exil Productions
2025-09-12 15:20:46 +02:00
parent fb8b89103f
commit 3f46c6f5a5
14 changed files with 1836 additions and 0 deletions

25
PurrLobby.sln Normal file
View File

@@ -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

354
PurrLobby/ILobbyProvider.cs Normal file
View File

@@ -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<string> OnLobbyJoinFailed;
public event UnityAction OnLobbyLeft;
public event UnityAction<Lobby> OnLobbyUpdated;
public event UnityAction<List<LobbyUser>> OnLobbyPlayerListUpdated;
public event UnityAction<List<FriendUser>> OnFriendListPulled;
public event UnityAction<string> 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<Lobby> CreateLobbyAsync(int maxPlayers, Dictionary<string, string> lobbyProperties = null)
{
var request = new CreateLobbyRequest
{
OwnerUserId = localUserId,
OwnerDisplayName = playerName,
MaxPlayers = maxPlayers,
Properties = lobbyProperties
};
var response = await PostRequest<ServerLobby>("/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<Lobby> JoinLobbyAsync(string lobbyId)
{
var request = new JoinLobbyRequest { UserId = localUserId, DisplayName = playerName };
var response = await PostRequest<ServerLobby>($"/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<bool>($"/lobbies/{lobbyId}/leave", request);
if (!success) await PostRequest<bool>($"/users/{localUserId}/leave", null);
CloseWebSocket();
currentLobby = null;
OnLobbyLeft?.Invoke();
}
public async Task<List<Lobby>> SearchLobbiesAsync(int maxRoomsToFind = 10, Dictionary<string, string> filters = null)
{
var response = await GetRequest<List<ServerLobby>>($"/lobbies/search?maxRoomsToFind={maxRoomsToFind}");
var result = new List<Lobby>();
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<string> GetLobbyDataAsync(string key)
{
if (!currentLobby.HasValue) return string.Empty;
var response = await GetRequest<string>($"/lobbies/{currentLobby.Value.LobbyId}/data/{key}");
return response ?? string.Empty;
}
public async Task<List<LobbyUser>> GetLobbyMembersAsync()
{
if (!currentLobby.HasValue) return new List<LobbyUser>();
var response = await GetRequest<List<ServerLobbyUser>>($"/lobbies/{currentLobby.Value.LobbyId}/members");
var list = new List<LobbyUser>();
if (response != null)
response.ForEach(s => list.Add(new LobbyUser { Id = s.UserId, DisplayName = s.DisplayName, IsReady = s.IsReady }));
return list;
}
public Task<string> 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<List<FriendUser>> GetFriendsAsync(LobbyManager.FriendFilter filter)
{
return Task.FromResult(new List<FriendUser>());
}
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<LobbyWebSocketMessage>(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<ServerLobby>(msg.Payload.ToString());
currentLobby = ConvertServerLobbyToClientLobby(lobby);
OnLobbyUpdated?.Invoke(currentLobby.Value);
break;
case "player.list":
var users = JsonConvert.DeserializeObject<List<LobbyUser>>(msg.Payload.ToString());
OnLobbyPlayerListUpdated?.Invoke(users);
break;
case "error":
OnError?.Invoke(msg.Payload.ToString());
break;
}
}
// ---- HTTP helpers ----
private async Task<T> GetRequest<T>(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<T>(request.downloadHandler.text);
}
private async Task PostRequest(string endpoint, object data)
{
await PostRequestRaw(endpoint, data, true);
}
private async Task<T> PostRequest<T>(string endpoint, object data)
{
var resp = await PostRequestRaw(endpoint, data, true);
return resp.success ? JsonConvert.DeserializeObject<T>(resp.body) : default;
}
private async Task<(bool success, string body, string error, Dictionary<string, string> 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<string, string>();
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<string, string> 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<string, string> 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
}

23
PurrLobby/Models/Lobby.cs Normal file
View File

@@ -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<string, string> Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
public bool IsOwner { get; set; }
public List<LobbyUser> Members { get; } = new();
public object? ServerObject { get; set; }
}

413
PurrLobby/Program.cs Normal file
View File

@@ -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<BrotliCompressionProvider>();
o.Providers.Add<GzipCompressionProvider>();
});
// rate limit per ip
builder.Services.AddRateLimiter(o =>
{
o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(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<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
// swagger setup
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(o =>
{
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<string>()
}
});
});
// service singletons
builder.Services.AddSingleton<ILobbyEventHub, LobbyEventHub>();
builder.Services.AddSingleton<ILobbyService, LobbyService>();
var app = builder.Build();
// prod vs dev
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler();
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<SetGameRequest>("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<CreateLobbyRequest>("application/json")
.Produces<Lobby>(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<JoinLobbyRequest>("application/json")
.Produces<Lobby>(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<LeaveLobbyRequest>("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<List<Lobby>>(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<List<LobbyUser>>(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<string>(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<LobbyDataRequest>("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<ReadyRequest>("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<int>(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<int>(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<int>(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<List<LobbyUser>>(StatusCodes.Status200OK);
// run
app.Run();
// dto records
public record SetGameRequest(Guid GameId);
public record CreateLobbyRequest(string OwnerUserId, string OwnerDisplayName, int MaxPlayers, Dictionary<string, string>? Properties);
public record JoinLobbyRequest(string UserId, string DisplayName);
public record LeaveLobbyRequest(string UserId);
public record ReadyRequest(string UserId, bool IsReady);
public record LobbyDataRequest(string Key, string Value);

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"PurrLobby": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:63036;http://localhost:63037"
}
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Hubs\**" />
<Content Remove="Hubs\**" />
<EmbeddedResource Remove="Hubs\**" />
<None Remove="Hubs\**" />
</ItemGroup>
<ItemGroup>
<Compile Remove="ILobbyProvider.cs" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
</ItemGroup>
<!-- Copy static web assets to output on build so running the exe from bin works -->
<ItemGroup>
<Content Update="wwwroot\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -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<LobbyKey, ConcurrentDictionary<WebSocket, Subscriber>> _subscribers = new();
// idle cleanup flags
private readonly ConcurrentDictionary<LobbyKey, byte> _idleCleanupPending = new();
// active ping loops
private readonly ConcurrentDictionary<LobbyKey, byte> _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<byte>(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<ILobbyService>();
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<ILobbyService>();
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<WebSocket>();
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<ILobbyService>();
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 _);
}
});
}
}

View File

@@ -0,0 +1,408 @@
using System.Collections.Concurrent;
using PurrLobby.Models;
namespace PurrLobby.Services;
// lobby service core logic
public interface ILobbyService
{
Task<Lobby> CreateLobbyAsync(Guid gameId, string ownerUserId, string ownerDisplayName, int maxPlayers, Dictionary<string, string>? properties, CancellationToken ct = default);
Task<Lobby?> JoinLobbyAsync(Guid gameId, string lobbyId, string userId, string displayName, CancellationToken ct = default);
Task<bool> LeaveLobbyAsync(Guid gameId, string lobbyId, string userId, CancellationToken ct = default);
Task<bool> LeaveLobbyAsync(Guid gameId, string userId, CancellationToken ct = default);
Task<List<Lobby>> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary<string, string>? filters, CancellationToken ct = default);
Task<bool> SetIsReadyAsync(Guid gameId, string lobbyId, string userId, bool isReady, CancellationToken ct = default);
Task<bool> SetLobbyDataAsync(Guid gameId, string lobbyId, string key, string value, CancellationToken ct = default);
Task<string?> GetLobbyDataAsync(Guid gameId, string lobbyId, string key, CancellationToken ct = default);
Task<List<LobbyUser>> GetLobbyMembersAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
Task<bool> SetAllReadyAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
Task<bool> SetLobbyStartedAsync(Guid gameId, string lobbyId, CancellationToken ct = default);
// stats
Task<int> GetGlobalPlayerCountAsync(CancellationToken ct = default);
Task<int> GetGlobalLobbyCountAsync(CancellationToken ct = default);
Task<int> GetLobbyCountByGameAsync(Guid gameId, CancellationToken ct = default);
Task<List<LobbyUser>> 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<string, string> Properties { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<LobbyUser> 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<string, LobbyState> _lobbies = new();
// user index key gameIdN:userId -> lobbyId
private readonly ConcurrentDictionary<string, string> _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<Lobby> CreateLobbyAsync(Guid gameId, string ownerUserId, string ownerDisplayName, int maxPlayers, Dictionary<string, string>? 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<char> 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<Lobby?> JoinLobbyAsync(Guid gameId, string lobbyId, string userId, string displayName, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || IsInvalidId(userId))
return Task.FromResult<Lobby?>(null);
if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult<Lobby?>(null);
if (state.GameId != gameId)
return Task.FromResult<Lobby?>(null);
// prevent multi lobby join per game
if (_userLobbyIndexByGame.TryGetValue($"{gameId:N}:{userId}", out var existingLobbyId) && existingLobbyId != lobbyId)
return Task.FromResult<Lobby?>(null);
var name = SanitizeString(displayName, DisplayNameMaxLength);
lock (state)
{
if (state.Started) return Task.FromResult<Lobby?>(null);
if (state.Members.Any(m => m.Id == userId))
return Task.FromResult<Lobby?>(Project(state, userId));
if (state.Members.Count >= state.MaxPlayers)
return Task.FromResult<Lobby?>(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<Lobby?>(Project(state, userId));
}
public Task<bool> 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<bool> 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<List<Lobby>> SearchLobbiesAsync(Guid gameId, int maxRoomsToFind, Dictionary<string, string>? filters, CancellationToken ct = default)
{
if (gameId == Guid.Empty)
return Task.FromResult(new List<Lobby>());
var take = Math.Clamp(maxRoomsToFind, 1, 100);
IEnumerable<LobbyState> 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<bool> 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<bool> 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<string?> GetLobbyDataAsync(Guid gameId, string lobbyId, string key, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId) || string.IsNullOrWhiteSpace(key))
return Task.FromResult<string?>(null);
if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult<string?>(null);
if (state.GameId != gameId)
return Task.FromResult<string?>(null);
return Task.FromResult(state.Properties.TryGetValue(key, out var v) ? v : null);
}
public Task<List<LobbyUser>> GetLobbyMembersAsync(Guid gameId, string lobbyId, CancellationToken ct = default)
{
if (gameId == Guid.Empty || IsInvalidId(lobbyId))
return Task.FromResult(new List<LobbyUser>());
if (!_lobbies.TryGetValue(lobbyId, out var state))
return Task.FromResult(new List<LobbyUser>());
if (state.GameId != gameId)
return Task.FromResult(new List<LobbyUser>());
lock (state)
{
return Task.FromResult(state.Members.Select(m => new LobbyUser
{
Id = m.Id,
DisplayName = m.DisplayName,
IsReady = m.IsReady
}).ToList());
}
}
public Task<bool> 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<bool> 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<int> 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<int> GetGlobalLobbyCountAsync(CancellationToken ct = default)
{
var count = _lobbies.Count;
return Task.FromResult(count);
}
public Task<int> GetLobbyCountByGameAsync(Guid gameId, CancellationToken ct = default)
{
var count = _lobbies.Values.Count(l => l.GameId == gameId);
return Task.FromResult(count);
}
public Task<List<LobbyUser>> GetActivePlayersByGameAsync(Guid gameId, CancellationToken ct = default)
{
var players = new Dictionary<string, LobbyUser>();
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());
}
}

View File

@@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PurrLobby</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="/styles.css?v=5" />
</head>
<body>
<div class="container">
<header class="hero">
<img class="logo" src="/purrlogo.png" alt="PurrLobby" />
<h1 class="title">PurrLobby</h1>
</header>
<h2 class="section-title center">Global Stats</h2>
<div class="bigstats">
<div class="bigstat">
<div class="bignum" id="globalPlayers">-</div>
<div class="biglabel">Players</div>
</div>
<div class="bigstat">
<div class="bignum" id="globalLobbies">-</div>
<div class="biglabel">Lobbies</div>
</div>
</div>
<h2 class="section-title">Track Your Game</h2>
<form id="gameForm" class="stack">
<input id="gameIdInput" type="text" placeholder="Enter Game ID (GUID)" required />
<button type="submit">Set Game</button>
</form>
<small id="cookieStatus" class="muted"></small>
<h2 class="section-title center">Game Stats</h2>
<div class="bigstats">
<div class="bigstat">
<div class="bignum" id="gamePlayers">-</div>
<div class="biglabel">Players</div>
</div>
<div class="bigstat">
<div class="bignum" id="gameLobbies">-</div>
<div class="biglabel">Lobbies</div>
</div>
</div>
<div class="center" style="margin: 20px 0;">
<a href="/swagger" class="btn" target="_blank">Swagger API Docs</a>
</div>
<p class="disclaimer">
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
</p>
</div>
<script src="/main.js" type="module"></script>
</body>
</html>

80
PurrLobby/wwwroot/main.js Normal file
View File

@@ -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);
}
});
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -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);
}