Add project files.
This commit is contained in:
25
PurrLobby.sln
Normal file
25
PurrLobby.sln
Normal 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
354
PurrLobby/ILobbyProvider.cs
Normal 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
23
PurrLobby/Models/Lobby.cs
Normal 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
413
PurrLobby/Program.cs
Normal 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);
|
||||||
12
PurrLobby/Properties/launchSettings.json
Normal file
12
PurrLobby/Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"PurrLobby": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"applicationUrl": "https://localhost:63036;http://localhost:63037"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
PurrLobby/PurrLobby.csproj
Normal file
29
PurrLobby/PurrLobby.csproj
Normal 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>
|
||||||
300
PurrLobby/Services/LobbyEventHub.cs
Normal file
300
PurrLobby/Services/LobbyEventHub.cs
Normal 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 _);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
408
PurrLobby/Services/LobbyService.cs
Normal file
408
PurrLobby/Services/LobbyService.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
14
PurrLobby/appsettings.Production.json
Normal file
14
PurrLobby/appsettings.Production.json
Normal 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"
|
||||||
|
}
|
||||||
BIN
PurrLobby/wwwroot/favicon.ico
Normal file
BIN
PurrLobby/wwwroot/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
63
PurrLobby/wwwroot/index.html
Normal file
63
PurrLobby/wwwroot/index.html
Normal 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
80
PurrLobby/wwwroot/main.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
BIN
PurrLobby/wwwroot/purrlogo.png
Normal file
BIN
PurrLobby/wwwroot/purrlogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
115
PurrLobby/wwwroot/styles.css
Normal file
115
PurrLobby/wwwroot/styles.css
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user