From 2eba82b342c0b6ad47215301daf256589a7086c0 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:35:07 +0300 Subject: [PATCH] feat: Update 2018 client support with registration defaults and WebSocket handling --- DorkNet.Models/Auth/Login2018.cs | 9 +-- DorkNet.Models/Config/RecRoomConfig.cs | 14 +++-- .../Notification/V2/NotificationController.cs | 62 +++++++++++++++---- .../V1/PlatformLoginV1Controller.cs | 11 +++- DorkNet.Server/Program.cs | 5 ++ DorkNet.Server/Services/PlayerService.cs | 39 ++++++++++++ DorkNet.Server/appsettings.Local.json | 22 +++++++ 7 files changed, 138 insertions(+), 24 deletions(-) create mode 100644 DorkNet.Server/appsettings.Local.json diff --git a/DorkNet.Models/Auth/Login2018.cs b/DorkNet.Models/Auth/Login2018.cs index f70a151..231e9ab 100644 --- a/DorkNet.Models/Auth/Login2018.cs +++ b/DorkNet.Models/Auth/Login2018.cs @@ -72,11 +72,12 @@ public class Player2018 [JsonPropertyName("Level")] public int Level { get; set; } = 1; - // Obfuscated enum in the dump; not a gate (the client just stores it). 3 is - // the "fully registered" value used elsewhere; verify against a live client - // if the signup flow re-appears for an existing account. + // Registration step enum (ProfileSelectionManager.NFCBDCFLBOC): + // invalid=-1, video=0, birthday=1, email=2, COMPLETE=10. Anything < 10 + // makes the client run the signup step flow (it got "stuck on birthday"). + // 10 = fully registered → client goes straight in-game. [JsonPropertyName("RegistrationStatus")] - public int RegistrationStatus { get; set; } = 3; + public int RegistrationStatus { get; set; } = 10; [JsonPropertyName("Developer")] public bool Developer { get; set; } diff --git a/DorkNet.Models/Config/RecRoomConfig.cs b/DorkNet.Models/Config/RecRoomConfig.cs index a104747..2194c6e 100644 --- a/DorkNet.Models/Config/RecRoomConfig.cs +++ b/DorkNet.Models/Config/RecRoomConfig.cs @@ -98,11 +98,17 @@ public class PhotonConfig public class MatchmakingParams { - [JsonPropertyName("MaxPlayersPerRoom")] - public int MaxPlayersPerRoom { get; set; } = 8; + // 2018 RecNet.LOIBFONMHMF.Deserialize reads exactly TWO required float keys + // via Util.GetKey (verified via dnlib). It's fetched as an optional + // object (GetObjectKey) inside config/v2, but once PRESENT the client + // deserializes it — wrong/missing keys throw KeyNotFoundException → + // "Received malformed RecNet response" → "Failed to connect to RecNet" at + // boot. The 2020 MaxPlayersPerRoom/MinPlayersToStart keys are NOT read here. + [JsonPropertyName("PreferFullRoomsFrequency")] + public float PreferFullRoomsFrequency { get; set; } = 0.5f; - [JsonPropertyName("MinPlayersToStart")] - public int MinPlayersToStart { get; set; } = 1; + [JsonPropertyName("PreferEmptyRoomsFrequency")] + public float PreferEmptyRoomsFrequency { get; set; } = 0.5f; } public class LevelProgressionEntry diff --git a/DorkNet.Server/Controllers/API/Notification/V2/NotificationController.cs b/DorkNet.Server/Controllers/API/Notification/V2/NotificationController.cs index c605673..5cf23cb 100644 --- a/DorkNet.Server/Controllers/API/Notification/V2/NotificationController.cs +++ b/DorkNet.Server/Controllers/API/Notification/V2/NotificationController.cs @@ -1,29 +1,65 @@ +using System.Net.WebSockets; using Microsoft.AspNetCore.Mvc; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Notification.V2; /// -/// Legacy raw-WebSocket notification endpoint. Replaced by the SignalR -/// hub at notify.{apex}/hub/v1. Returns 410 Gone for any client -/// that still tries the old path. +/// api/notification/v2 — the JUNE-2018 client opens a raw WebSocket here for +/// push notifications (RecNet.cs:48895, RecNet.IJADBHMBMPG wraps a +/// BestHTTP.WebSocket with open/message(string)/error events). The 2020 client +/// replaced this with the SignalR hub at notify.{apex}/hub/v1. +/// +/// This branch targets 2018, so we ACCEPT the WebSocket and hold it open. We +/// don't push anything yet (server-driven notifications are a later feature) — +/// an open, quiet socket keeps the client's notification subsystem "connected" +/// instead of erroring/retry-looping on the old 410 Gone. Incoming frames +/// (subscribe/auth) are drained and ignored. /// [ApiController] [Route("api/[controller]/v2")] -public class NotificationController(DomainConfig domain) : ControllerBase +public class NotificationController( + DomainConfig domain, + ILogger log) : ControllerBase { [HttpGet] - public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new + public async Task Connect() { - error = "use_signalr_hub", - hint = $"connect to {domain.Sub("notify")}/hub/v1 instead", - }); + if (!HttpContext.WebSockets.IsWebSocketRequest) + { + // Non-WS probe (or a curl check) — return an empty notification + // list rather than 410 so HTTP callers don't treat it as an error. + Response.StatusCode = StatusCodes.Status200OK; + Response.ContentType = "application/json"; + await Response.WriteAsync("[]"); + return; + } - /// GET api/notification/hub/v1 — returns the - /// SignalR hub URL the client should open. Without this the - /// watch falls back to its own default and may try the wrong - /// scheme. URL is built off the configured deployment apex - /// (DORKNET_DOMAIN). + using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); + log.LogInformation("[notify] 2018 client opened notification WebSocket"); + var buf = new byte[4096]; + try + { + while (ws.State == WebSocketState.Open) + { + var result = await ws.ReceiveAsync( + new ArraySegment(buf), HttpContext.RequestAborted); + if (result.MessageType == WebSocketMessageType.Close) + { + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, + "bye", HttpContext.RequestAborted); + break; + } + // Drain & ignore subscribe/auth frames — no server push yet. + } + } + catch (OperationCanceledException) { /* client navigated away */ } + catch (WebSocketException) { /* abrupt disconnect — normal */ } + log.LogDebug("[notify] notification WebSocket closed"); + } + + /// GET api/notification/hub/v1 — 2020 SignalR hub pointer + /// (unused by the 2018 client; kept for any 2019/2020 caller). [HttpGet("/api/notification/hub/v1")] public IActionResult HubInfo() => Ok(new { diff --git a/DorkNet.Server/Controllers/API/PlatformLogin/V1/PlatformLoginV1Controller.cs b/DorkNet.Server/Controllers/API/PlatformLogin/V1/PlatformLoginV1Controller.cs index 590dc36..83ab9d4 100644 --- a/DorkNet.Server/Controllers/API/PlatformLogin/V1/PlatformLoginV1Controller.cs +++ b/DorkNet.Server/Controllers/API/PlatformLogin/V1/PlatformLoginV1Controller.cs @@ -166,10 +166,15 @@ public class PlatformLoginV1Controller( // account-selection list. Returns a BARE JSON ARRAY of Player2018 objects. [HttpPost("getcachedlogins")] [HttpGet("getcachedlogins")] - public async Task GetCachedLogins( - [FromQuery] int Platform = 0, [FromQuery] string? PlatformId = null) + public async Task GetCachedLogins() { - var players = await playerService.GetCachedLoginsAsync(Platform, PlatformId); + // The 2018 client sends Platform/PlatformId in the FORM BODY + // (application/x-www-form-urlencoded), not the query string — so read + // from form/query/json. Reading [FromQuery] only returned [] (empty + // account grid + broke +profile auto-login). Verified from the wire: + // req=Platform=0&PlatformId=76561198969189675. + var p = await ReadParamsAsync(); + var players = await playerService.GetCachedLoginsAsync(p.GetInt("Platform"), p.Get("PlatformId")); return Ok(players.Select(ToPlayer2018).ToList()); } diff --git a/DorkNet.Server/Program.cs b/DorkNet.Server/Program.cs index ff0ef7d..7e4f1f5 100644 --- a/DorkNet.Server/Program.cs +++ b/DorkNet.Server/Program.cs @@ -886,6 +886,11 @@ using (var scope = app.Services.CreateScope()) // that's already there (so spent-down accounts aren't refilled). await playerService.BackfillStarterWalletsAsync(); + // 2018 client: complete any account missing birthday/email/password so the + // (screen-mode-broken) signup step flow never appears. See + // PlayerService.ApplyRegistrationDefaults. + await playerService.BackfillAccountCompletionAsync(); + // Backfill the per-player dorm room + DormStateEntity for any // legacy account that signed in via a path that didn't run // EnsurePersonalDormAsync (early-bird signups, manual SQL diff --git a/DorkNet.Server/Services/PlayerService.cs b/DorkNet.Server/Services/PlayerService.cs index 1fe19f9..2773eeb 100644 --- a/DorkNet.Server/Services/PlayerService.cs +++ b/DorkNet.Server/Services/PlayerService.cs @@ -517,6 +517,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILoggerComplete any account missing birthday/email/password so the 2018 + /// client never enters its (broken-in-screen-mode) signup step flow. Runs at + /// startup like the other backfills. + public async Task BackfillAccountCompletionAsync() + { + var incomplete = await db.Players + .Where(p => p.Birthday == null || p.Email == null || p.PasswordHash == null || !p.IsDeveloper) + .ToListAsync(); + if (incomplete.Count == 0) return; + foreach (var p in incomplete) ApplyRegistrationDefaults(p); + await db.SaveChangesAsync(); + logger.LogInformation( + "[backfill] completed registration (birthday/email/password) for {N} account(s)", incomplete.Count); + } + public async Task GetByIdAsync(long id) => await db.Players.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == id); diff --git a/DorkNet.Server/appsettings.Local.json b/DorkNet.Server/appsettings.Local.json new file mode 100644 index 0000000..9da9611 --- /dev/null +++ b/DorkNet.Server/appsettings.Local.json @@ -0,0 +1,22 @@ +{ + "Jwt": { + "Secret": "dorknet-local-dev-secret-please-change-0123456789abcdef" + }, + "Domain": { + "Apex": "localhost", + "Scheme": "http", + "SingleOriginBaseUrl": "http://127.0.0.1:5005" + }, + "Database": { + "Provider": "sqlite" + }, + "Photon": { + "AppId": "", + "VoiceAppId": "", + "CloudRegion": "us", + "RequireCustomAuth": false + }, + "Server": { + "MOTD": "DorkNet 2018 local test server" + } +}