diff --git a/.gitignore b/.gitignore index 1461f82..147ac2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,14 @@ ## .NET bin/ obj/ +build/ *.user *.suo .vs/ .vscode/ *.csproj.bak dist/ +local-releases/ ## Data / secrets # Local SQLite + uploaded blobs only — DON'T match the source-tree # DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on diff --git a/DorkNet.ClientMod/DorkNet.ClientMod.csproj b/DorkNet.ClientMod/DorkNet.ClientMod.csproj index 1390eae..341adf0 100644 --- a/DorkNet.ClientMod/DorkNet.ClientMod.csproj +++ b/DorkNet.ClientMod/DorkNet.ClientMod.csproj @@ -19,17 +19,32 @@ they would be derivative works of the game anyway, so we never reference them. Only MelonLoader's own DLLs are needed. - tools/build-patcher-zip.ps1 downloads the MelonLoader release zip - and points MelonLoaderDir at its extracted contents, so a fresh - contributor checkout builds without a local Rec Room install. - - To build outside the script (manual dev loop): - dotnet build -p:MelonLoaderDir= + By default the project downloads the pinned MelonLoader release + into DorkNet.ClientMod/build/ and references those DLLs. Pass + -p:MelonLoaderDir= only when you + deliberately want to use a local override. --> - $(MSBuildThisFileDirectory)..\build\melonloader + v0.6.6 + $(MSBuildThisFileDirectory)build\melonloader + $(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip + $(MelonLoaderCacheRoot)\MelonLoader + + + + + + $(MelonLoaderDir)\net6\MelonLoader.dll diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 913ddac..5479861 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -42,6 +42,7 @@ public class Mod : MelonMod internal static string? DiagnosticsLogPath; private static readonly Dictionary ResolvedTypeCache = new(); private readonly HashSet _diagnosticPatchLabels = new(); + private bool _networkPatchesRegistered; private bool _diagnosticCoreRegistered; private bool _diagnosticGameComplete; private int _diagnosticRetryFrame; @@ -75,6 +76,7 @@ public class Mod : MelonMod Instance = this; Log.Msg("=== DorkNet ClientMod loading ==="); LoadConfig(); + RegisterNetworkPatches(); DiagnosticPatches.Write("[lifecycle] OnInitializeMelon"); } @@ -82,12 +84,7 @@ public class Mod : MelonMod { DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon"); Log.Msg("=== Registering client patches ==="); - TryPatch("Uri ctor (string)", typeof(Uri), "ctor", - new[] { typeof(string) }, - prefix: nameof(UriPatches.UriStringCtor_Prefix)); - TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor", - new[] { typeof(string), typeof(UriKind) }, - prefix: nameof(UriPatches.UriStringCtor_Prefix)); + RegisterNetworkPatches(); TryPatchByName("PhotonNetwork", "ConnectUsingSettings", prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix)); TryPatchByName("NetworkingPeer", "CallAuthenticate", @@ -189,6 +186,57 @@ public class Mod : MelonMod Log.Msg("=== Client patches registered ==="); } + private void RegisterNetworkPatches() + { + if (_networkPatchesRegistered) return; + + var complete = true; + complete &= TryPatch("Uri ctor (string)", typeof(Uri), "ctor", + new[] { typeof(string) }, + prefix: nameof(UriPatches.UriStringCtor_Prefix)); + complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor", + new[] { typeof(string), typeof(UriKind) }, + prefix: nameof(UriPatches.UriStringCtor_Prefix)); + complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send", + prefix: nameof(UriPatches.HttpRequestSend_Prefix)); + complete &= TryPatchHttpManagerStringSendRequestOverloads(); + complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest", + args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) }, + prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix)); + + _networkPatchesRegistered = complete; + } + + private bool TryPatchHttpManagerStringSendRequestOverloads() + { + var type = ResolveType("BestHTTP.HTTPManager"); + if (type is null) + { + Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string): type not found"); + return false; + } + + var prefix = GetPatchMethod(nameof(UriPatches.HttpManagerSendRequestString_Prefix)); + var patched = 0; + foreach (var method in AccessTools.GetDeclaredMethods(type)) + { + if (method.Name != "SendRequest") continue; + var parameters = method.GetParameters(); + if (parameters.Length == 0 || parameters[0].ParameterType != typeof(string)) continue; + HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefix)); + patched++; + } + + if (patched == 0) + { + Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string)"); + return false; + } + + Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}"); + return true; + } + public override void OnUpdate() { if (_diagnosticGameComplete) return; @@ -464,18 +512,70 @@ public class Mod : MelonMod internal static class UriPatches { + public static void HttpManagerSendRequestString_Prefix(ref string url) + { + RewriteString(ref url, "httpmanager-rewrite"); + } + + public static void HttpManagerSendRequestObject_Prefix(object request) + { + RewriteRequestUri(request, "httpmanager-request-rewrite"); + } + public static void UriStringCtor_Prefix(ref string uriString) + { + RewriteString(ref uriString, "uri-rewrite"); + } + + public static void HttpRequestSend_Prefix(object __instance) + { + RewriteRequestUri(__instance, "http-rewrite"); + } + + private static void RewriteRequestUri(object request, string label) + { + try + { + var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance); + if (uriProp?.GetValue(request) is not Uri uri) return; + + var rewritten = RewriteUri(uri); + if (rewritten is null) return; + + uriProp.SetValue(request, rewritten); + Mod.Log.Msg($"[{label}] {uri} → {rewritten}"); + } + catch (Exception ex) + { + Mod.Log.Warning($"[{label}] failed: {ex.Message}"); + } + } + + private static void RewriteString(ref string uriString, string label) { if (string.IsNullOrEmpty(uriString)) return; var host = Mod.Cfg.ServerHost; if (string.IsNullOrEmpty(host)) return; if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return; var rewritten = uriString.Replace(".rec.net", "." + host); - if (rewritten != uriString) + if (rewritten == uriString) return; + Mod.Log.Msg($"[{label}] {uriString} → {rewritten}"); + uriString = rewritten; + } + + private static Uri? RewriteUri(Uri uri) + { + var host = Mod.Cfg.ServerHost; + if (string.IsNullOrEmpty(host)) return null; + if (!uri.Host.EndsWith(".rec.net", StringComparison.OrdinalIgnoreCase)) return null; + + var service = uri.Host[..^".rec.net".Length]; + if (string.IsNullOrWhiteSpace(service)) return null; + + return new UriBuilder(uri) { - Mod.Log.Msg($"[uri-rewrite] {uriString} → {rewritten}"); - uriString = rewritten; - } + Host = service + "." + host, + }.Uri; } } diff --git a/DorkNet.Server/Controllers/API/Config/V1/ServiceUrlsController.cs b/DorkNet.Server/Controllers/API/Config/V1/ServiceUrlsController.cs index fe6b770..b43f0b6 100644 --- a/DorkNet.Server/Controllers/API/Config/V1/ServiceUrlsController.cs +++ b/DorkNet.Server/Controllers/API/Config/V1/ServiceUrlsController.cs @@ -15,5 +15,5 @@ public class ServiceUrlsController(DomainConfig domain) : ControllerBase { [HttpGet("services")] public ActionResult> Get() - => Ok(ConfigService.BuildServiceUrlMap(domain.Apex)); + => Ok(ConfigService.BuildServiceUrlMap(domain)); } diff --git a/DorkNet.Server/Controllers/Ns/NsController.cs b/DorkNet.Server/Controllers/Ns/NsController.cs index 2712a54..32c250e 100644 --- a/DorkNet.Server/Controllers/Ns/NsController.cs +++ b/DorkNet.Server/Controllers/Ns/NsController.cs @@ -37,13 +37,13 @@ public class NsController : ControllerBase // code: locker)" in BootSequence. [HttpGet("/", Order = -1000)] public ActionResult> Root() => - Ok(ConfigService.BuildServiceUrlMap(_domain.Apex)); + Ok(ConfigService.BuildServiceUrlMap(_domain)); // Some builds fetch /v1 or /v1/services directly [HttpGet("/v1")] [HttpGet("/v1/services")] public ActionResult> Services() => - Ok(ConfigService.BuildServiceUrlMap(_domain.Apex)); + Ok(ConfigService.BuildServiceUrlMap(_domain)); // Ping / health used by the client keep-alive loop [HttpGet("/v1/ping")] diff --git a/DorkNet.Server/Program.cs b/DorkNet.Server/Program.cs index c094f1b..29e3190 100644 --- a/DorkNet.Server/Program.cs +++ b/DorkNet.Server/Program.cs @@ -87,14 +87,21 @@ var apex = builder.Configuration["Domain:Apex"] ?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN") ?? "localhost"; -builder.Services.AddSingleton(new DomainConfig(apex)); +var domainScheme = + builder.Configuration["Domain:Scheme"] + ?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_SCHEME") + ?? "https"; +var domainPort = + builder.Configuration["Domain:Port"] + ?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN_PORT"); +builder.Services.AddSingleton(new DomainConfig(apex, domainScheme, domainPort)); builder.Services.Configure(opt => { opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" }; opt.AllowEmptyHosts = true; opt.IncludeFailureMessage = true; }); -Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]"); +Console.WriteLine($"[domain] apex={apex}, scheme={domainScheme}, port={domainPort}, allowedHosts=[{apex}, *.{apex}, localhost]"); // ── Services ────────────────────────────────────────────────────────────────── builder.Services.AddScoped(); @@ -441,28 +448,18 @@ app.Use(async (ctx, next) => } }); -// ── Migrate DB ──────────────────────────────────────────────────────────────── -// EF Core migrations under Migrations/. On boot we apply any pending ones — -// `Migrate()` is a no-op when the DB is already up to date and applies the -// missing migrations in order otherwise. Schema evolution is now exclusively -// done via `dotnet ef migrations add `; never call `EnsureCreated` (it -// bypasses the migration history table and leaves the db in a broken -// "exists but no __EFMigrationsHistory entries" state that future Migrate -// calls can't reason about). -// -// One-time upgrade path: DBs created by the previous EnsureCreated boot -// path already contain the Initial schema but have no __EFMigrationsHistory -// entries. The baseline detector below catches that case and inserts a -// row marking Initial as already applied, so Migrate() will only apply the -// Phase1+Phase2 deltas (column adds) instead of trying to re-CREATE TABLE. +// ── Create/repair DB ────────────────────────────────────────────────────────── +// Local launcher installs use SQLite as an appliance database. For that path, +// create the current EF model directly instead of replaying the migration chain; +// then run small idempotent repair patches for DBs made by older launcher builds. +// Hosted Postgres keeps its existing EnsureCreated + explicit patch path below. using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); if (db.Database.IsSqlite()) { - // Sqlite-only legacy baseline path — sqlite_master probes inside. - BaselineExistingSchemaIfNeeded(db); - db.Database.Migrate(); + db.Database.EnsureCreated(); + ApplySqliteCompatibilityPatches(db); } else { @@ -1185,3 +1182,47 @@ static void BaselineExistingSchemaIfNeeded(DorkNetDbContext db) Log.Information( "[migrations] Detected legacy EnsureCreated DB; baselined as 20260508092637_Initial. Subsequent migrations will apply normally."); } + +static void ApplySqliteCompatibilityPatches(DorkNetDbContext db) +{ + if (!db.Database.IsSqlite()) return; + AddSqliteColumnIfMissing(db, "Rooms", "HiddenFromBrowse", + @"""HiddenFromBrowse"" INTEGER NOT NULL DEFAULT 0"); +} + +static void AddSqliteColumnIfMissing(DorkNetDbContext db, string table, string column, string definition) +{ + var conn = db.Database.GetDbConnection(); + var shouldClose = conn.State == System.Data.ConnectionState.Closed; + if (shouldClose) conn.Open(); + try + { + var exists = false; + using var check = conn.CreateCommand(); + check.CommandText = $"PRAGMA table_info(\"{table.Replace("\"", "\"\"")}\");"; + { + using var reader = check.ExecuteReader(); + while (reader.Read()) + { + if (!string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase)) continue; + exists = true; + break; + } + } + + if (exists) + { + Log.Information("[sqlite-compat] {Table}.{Column} already exists", table, column); + return; + } + + using var alter = conn.CreateCommand(); + alter.CommandText = $"ALTER TABLE \"{table.Replace("\"", "\"\"")}\" ADD COLUMN {definition};"; + alter.ExecuteNonQuery(); + Log.Information("[sqlite-compat] Added {Table}.{Column}", table, column); + } + finally + { + if (shouldClose) conn.Close(); + } +} diff --git a/DorkNet.Server/Services/ConfigService.cs b/DorkNet.Server/Services/ConfigService.cs index 47546bb..b6d7783 100644 --- a/DorkNet.Server/Services/ConfigService.cs +++ b/DorkNet.Server/Services/ConfigService.cs @@ -20,14 +20,12 @@ public class ConfigService(IConfiguration config, DomainConfig domain) // the client used to hit us. The baseUrl parameter is kept // for API stability but is no longer dereferenced. _ = baseUrl; - var apex = domain.Apex; - return new RecRoomConfig { MessageOfTheDay = config["Server:MOTD"] ?? "Welcome to the private server!", - CdnBaseUri = $"https://{domain.Sub("cdn")}", + CdnBaseUri = domain.SubUrl("cdn"), PhotonConfig = photon, - ServiceUrls = BuildServiceUrlMap(apex), + ServiceUrls = BuildServiceUrlMap(domain), ConfigTable = new Dictionary { // Season keys the 2019/2020 client checks at startup @@ -106,4 +104,15 @@ public class ConfigService(IConfiguration config, DomainConfig domain) } return map; } + + public static Dictionary BuildServiceUrlMap(DomainConfig domain) + { + var map = new Dictionary(ServiceSubdomains.Length); + foreach (var (service, sub) in ServiceSubdomains) + { + var host = string.IsNullOrEmpty(sub) ? domain.Apex : domain.Sub(sub); + map[service] = domain.Url(host); + } + return map; + } } diff --git a/DorkNet.Server/Services/DomainConfig.cs b/DorkNet.Server/Services/DomainConfig.cs index 6bff828..4d6332b 100644 --- a/DorkNet.Server/Services/DomainConfig.cs +++ b/DorkNet.Server/Services/DomainConfig.cs @@ -11,8 +11,19 @@ namespace DorkNet.Server.Services; public sealed class DomainConfig { public string Apex { get; } - public DomainConfig(string apex) { Apex = apex; } + public string Scheme { get; } + public string? Port { get; } + + public DomainConfig(string apex, string scheme = "https", string? port = null) + { + Apex = apex; + Scheme = string.IsNullOrWhiteSpace(scheme) ? "https" : scheme.TrimEnd(':', '/', '\\'); + Port = string.IsNullOrWhiteSpace(port) ? null : port.Trim().TrimStart(':'); + } + public string Sub(string prefix) => $"{prefix}.{Apex}"; + public string Url(string host) => Port is null ? $"{Scheme}://{host}" : $"{Scheme}://{host}:{Port}"; + public string SubUrl(string prefix) => Url(Sub(prefix)); public static bool MatchesSubdomain(string host, string prefix) => host.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase); } diff --git a/DorkNet.Server/Services/RoomService.cs b/DorkNet.Server/Services/RoomService.cs index dd15a9a..67b9cad 100644 --- a/DorkNet.Server/Services/RoomService.cs +++ b/DorkNet.Server/Services/RoomService.cs @@ -923,14 +923,27 @@ public class RoomService(DorkNetDbContext db) // Name="BloodMoon" because SaveChanges hadn't fired yet, so // the image binding silently no-op'd. Mutating the already- // loaded entity sidesteps that race. - var bloodmoon = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "BloodMoon" || r.Name == "Crescendo"); - if (bloodmoon is not null) + var crescendoRooms = await db.Rooms + .Where(r => r.Name.ToLower() == "bloodmoon" || r.Name.ToLower() == "crescendo") + .ToListAsync(); + var crescendo = crescendoRooms.FirstOrDefault(r => + string.Equals(r.Name, "Crescendo", StringComparison.OrdinalIgnoreCase)); + var bloodMoon = crescendoRooms.FirstOrDefault(r => + string.Equals(r.Name, "BloodMoon", StringComparison.OrdinalIgnoreCase)); + var shouldRenameBloodMoon = crescendo is null; + crescendo ??= bloodMoon; + if (crescendo is not null) { - if (bloodmoon.Name != "Crescendo") bloodmoon.Name = "Crescendo"; - if (string.IsNullOrWhiteSpace(bloodmoon.Description)) - bloodmoon.Description = "Brave the haunted halls of Castle Dracula and survive the night."; - if (bloodmoon.ImageName != "image_Crescendo.png") - bloodmoon.ImageName = "image_Crescendo.png"; + if (shouldRenameBloodMoon && crescendo.Name != "Crescendo") + crescendo.Name = "Crescendo"; + if (string.IsNullOrWhiteSpace(crescendo.Description)) + crescendo.Description = "Brave the haunted halls of Castle Dracula and survive the night."; + if (crescendo.ImageName != "image_Crescendo.png") + crescendo.ImageName = "image_Crescendo.png"; + } + foreach (var duplicate in crescendoRooms.Where(r => r.Id != crescendo?.Id)) + { + duplicate.HiddenFromBrowse = true; } var paintball = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "Paintball"); diff --git a/tools/build-patcher-zip.ps1 b/tools/build-patcher-zip.ps1 index 61c4605..eb528a0 100644 --- a/tools/build-patcher-zip.ps1 +++ b/tools/build-patcher-zip.ps1 @@ -120,6 +120,7 @@ try { 'config_dest' = 'MelonLoader/UserData/dorknet-clientmod.json' 'old_plugin_paths' = @( 'BepInEx/plugins/DorkNet.ClientPatch.dll' + 'MelonLoader/Mods/DorkNet.ClientMod.dll' ) 'steam_stub' = @{ 'api_dll' = 'steam_api64.dll'