mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-27 16:08:47 -07:00
feat: Update 2018 client support with registration defaults and WebSocket handling
This commit is contained in:
@@ -72,11 +72,12 @@ public class Player2018
|
|||||||
[JsonPropertyName("Level")]
|
[JsonPropertyName("Level")]
|
||||||
public int Level { get; set; } = 1;
|
public int Level { get; set; } = 1;
|
||||||
|
|
||||||
// Obfuscated enum in the dump; not a gate (the client just stores it). 3 is
|
// Registration step enum (ProfileSelectionManager.NFCBDCFLBOC):
|
||||||
// the "fully registered" value used elsewhere; verify against a live client
|
// invalid=-1, video=0, birthday=1, email=2, COMPLETE=10. Anything < 10
|
||||||
// if the signup flow re-appears for an existing account.
|
// makes the client run the signup step flow (it got "stuck on birthday").
|
||||||
|
// 10 = fully registered → client goes straight in-game.
|
||||||
[JsonPropertyName("RegistrationStatus")]
|
[JsonPropertyName("RegistrationStatus")]
|
||||||
public int RegistrationStatus { get; set; } = 3;
|
public int RegistrationStatus { get; set; } = 10;
|
||||||
|
|
||||||
[JsonPropertyName("Developer")]
|
[JsonPropertyName("Developer")]
|
||||||
public bool Developer { get; set; }
|
public bool Developer { get; set; }
|
||||||
|
|||||||
@@ -98,11 +98,17 @@ public class PhotonConfig
|
|||||||
|
|
||||||
public class MatchmakingParams
|
public class MatchmakingParams
|
||||||
{
|
{
|
||||||
[JsonPropertyName("MaxPlayersPerRoom")]
|
// 2018 RecNet.LOIBFONMHMF.Deserialize reads exactly TWO required float keys
|
||||||
public int MaxPlayersPerRoom { get; set; } = 8;
|
// via Util.GetKey<Single> (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")]
|
[JsonPropertyName("PreferEmptyRoomsFrequency")]
|
||||||
public int MinPlayersToStart { get; set; } = 1;
|
public float PreferEmptyRoomsFrequency { get; set; } = 0.5f;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LevelProgressionEntry
|
public class LevelProgressionEntry
|
||||||
|
|||||||
@@ -1,29 +1,65 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using DorkNet.Server.Services;
|
using DorkNet.Server.Services;
|
||||||
|
|
||||||
namespace DorkNet.Server.Controllers.API.Notification.V2;
|
namespace DorkNet.Server.Controllers.API.Notification.V2;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Legacy raw-WebSocket notification endpoint. Replaced by the SignalR
|
/// api/notification/v2 — the JUNE-2018 client opens a raw WebSocket here for
|
||||||
/// hub at <c>notify.{apex}/hub/v1</c>. Returns 410 Gone for any client
|
/// push notifications (RecNet.cs:48895, RecNet.IJADBHMBMPG wraps a
|
||||||
/// that still tries the old path.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]/v2")]
|
[Route("api/[controller]/v2")]
|
||||||
public class NotificationController(DomainConfig domain) : ControllerBase
|
public class NotificationController(
|
||||||
|
DomainConfig domain,
|
||||||
|
ILogger<NotificationController> log) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
|
public async Task Connect()
|
||||||
{
|
{
|
||||||
error = "use_signalr_hub",
|
if (!HttpContext.WebSockets.IsWebSocketRequest)
|
||||||
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
|
{
|
||||||
});
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>GET <c>api/notification/hub/v1</c> — returns the
|
using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync();
|
||||||
/// SignalR hub URL the client should open. Without this the
|
log.LogInformation("[notify] 2018 client opened notification WebSocket");
|
||||||
/// watch falls back to its own default and may try the wrong
|
var buf = new byte[4096];
|
||||||
/// scheme. URL is built off the configured deployment apex
|
try
|
||||||
/// (DORKNET_DOMAIN).</summary>
|
{
|
||||||
|
while (ws.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
var result = await ws.ReceiveAsync(
|
||||||
|
new ArraySegment<byte>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>GET <c>api/notification/hub/v1</c> — 2020 SignalR hub pointer
|
||||||
|
/// (unused by the 2018 client; kept for any 2019/2020 caller).</summary>
|
||||||
[HttpGet("/api/notification/hub/v1")]
|
[HttpGet("/api/notification/hub/v1")]
|
||||||
public IActionResult HubInfo() => Ok(new
|
public IActionResult HubInfo() => Ok(new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -166,10 +166,15 @@ public class PlatformLoginV1Controller(
|
|||||||
// account-selection list. Returns a BARE JSON ARRAY of Player2018 objects.
|
// account-selection list. Returns a BARE JSON ARRAY of Player2018 objects.
|
||||||
[HttpPost("getcachedlogins")]
|
[HttpPost("getcachedlogins")]
|
||||||
[HttpGet("getcachedlogins")]
|
[HttpGet("getcachedlogins")]
|
||||||
public async Task<IActionResult> GetCachedLogins(
|
public async Task<IActionResult> GetCachedLogins()
|
||||||
[FromQuery] int Platform = 0, [FromQuery] string? PlatformId = null)
|
|
||||||
{
|
{
|
||||||
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());
|
return Ok(players.Select(ToPlayer2018).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -886,6 +886,11 @@ using (var scope = app.Services.CreateScope())
|
|||||||
// that's already there (so spent-down accounts aren't refilled).
|
// that's already there (so spent-down accounts aren't refilled).
|
||||||
await playerService.BackfillStarterWalletsAsync();
|
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
|
// Backfill the per-player dorm room + DormStateEntity for any
|
||||||
// legacy account that signed in via a path that didn't run
|
// legacy account that signed in via a path that didn't run
|
||||||
// EnsurePersonalDormAsync (early-bird signups, manual SQL
|
// EnsurePersonalDormAsync (early-bird signups, manual SQL
|
||||||
|
|||||||
@@ -517,6 +517,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
|||||||
HairColor = StarterHairColor,
|
HairColor = StarterHairColor,
|
||||||
SkinColor = StarterSkinColor,
|
SkinColor = StarterSkinColor,
|
||||||
};
|
};
|
||||||
|
ApplyRegistrationDefaults(player);
|
||||||
|
|
||||||
db.Players.Add(player);
|
db.Players.Add(player);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -596,6 +597,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
|||||||
HairColor = StarterHairColor,
|
HairColor = StarterHairColor,
|
||||||
SkinColor = StarterSkinColor,
|
SkinColor = StarterSkinColor,
|
||||||
};
|
};
|
||||||
|
ApplyRegistrationDefaults(player);
|
||||||
db.Players.Add(player);
|
db.Players.Add(player);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -629,6 +631,43 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2018 client runs a signup step-flow (video→birthday→email→complete) for
|
||||||
|
// accounts missing these — and its screen-mode UI is unusable, so accounts
|
||||||
|
// are born COMPLETE: an adult birthday, a synthetic email, and a default
|
||||||
|
// password ("dorknet"). RegistrationStatus is reported as complete(10) in
|
||||||
|
// the Player2018 wire shape.
|
||||||
|
private static readonly DateTime DefaultBirthday = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
private static readonly string DefaultPasswordHash = BCrypt.Net.BCrypt.HashPassword("dorknet", 11);
|
||||||
|
|
||||||
|
private static void ApplyRegistrationDefaults(PlayerEntity p)
|
||||||
|
{
|
||||||
|
p.Birthday ??= DefaultBirthday;
|
||||||
|
if (string.IsNullOrEmpty(p.Email))
|
||||||
|
p.Email = $"{(string.IsNullOrEmpty(p.Username) ? "player" : p.Username).ToLowerInvariant()}@dorknet.local";
|
||||||
|
if (string.IsNullOrEmpty(p.PasswordHash)) p.PasswordHash = DefaultPasswordHash;
|
||||||
|
// 2018 SCREEN MODE is a developer-gated feature: SessionManager.BIAOMKDOLDL
|
||||||
|
// (which keeps screen mode enabled post-login) = account.Developer
|
||||||
|
// (RecNet EEBPLECPEGD.Deserialize "Developer" key). Without it the client
|
||||||
|
// demands a headset (vr_device_required) after login. Grant it so the
|
||||||
|
// screen-mode (non-VR) client is playable on this local-test branch.
|
||||||
|
p.IsDeveloper = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Complete 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.</summary>
|
||||||
|
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<PlayerEntity?> GetByIdAsync(long id) =>
|
public async Task<PlayerEntity?> GetByIdAsync(long id) =>
|
||||||
await db.Players.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == id);
|
await db.Players.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == id);
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user