mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Enhance UgcPurchasablesController to handle bulk requests with object arrays
- Updated Bulk method to parse incoming JSON object arrays for UGC item IDs. - Introduced ReadBulkIdsAsync to handle both object arrays and flat GUID lists. - Added synthetic placeholder responses for missing UGC items to prevent client errors. feat: Implement MatchmakeRoom and MatchmakeSubRoom endpoints in GoToController - Added MatchmakeRoom and MatchmakeSubRoom methods to facilitate joining public and sub-rooms by numeric ID. - Enhanced ApplySubRoomMutation to ensure proper state transitions for sub-rooms. - Improved logging for matchmaking actions. fix: Refactor PlayerSettingsController to handle database save exceptions - Introduced SaveSettingsChangesAsync to manage DbUpdateException for busy SQLite scenarios. - Updated methods to call SaveSettingsChangesAsync instead of direct SaveChangesAsync. chore: Update URL coverage documentation for new matchmaking endpoints - Documented new MatchmakeRoom and MatchmakeSubRoom endpoints in url-coverage.md. chore: Add default room data file for room loading - Included default_room.room to serve as a fallback for missing room data. fix: Improve SQLite concurrency handling in DatabaseBootstrap - Configured SQLite to use WAL mode and set a busy timeout for better concurrency. chore: Create SqliteErrors utility for handling SQLite exceptions - Added SqliteErrors class to encapsulate logic for detecting busy SQLite exceptions. refactor: Update RoomDataBlobService to serve default room data - Modified GetDefaultBlob to load and serve a default room blob with proper role data.
This commit is contained in:
+670
-9
@@ -20,6 +20,8 @@
|
||||
// Il2CppAssemblies directory just to write the source.
|
||||
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes;
|
||||
using MelonLoader;
|
||||
using System;
|
||||
using System.Collections;
|
||||
@@ -30,6 +32,7 @@ using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
|
||||
[assembly: MelonGame(null, null)]
|
||||
@@ -49,6 +52,7 @@ public class Mod : MelonMod
|
||||
private bool _diagnosticGameComplete;
|
||||
private int _diagnosticRetryFrame;
|
||||
private bool _firstUpdateLogged;
|
||||
private bool _processExceptionHandlersRegistered;
|
||||
|
||||
// Config backed by a plain JSON file under MelonLoader/UserData.
|
||||
// The defaults below match the recommended install, so dropping this
|
||||
@@ -143,6 +147,7 @@ public class Mod : MelonMod
|
||||
Instance = this;
|
||||
Log.Msg("=== DorkNet ClientMod loading ===");
|
||||
LoadConfig();
|
||||
RegisterProcessExceptionHandlers();
|
||||
RegisterNetworkPatches();
|
||||
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
|
||||
}
|
||||
@@ -577,6 +582,9 @@ public class Mod : MelonMod
|
||||
TryPatchByName("BestHTTP.HTTPRequest", "AddHeader",
|
||||
args: new[] { typeof(string), typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.HeaderSet_Prefix));
|
||||
TryPatchByName("BestHTTP.HTTPRequest", "CallCallback",
|
||||
prefix: nameof(HttpTracePatches.CallCallback_Prefix),
|
||||
postfix: nameof(HttpTracePatches.CallCallback_Postfix));
|
||||
complete &= TryPatchHttpManagerStringSendRequestOverloads();
|
||||
complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest",
|
||||
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
|
||||
@@ -595,7 +603,11 @@ public class Mod : MelonMod
|
||||
var recNetServiceType = ResolveType("GJDLNNLKDIJ") ?? typeof(object);
|
||||
TryPatchByName("BNDIAONDFFF", "ctor",
|
||||
args: new[] { httpMethodsType, recNetServiceType, typeof(string) },
|
||||
prefix: nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix));
|
||||
prefix: nameof(HttpTracePatches.RecNetRequestBuilderCtor_Prefix),
|
||||
postfix: nameof(HttpTracePatches.RecNetRequestBuilderCtor_Postfix));
|
||||
TryPatchByName("BNDIAONDFFF", "FGHNOKLDOKO",
|
||||
prefix: nameof(HttpTracePatches.RecNetResponseTuple_Prefix),
|
||||
postfix: nameof(HttpTracePatches.RecNetResponseTuple_Postfix));
|
||||
|
||||
_networkPatchesRegistered = complete;
|
||||
}
|
||||
@@ -1002,7 +1014,7 @@ public class Mod : MelonMod
|
||||
{
|
||||
// Look in all three patch holder classes — small enough that a
|
||||
// linear scan is cheaper than per-class lookups.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(FileHashCheckerPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(HttpTracePatches), typeof(AuthPatches), typeof(AntiCheatPatches), typeof(FileHashCheckerPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(RoomLoadDiagnostics), typeof(SavePatches), typeof(ChatPatches), typeof(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
@@ -1028,8 +1040,6 @@ public class Mod : MelonMod
|
||||
|
||||
private bool RegisterGameDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
|
||||
// CheatManager fires these when its anti-cheat heuristics trip —
|
||||
// logging them surfaces "client thinks it's been tampered with",
|
||||
// a common cause of silent dorm-drops / kicks on a modded client.
|
||||
@@ -1046,22 +1056,26 @@ public class Mod : MelonMod
|
||||
"OnAdvancedMovementCheatDetected",
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticByName("CheatManager", method,
|
||||
TryPatchDiagnosticByName("CheatManager", method,
|
||||
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
|
||||
TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
|
||||
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
|
||||
TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
|
||||
args: new[] { typeof(int) },
|
||||
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuitInt_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("SessionManager", "FatalApplicationQuit",
|
||||
TryPatchDiagnosticByName("SessionManager", "FatalApplicationQuit",
|
||||
args: new[] { typeof(int), typeof(string) },
|
||||
prefix: nameof(DiagnosticPatches.SessionManagerFatalApplicationQuit_Prefix),
|
||||
logMiss: logMisses);
|
||||
TryPatchDiagnosticAllOverloads("BNDIAONDFFF", "FGHNOKLDOKO",
|
||||
finalizer: nameof(HttpTracePatches.RecNetResponseTuple_Finalizer),
|
||||
logMiss: logMisses);
|
||||
RegisterDeepRoomDiagnostics(logMisses);
|
||||
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were
|
||||
// pure-logging diagnostics that fired during Unity's very first
|
||||
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam
|
||||
@@ -1070,7 +1084,7 @@ public class Mod : MelonMod
|
||||
// hooks gave us no behavioral leverage — only a `[call] …` log
|
||||
// line — so they're disabled. If anyone needs Steam-side visibility
|
||||
// later, route it through the game's own log lines instead.
|
||||
return complete;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryPatchDiagnosticByName(string typeName, string methodName,
|
||||
@@ -1087,6 +1101,190 @@ public class Mod : MelonMod
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryPatchDiagnosticAllOverloads(string typeName, string methodName,
|
||||
string? prefix = null, string? postfix = null,
|
||||
string? finalizer = null, bool logMiss = true)
|
||||
{
|
||||
var label = $"{typeName}.{methodName}(*)";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var type = ResolveType(typeName);
|
||||
if (type is null)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
var patched = 0;
|
||||
foreach (var method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != methodName) continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
|
||||
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)),
|
||||
finalizer: finalizer is null ? null : new HarmonyMethod(GetPatchMethod(finalizer)));
|
||||
patched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] {type.FullName}.{method.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning($"[patch-miss] {label}: no overloads matched");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
Log.Msg($"[patch-ok] {label} x{patched}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool RegisterDeepRoomDiagnostics(bool logMisses)
|
||||
{
|
||||
var complete = true;
|
||||
|
||||
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogError",
|
||||
args: new[] { typeof(object) },
|
||||
prefix: nameof(RoomLoadDiagnostics.LogError_Prefix),
|
||||
logMiss: logMisses);
|
||||
complete &= TryPatchDiagnosticByName("UnityEngine.Debug", "LogException",
|
||||
args: new[] { typeof(Exception) },
|
||||
prefix: nameof(RoomLoadDiagnostics.LogException_Prefix),
|
||||
logMiss: logMisses);
|
||||
|
||||
foreach (var method in new[]
|
||||
{
|
||||
"ConnectUsingSettings",
|
||||
"ConnectToRegion",
|
||||
"JoinRoom",
|
||||
"JoinOrCreateRoom",
|
||||
"CreateRoom",
|
||||
"LeaveRoom",
|
||||
"Disconnect",
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticAllOverloads("PhotonNetwork", method,
|
||||
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
foreach (var item in new (string Type, string Method)[]
|
||||
{
|
||||
("CMMIJKCDKPL", "CDNPLAGEDBK"),
|
||||
("KGHEDLNPANK", "CDNPLAGEDBK"),
|
||||
("HMPNINFEDFB", "IJIOJAIBPKE"),
|
||||
("PhotonNetwork", "Disconnect"),
|
||||
})
|
||||
{
|
||||
complete &= TryPatchDiagnosticAllOverloads(item.Type, item.Method,
|
||||
prefix: nameof(RoomLoadDiagnostics.NamedCall_Prefix),
|
||||
finalizer: nameof(RoomLoadDiagnostics.NamedCall_Finalizer),
|
||||
logMiss: logMisses);
|
||||
}
|
||||
|
||||
complete &= PatchKnownRoomLoadMoveNext(logMisses);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool PatchKnownRoomLoadMoveNext(bool logMiss)
|
||||
{
|
||||
const string label = "room-load-state-machines.MoveNext";
|
||||
if (_diagnosticPatchLabels.Contains(label))
|
||||
return true;
|
||||
|
||||
var nameNeedles = new[]
|
||||
{
|
||||
"OMJKHJLFOCO", "AFMPFLKGABO",
|
||||
"AOEBBIFGIKI", "FINJCCNPHEP", "FDDFOKNJIFL", "GMNHJNAHAAI",
|
||||
"MHDOLJJPEAD", "LFGLDJCMJDN", "PFDIKJHMBDI", "ICAGCHKPIOM",
|
||||
"JEBACILNLJI", "DINBAFNHLPJ", "HLHPNABABME", "PAPLNIPKAMG",
|
||||
"IBEOONPEELF", "JHPHMNGOFLB", "NLDBPDCNNCF", "LONOBOBHMJL",
|
||||
"BNDIAONDFFF", "HOONLPFFADG", "PHODPEHLHGM",
|
||||
};
|
||||
|
||||
var patched = 0;
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
foreach (var type in GetLoadableTypes(asm))
|
||||
{
|
||||
var fullName = type.FullName ?? type.Name;
|
||||
var match = false;
|
||||
foreach (var needle in nameNeedles)
|
||||
{
|
||||
if (fullName.IndexOf(needle, StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) continue;
|
||||
|
||||
IEnumerable<MethodInfo> methods;
|
||||
try { methods = AccessTools.GetDeclaredMethods(type); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
if (method.Name != "MoveNext") continue;
|
||||
if (method.IsAbstract || method.ContainsGenericParameters) continue;
|
||||
try
|
||||
{
|
||||
HarmonyInstance.Patch(method,
|
||||
finalizer: new HarmonyMethod(GetPatchMethod(nameof(RoomLoadDiagnostics.MoveNext_Finalizer))));
|
||||
patched++;
|
||||
Log.Msg($"[patch-ok] room-load MoveNext {type.FullName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning($"[patch-fail] room-load MoveNext {type.FullName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
if (logMiss) Log.Warning("[patch-miss] room-load state-machine MoveNext hooks");
|
||||
return false;
|
||||
}
|
||||
|
||||
_diagnosticPatchLabels.Add(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void RegisterProcessExceptionHandlers()
|
||||
{
|
||||
if (_processExceptionHandlersRegistered) return;
|
||||
_processExceptionHandlersRegistered = true;
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var ex = e.ExceptionObject as Exception;
|
||||
DiagnosticPatches.Write($"[process-exception] unhandled terminating={e.IsTerminating} {RoomLoadDiagnostics.FormatException(ex, e.ExceptionObject)}");
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write($"[process-exception] unobserved-task {RoomLoadDiagnostics.FormatException(e.Exception, e.Exception)}");
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Patch holders ─────────────────────────────────────────────────────
|
||||
@@ -1094,6 +1292,228 @@ public class Mod : MelonMod
|
||||
// Harmony invokes. Kept separate from Mod for readability and so the
|
||||
// reflection lookup in GetPatchMethod stays simple.
|
||||
|
||||
internal static class RoomLoadDiagnostics
|
||||
{
|
||||
private static readonly Dictionary<string, int> Counts = new();
|
||||
private static int _globalCount;
|
||||
|
||||
public static void LogError_Prefix(object? message)
|
||||
{
|
||||
DiagnosticPatches.Write($"[unity-error] LogError {Sanitize(message?.ToString() ?? "<null>", 1600)}");
|
||||
WriteStack("unity-error");
|
||||
}
|
||||
|
||||
public static void LogException_Prefix(Exception exception)
|
||||
{
|
||||
DiagnosticPatches.Write($"[unity-exception] {FormatException(exception, exception)}");
|
||||
WriteStack("unity-exception");
|
||||
}
|
||||
|
||||
public static void NamedCall_Prefix(MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("call:" + name, 80)) return;
|
||||
|
||||
DiagnosticPatches.Write($"[runtime-call] ENTER {name} [{Signature(__originalMethod)}]");
|
||||
if (IsImportantCall(name))
|
||||
WriteStack("runtime-call");
|
||||
}
|
||||
|
||||
public static Exception? NamedCall_Finalizer(Exception? __exception, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write($"[runtime-call] EXCEPTION {FullMethodName(__originalMethod)} {FormatException(__exception, __exception)}");
|
||||
WriteStack("runtime-call-exception");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void MoveNext_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("enter:" + name, 120)) return;
|
||||
DiagnosticPatches.Write($"[room-load] ENTER {name} state={DumpInstanceFields(__instance, 28)}");
|
||||
}
|
||||
|
||||
public static void MoveNext_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
var name = FullMethodName(__originalMethod);
|
||||
if (!ShouldLog("exit:" + name, 120)) return;
|
||||
DiagnosticPatches.Write($"[room-load] EXIT {name} state={DumpInstanceFields(__instance, 28)}");
|
||||
}
|
||||
|
||||
public static Exception? MoveNext_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[room-load] EXCEPTION {FullMethodName(__originalMethod)} " +
|
||||
$"{FormatException(__exception, __exception)} state={DumpInstanceFields(__instance, 40)}");
|
||||
WriteStack("room-load-exception");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static string FormatException(Exception? ex, object? fallback)
|
||||
{
|
||||
if (ex is null) return fallback?.ToString() ?? "<null>";
|
||||
var builder = new StringBuilder();
|
||||
AppendException(builder, ex, 0);
|
||||
return Sanitize(builder.ToString(), 5000);
|
||||
}
|
||||
|
||||
private static void AppendException(StringBuilder builder, Exception ex, int depth)
|
||||
{
|
||||
if (depth > 4) return;
|
||||
if (builder.Length > 0) builder.Append(" | ");
|
||||
builder.Append(ex.GetType().FullName).Append(": ").Append(ex.Message);
|
||||
if (!string.IsNullOrWhiteSpace(ex.StackTrace))
|
||||
builder.Append(" stack=").Append(ex.StackTrace);
|
||||
if (ex is AggregateException aggregate)
|
||||
{
|
||||
foreach (var inner in aggregate.InnerExceptions)
|
||||
AppendException(builder, inner, depth + 1);
|
||||
}
|
||||
else if (ex.InnerException is not null)
|
||||
{
|
||||
AppendException(builder, ex.InnerException, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldLog(string key, int perKeyLimit)
|
||||
{
|
||||
lock (Counts)
|
||||
{
|
||||
_globalCount++;
|
||||
if (_globalCount > 12000)
|
||||
return false;
|
||||
|
||||
Counts.TryGetValue(key, out var count);
|
||||
if (count >= perKeyLimit)
|
||||
return false;
|
||||
|
||||
Counts[key] = count + 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DumpInstanceFields(object? instance, int maxFields)
|
||||
{
|
||||
if (instance is null) return "<null>";
|
||||
try
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? value;
|
||||
try { value = field.GetValue(instance); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add($"{field.Name}={DescribeValue(value, 400)}");
|
||||
}
|
||||
|
||||
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<dump failed {ex.GetType().Name}: {Sanitize(ex.Message, 400)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeValue(object? value, int limit)
|
||||
{
|
||||
if (value is null) return "null";
|
||||
|
||||
var type = value.GetType();
|
||||
if (value is string text) return "\"" + Sanitize(text, limit) + "\"";
|
||||
if (value is Exception ex) return FormatException(ex, ex);
|
||||
if (type.IsPrimitive || type.IsEnum || value is decimal)
|
||||
return Sanitize(value.ToString() ?? "<null>", limit);
|
||||
if (value is IEnumerable enumerable && value is not string)
|
||||
return DescribeEnumerable(enumerable, type, limit);
|
||||
|
||||
var rendered = value.ToString();
|
||||
if (string.IsNullOrWhiteSpace(rendered) || rendered == type.FullName)
|
||||
return "<" + type.FullName + ">";
|
||||
return "<" + type.FullName + " " + Sanitize(rendered, limit) + ">";
|
||||
}
|
||||
|
||||
private static string DescribeEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var count = 0;
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (count++ >= 6)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
parts.Add(DescribeValue(item, 120));
|
||||
}
|
||||
return "<" + type.FullName + " [" + Sanitize(string.Join(", ", parts), limit) + "]>";
|
||||
}
|
||||
|
||||
private static string Signature(MethodBase method)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
var parts = new List<string>(parameters.Length);
|
||||
foreach (var parameter in parameters)
|
||||
parts.Add(parameter.ParameterType.FullName ?? parameter.ParameterType.Name);
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "<signature unread>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string FullMethodName(MethodBase method) =>
|
||||
$"{method.DeclaringType?.FullName ?? "<null>"}.{method.Name}";
|
||||
|
||||
private static bool IsImportantCall(string name) =>
|
||||
name.IndexOf("Disconnect", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("Join", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("Photon", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.IndexOf("CDNPLAGEDBK", StringComparison.Ordinal) >= 0 ||
|
||||
name.IndexOf("IJIOJAIBPKE", StringComparison.Ordinal) >= 0;
|
||||
|
||||
private static void WriteStack(string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write($"[{tag}-stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DiagnosticPatches.Write($"[{tag}-stack] failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string value, int limit)
|
||||
{
|
||||
var redacted = Regex.Replace(value, @"(?i)(Bearer\s+)[-._~+/=A-Za-z0-9]+", "$1<redacted>");
|
||||
redacted = Regex.Replace(redacted, @"(?i)(access_token|accessToken|refresh_token|refreshToken|authorization|token|key)=([^&\s]+)", "$1=<redacted>");
|
||||
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||
return redacted.Length <= limit ? redacted : redacted[..limit] + "...<truncated>";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Debug console access ───────────────────────────────────────────────
|
||||
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
|
||||
// dev console (text input + a DebugConsoleCommandConfig command table:
|
||||
@@ -1876,8 +2296,12 @@ internal static class HttpTracePatches
|
||||
private static readonly Regex BearerRegex = new(
|
||||
@"(?i)Bearer\s+[-._~+/=A-Za-z0-9]+",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex SensitiveHeaderRegex = new(
|
||||
@"(?im)^(Authorization|Cookie|Set-Cookie|X-RNSIG):\s*[^\r\n]+",
|
||||
RegexOptions.Compiled);
|
||||
private static int _requestId;
|
||||
private static int _failureLogCount;
|
||||
private static int _responseTupleLogCount;
|
||||
|
||||
public static void RecNetRequestSend_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
@@ -1892,6 +2316,49 @@ internal static class HttpTracePatches
|
||||
$"service={SanitizeInline(__1?.ToString() ?? "<null>")} path={SanitizeInline(__2?.ToString() ?? "<null>")}");
|
||||
}
|
||||
|
||||
public static void RecNetResponseTuple_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (System.Threading.Interlocked.Increment(ref _responseTupleLogCount) > 80) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
DumpObjectFields(__instance, 20, 600));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-response-map-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RecNetResponseTuple_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (_responseTupleLogCount > 80) return;
|
||||
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
DumpObjectFields(__instance, 20, 600));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("recnet-response-map-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static Exception? RecNetResponseTuple_Finalizer(Exception? __exception, object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
if (__exception is not null)
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[recnet-response-map] EXCEPTION {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{RoomLoadDiagnostics.FormatException(__exception, __exception)} state={DumpObjectFields(__instance, 36, 1200)}");
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
public static void HeaderSet_Prefix(object __instance, object? __0, object? __1, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
@@ -1909,6 +2376,34 @@ internal static class HttpTracePatches
|
||||
}
|
||||
}
|
||||
|
||||
public static void CallCallback_Prefix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] ENTER {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-enter", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CallCallback_Postfix(object __instance, MethodBase __originalMethod)
|
||||
{
|
||||
try
|
||||
{
|
||||
DiagnosticPatches.Write(
|
||||
$"[http-callback] EXIT {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name} " +
|
||||
$"{DescribeRequestAndResponse(__instance)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailure("callback-exit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static void TraceUrl(string? url, string source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return;
|
||||
@@ -2091,6 +2586,134 @@ internal static class HttpTracePatches
|
||||
return "<body unread>";
|
||||
}
|
||||
|
||||
private static string DescribeRequestAndResponse(object request)
|
||||
{
|
||||
var uri = TryReadRequestUri(request)?.ToString() ?? "<uri unread>";
|
||||
var method = TryReadHttpMethod(request);
|
||||
var response = TryReadResponseObject(request);
|
||||
var responseText = response is null ? "<response unread>" : DescribeResponseObject(response);
|
||||
var requestState = DescribeKnownMembers(request, "State", "Exception", "Error", "IsCancellationRequested");
|
||||
return $"method={method} uri={SanitizeInline(uri)} request={requestState} response={responseText}";
|
||||
}
|
||||
|
||||
private static object? TryReadResponseObject(object request)
|
||||
{
|
||||
foreach (var name in new[] { "Response", "response", "_response", "<Response>k__BackingField" })
|
||||
{
|
||||
if (TryReadMemberValue(request, name, out var value) && value is not null)
|
||||
return value;
|
||||
}
|
||||
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
foreach (var prop in request.GetType().GetProperties(flags))
|
||||
{
|
||||
if (prop.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
if (!prop.CanRead || prop.GetIndexParameters().Length != 0) continue;
|
||||
try
|
||||
{
|
||||
var value = prop.GetValue(request);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
foreach (var field in request.GetType().GetFields(flags))
|
||||
{
|
||||
if (field.Name.IndexOf("Response", StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
try
|
||||
{
|
||||
var value = field.GetValue(request);
|
||||
if (value is not null) return value;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string DescribeResponseObject(object response)
|
||||
{
|
||||
var known = DescribeKnownMembers(response,
|
||||
"StatusCode", "Message", "IsSuccess", "DataAsText", "Data", "Exception", "Error");
|
||||
return $"{response.GetType().FullName} {{{known}}}";
|
||||
}
|
||||
|
||||
private static string DescribeKnownMembers(object obj, params string[] names)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryReadMemberValue(obj, name, out var value)) continue;
|
||||
parts.Add($"{name}={FormatValue(value, 700)}");
|
||||
}
|
||||
return parts.Count == 0 ? "<no known members>" : string.Join("; ", parts);
|
||||
}
|
||||
|
||||
private static string FormatValue(object? value, int limit)
|
||||
{
|
||||
if (value is null) return "null";
|
||||
if (value is byte[] bytes)
|
||||
return $"byte[{bytes.Length}]";
|
||||
if (value is IEnumerable enumerable && value is not string)
|
||||
return FormatEnumerable(enumerable, value.GetType(), limit);
|
||||
var text = value is Exception ex
|
||||
? $"{ex.GetType().Name}: {ex.Message}"
|
||||
: value.ToString() ?? "<null>";
|
||||
text = SanitizeInline(text);
|
||||
return text.Length <= limit ? text : text[..limit] + "...<truncated>";
|
||||
}
|
||||
|
||||
private static string FormatEnumerable(IEnumerable enumerable, Type type, int limit)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var countText = TryReadEnumerableCount(enumerable, out var count) ? count.ToString() : "?";
|
||||
var shown = 0;
|
||||
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
if (shown++ >= 6)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
parts.Add(FormatValue(item, 180));
|
||||
}
|
||||
|
||||
var rendered = $"{type.FullName} count={countText} [{string.Join(", ", parts)}]";
|
||||
return rendered.Length <= limit ? rendered : rendered[..limit] + "...<truncated>";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"{type.FullName} <enumeration failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadEnumerableCount(IEnumerable enumerable, out int count)
|
||||
{
|
||||
count = 0;
|
||||
if (enumerable is ICollection collection)
|
||||
{
|
||||
count = collection.Count;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var prop = enumerable.GetType().GetProperty("Count", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (prop?.GetValue(enumerable) is int value)
|
||||
{
|
||||
count = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static byte[]? TryCopyBytes(object? value, int limit)
|
||||
{
|
||||
if (value is null) return null;
|
||||
@@ -2163,6 +2786,43 @@ internal static class HttpTracePatches
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string DumpObjectFields(object? instance, int maxFields, int valueLimit)
|
||||
{
|
||||
if (instance is null) return "<null>";
|
||||
|
||||
try
|
||||
{
|
||||
var type = instance.GetType();
|
||||
var parts = new List<string>();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (parts.Count >= maxFields)
|
||||
{
|
||||
parts.Add("...");
|
||||
break;
|
||||
}
|
||||
|
||||
object? value;
|
||||
try { value = field.GetValue(instance); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
parts.Add($"{field.Name}=<read failed {ex.GetType().Name}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
parts.Add($"{field.Name}={FormatValue(value, valueLimit)}");
|
||||
}
|
||||
|
||||
return $"{type.FullName} {{{string.Join("; ", parts)}}}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"<field dump failed {ex.GetType().Name}: {SanitizeInline(ex.Message)}>";
|
||||
}
|
||||
}
|
||||
|
||||
private static string RedactHeaderValue(string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "<empty>";
|
||||
@@ -2181,6 +2841,7 @@ internal static class HttpTracePatches
|
||||
{
|
||||
var redacted = SensitivePairRegex.Replace(value, "$1=<redacted>");
|
||||
redacted = BearerRegex.Replace(redacted, "Bearer <redacted>");
|
||||
redacted = SensitiveHeaderRegex.Replace(redacted, "$1: <redacted>");
|
||||
redacted = redacted.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
|
||||
return redacted.Length <= 1200 ? redacted : redacted[..1200] + "...<truncated>";
|
||||
}
|
||||
|
||||
@@ -151,9 +151,9 @@ public class PlayerEventsController(
|
||||
[AllowAnonymous]
|
||||
[HttpPost("api/playerevents/v1/bulk")]
|
||||
[HttpGet("api/playerevents/v1/bulk")]
|
||||
public async Task<ActionResult> Bulk([FromBody] EventBulkRequest? body)
|
||||
public async Task<ActionResult> Bulk()
|
||||
{
|
||||
var ids = await ReadEventIdsAsync(body);
|
||||
var ids = await ReadEventIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
@@ -614,29 +614,73 @@ public class PlayerEventsController(
|
||||
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
|
||||
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
|
||||
|
||||
private async Task<List<long>> ReadEventIdsAsync(EventBulkRequest? body)
|
||||
private async Task<List<long>> ReadEventIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
if (body?.PlayerEventIds is { Count: > 0 }) ids.AddRange(body.PlayerEventIds);
|
||||
if (body?.EventIds is { Count: > 0 }) ids.AddRange(body.EventIds);
|
||||
if (body?.Ids is { Count: > 0 }) ids.AddRange(body.Ids);
|
||||
|
||||
foreach (var value in Request.Query.SelectMany(q => q.Value))
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
AddDelimitedIds(ids, value);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
|
||||
foreach (var value in form[key])
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
AddDelimitedIds(ids, value);
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty(key, out var prop))
|
||||
AddJsonIds(ids, prop);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddJsonIds(ids, doc.RootElement);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private static void AddDelimitedIds(List<long> ids, string? value)
|
||||
{
|
||||
foreach (var part in (value ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddJsonIds(List<long> ids, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
AddJsonIds(ids, item);
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
if (element.TryGetInt64(out var id) && id > 0) ids.Add(id);
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
AddDelimitedIds(ids, element.GetString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ReadStringFieldAsync(params string[] names)
|
||||
{
|
||||
var fields = await ReadRequestFieldsAsync();
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
|
||||
[AllowAnonymous]
|
||||
@@ -215,7 +215,15 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
GameSessionId = gameSessionId ?? 0,
|
||||
Category = "hile",
|
||||
});
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
logger.LogWarning(ex, "[hile] dropping low-priority hile heartbeat because sqlite is busy");
|
||||
}
|
||||
return HileResult();
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,38 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ
|
||||
[AllowAnonymous]
|
||||
public IActionResult Active() => Ok(CurrentProgressionEventId());
|
||||
|
||||
/// <summary>
|
||||
/// The current player's progress record for a given progression event.
|
||||
/// The 2023 client fetches this during InitialRoomLoad
|
||||
/// (<c>api/progressionEvents/record/{id}</c>, where <c>{id}</c> is the
|
||||
/// <c>yyyyMMdd</c> event id). A 404 here faults the room-load coroutine
|
||||
/// with a NullReferenceException and traps the client in a dorm
|
||||
/// matchmaking loop, so we always return a non-null record.
|
||||
/// </summary>
|
||||
[HttpGet("api/progressionEvents/record/{progressionEventId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Record(long progressionEventId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
|
||||
// Count this player's completed progression-event objectives so the
|
||||
// record reflects real progress rather than a flat zero.
|
||||
var completed = await db.ObjectiveProgress
|
||||
.CountAsync(o => o.PlayerId == me
|
||||
&& o.IsCompleted
|
||||
&& o.Key.StartsWith("progressionEvent:"));
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ProgressionEventId = progressionEventId,
|
||||
Xp = 0,
|
||||
ClaimedRewardIndex = completed > 0 ? completed - 1 : -1,
|
||||
PurchasedXpBoostCount = 0,
|
||||
DailyBoostGameplayMinutes = 0,
|
||||
XpBoostExpiresAt = (DateTime?)null,
|
||||
});
|
||||
}
|
||||
|
||||
private static long CurrentProgressionEventId()
|
||||
{
|
||||
var (start, _) = CurrentWeekWindow();
|
||||
|
||||
@@ -1184,32 +1184,16 @@ public class RoomsController(
|
||||
// no DeserializeRoomDataBlobCoroutine, the master init
|
||||
// proceeds with default-zero permissions.
|
||||
//
|
||||
// • Saved dorm / customisable rooms: DataBlobName is the
|
||||
// latest uploaded blob name (RoomEntity.CurrentDataBlobName,
|
||||
// written by StorageController on /upload). Unsaved dorms
|
||||
// deliberately use "" so the baked DormRoom scene loads
|
||||
// without deserializing the synthetic all-perms blob. The
|
||||
// role-only default blob made December wait forever on
|
||||
// StandaloneShapeContainer big-data during first-dorm entry.
|
||||
// • AG-Original rooms: DataBlobName="" → completed-promise
|
||||
// short-circuit → no download → no leaked stale-blob
|
||||
// behaviour → master flow proceeds → spawn fires. Maker
|
||||
// Pen stays locked, which matches public-server semantics
|
||||
// (you can't edit the rec center).
|
||||
//
|
||||
// Heuristic: "customisable" iff it's the dorm OR a user-created
|
||||
// room (CreatorPlayerId != 1, the seeded system account that
|
||||
// owns AG-Originals like the rec center). User clones inherit
|
||||
// the all-perms default blob until their first save replaces
|
||||
// it via StorageController; the rec center / paintball / etc.
|
||||
// serve "" so the persistence flow short-circuits.
|
||||
// If a room has a saved blob, use it. Otherwise point at
|
||||
// room_<id>_v1.dat, which misses S3 and is served by
|
||||
// RoomDataBlobService.GetDefaultBlob. That default is a captured
|
||||
// PersistedRoomData (v38); the older synthetic role-only blob and
|
||||
// empty DataBlobName path both leave the 2023 room-permissions
|
||||
// runtime without data it dereferences during spawn.
|
||||
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
||||
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
|
||||
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
|
||||
? room.CurrentDataBlobName
|
||||
: room.IsDormRoom ? ""
|
||||
: customisable ? $"room_{room.Id}_v1.dat" : "";
|
||||
: $"room_{room.Id}_v1.dat";
|
||||
|
||||
// Multi-scene rooms (imported via tools/import-room.ps1) carry one
|
||||
// RoomSceneEntity row per sub-room. Emit the full Scenes[] array
|
||||
@@ -1231,8 +1215,9 @@ public class RoomsController(
|
||||
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
|
||||
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
|
||||
? overrideDataBlobName
|
||||
: room.IsDormRoom ? dataBlobName
|
||||
: s.DataBlobName,
|
||||
: !string.IsNullOrWhiteSpace(s.DataBlobName)
|
||||
? s.DataBlobName
|
||||
: dataBlobName,
|
||||
MaxPlayers = s.MaxPlayers,
|
||||
CanMatchmakeInto = s.CanMatchmakeInto,
|
||||
DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||
@@ -1322,12 +1307,12 @@ public class RoomsController(
|
||||
IReadOnlyList<RoomRoleEntity>? roles = null)
|
||||
{
|
||||
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
||||
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
|
||||
// 2023's room-permissions runtime needs a real PersistedRoomData
|
||||
// source during spawn. If no saved blob exists, use the captured
|
||||
// default via the synthetic room_<id>_v1.dat CDN path.
|
||||
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
|
||||
? room.CurrentDataBlobName
|
||||
: room.IsDormRoom ? ""
|
||||
: customisable ? $"room_{room.Id}_v1.dat" : "";
|
||||
: $"room_{room.Id}_v1.dat";
|
||||
var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
|
||||
@@ -1339,8 +1324,9 @@ public class RoomsController(
|
||||
Name = s.Name,
|
||||
DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
|
||||
? overrideDataBlobName
|
||||
: room.IsDormRoom ? dataBlobName
|
||||
: s.DataBlobName,
|
||||
: !string.IsNullOrWhiteSpace(s.DataBlobName)
|
||||
? s.DataBlobName
|
||||
: dataBlobName,
|
||||
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
IsSandbox = s.IsSandbox,
|
||||
@@ -1364,27 +1350,11 @@ public class RoomsController(
|
||||
},
|
||||
};
|
||||
|
||||
static int WireRole(int role) => role switch
|
||||
{
|
||||
0 => 30, // CoOwner
|
||||
1 => 20, // Moderator
|
||||
2 => 10, // Host
|
||||
_ => 0,
|
||||
};
|
||||
var wireRoles = new List<object>
|
||||
{
|
||||
new { AccountId = room.CreatorPlayerId, Role = 255, InvitedRole = 0 },
|
||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 255),
|
||||
};
|
||||
wireRoles.AddRange(roleList.Select(r =>
|
||||
{
|
||||
var role = WireRole(r.Role);
|
||||
return (object)new
|
||||
{
|
||||
AccountId = r.PlayerId,
|
||||
Role = r.Accepted ? role : 0,
|
||||
InvitedRole = r.Accepted ? 0 : role,
|
||||
};
|
||||
}));
|
||||
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
|
||||
|
||||
var tags = string.IsNullOrEmpty(room.TagsCsv)
|
||||
? Array.Empty<object>()
|
||||
@@ -2710,9 +2680,8 @@ public class RoomsController(
|
||||
|
||||
// ── Bare-path roles ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>rooms/{id}/roles</c> — accepted role grants on
|
||||
/// the room. Owner is implicit (CreatorPlayerId) and surfaces under
|
||||
/// Role=30; co-owner/mod/host rows come from RoomRoles.</summary>
|
||||
/// <summary>GET <c>rooms/{id}/roles</c> — account role DTOs consumed by
|
||||
/// 2023's EFHPLDPNGIM deserializer.</summary>
|
||||
[HttpGet("rooms/{roomId:long}/roles")]
|
||||
public async Task<IActionResult> RolesList(long roomId)
|
||||
{
|
||||
@@ -2721,15 +2690,9 @@ public class RoomsController(
|
||||
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||
var list = new List<object>(rows.Count + 1)
|
||||
{
|
||||
new { RoomId = roomId, PlayerId = (int)room.CreatorPlayerId, Role = 30 /*Owner*/, Accepted = true },
|
||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 255),
|
||||
};
|
||||
list.AddRange(rows.Select(r => (object)new
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = (int)r.PlayerId,
|
||||
Role = r.Role,
|
||||
Accepted = r.Accepted,
|
||||
}));
|
||||
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
@@ -2739,11 +2702,11 @@ public class RoomsController(
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId == playerId)
|
||||
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = 30, Accepted = true });
|
||||
return Ok(BuildRoomAccountRoleWire(playerId, 255));
|
||||
var row = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
|
||||
if (row is null) return NotFound();
|
||||
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = row.Role, Accepted = row.Accepted });
|
||||
return Ok(BuildRoomRoleGrantWire(row));
|
||||
}
|
||||
|
||||
public sealed class GrantRoleRequest { public int? Role { get; set; } }
|
||||
@@ -2911,6 +2874,44 @@ public class RoomsController(
|
||||
return roomRole >= 0;
|
||||
}
|
||||
|
||||
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
|
||||
=> BuildRoomAccountRoleWire(
|
||||
role.PlayerId,
|
||||
ToClientRoomRole(role.Role),
|
||||
role.Accepted,
|
||||
role.GrantedByPlayerId);
|
||||
|
||||
private static object BuildRoomAccountRoleWire(
|
||||
long accountId,
|
||||
int role,
|
||||
bool accepted = true,
|
||||
long? lastChangedByAccountId = null)
|
||||
=> new
|
||||
{
|
||||
AccountId = ToClientAccountId(accountId),
|
||||
Role = accepted ? role : 0,
|
||||
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
|
||||
InvitedRole = accepted ? 0 : role,
|
||||
};
|
||||
|
||||
private static int ToClientRoomRole(int roomRole) => roomRole switch
|
||||
{
|
||||
0 => 30, // CoOwner
|
||||
1 => 20, // Moderator
|
||||
2 => 10, // Host
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static int ToClientAccountId(long accountId)
|
||||
=> accountId > int.MaxValue
|
||||
? int.MaxValue
|
||||
: accountId < int.MinValue
|
||||
? int.MinValue
|
||||
: (int)accountId;
|
||||
|
||||
private static int? ToClientAccountId(long? accountId)
|
||||
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
|
||||
|
||||
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstAsync(r => r.Id == roomId);
|
||||
|
||||
@@ -100,15 +100,132 @@ public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var ids = ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds");
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
// The 2023 client posts {"RoomId":N,"Ids":[{"itemType":1,
|
||||
// "itemId":"<guid>"}]} — Ids is an array of OBJECTS, not flat guid
|
||||
// strings. The generic field-flattener collapses each object to its
|
||||
// JSON text, so the old ReadGuidList parsed ZERO guids and this
|
||||
// endpoint always returned [] — the client's UGC resolver then
|
||||
// re-queried the same ids forever (the growing-list flood in the
|
||||
// server log), the referenced items never resolved, and the online
|
||||
// player/avatar spawn null-reffed on an unresolved custom item
|
||||
// (reportjoinresult FailedToSpawnPlayer → infinite dorm bounce).
|
||||
var requested = await ReadBulkIdsAsync();
|
||||
if (requested.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var ids = requested.Select(r => r.Id).ToList();
|
||||
var rows = await db.UgcPurchasables
|
||||
.Where(i => !i.IsDeleted && ids.Contains(i.PublicId))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
var byId = rows.ToDictionary(r => r.PublicId);
|
||||
|
||||
// Return an entry for EVERY requested id, in request order. Items not
|
||||
// in our DB (custom UGC the player owns from real Rec Room that we
|
||||
// never ingested) get a synthetic placeholder so the client treats
|
||||
// the reference as resolved instead of spinning.
|
||||
var result = requested
|
||||
.Select(r => byId.TryGetValue(r.Id, out var row) ? ToWire(row) : SyntheticWire(r.Id, r.ItemType))
|
||||
.ToList();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Parse the bulk request's item ids. Handles the 2023 client's
|
||||
/// JSON object-array shape (<c>Ids:[{itemType,itemId}]</c>) as well as
|
||||
/// flat guid lists in the body/form/query for older callers.</summary>
|
||||
private async Task<List<(Guid Id, int ItemType)>> ReadBulkIdsAsync()
|
||||
{
|
||||
var list = new List<(Guid, int)>();
|
||||
var seen = new HashSet<Guid>();
|
||||
void Add(Guid id, int itemType)
|
||||
{
|
||||
if (id != Guid.Empty && seen.Add(id)) list.Add((id, itemType));
|
||||
}
|
||||
|
||||
if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& (TryGetPropertyCI(root, "Ids", out var idsEl)
|
||||
|| TryGetPropertyCI(root, "itemIds", out idsEl)
|
||||
|| TryGetPropertyCI(root, "ugcPurchasableIds", out idsEl))
|
||||
&& idsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in idsEl.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var flat))
|
||||
{
|
||||
Add(flat, 0);
|
||||
}
|
||||
else if (el.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var itemType = TryGetPropertyCI(el, "itemType", out var it) && it.TryGetInt32(out var n) ? n : 0;
|
||||
if ((TryGetPropertyCI(el, "itemId", out var idEl)
|
||||
|| TryGetPropertyCI(el, "id", out idEl)
|
||||
|| TryGetPropertyCI(el, "ugcPurchasableId", out idEl))
|
||||
&& Guid.TryParse(idEl.GetString(), out var guid))
|
||||
{
|
||||
Add(guid, itemType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: flat guid lists in form/query (older callers).
|
||||
if (list.Count == 0)
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
foreach (var id in ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds"))
|
||||
Add(id, 0);
|
||||
}
|
||||
|
||||
return list.Take(200).ToList();
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
|
||||
{
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Minimal placeholder for a requested ugc item we don't have a
|
||||
/// row for. Same wire shape as <see cref="ToWire"/> so the client's
|
||||
/// deserializer resolves the reference; zero price / empty metadata.</summary>
|
||||
private static object SyntheticWire(Guid id, int itemType) => new
|
||||
{
|
||||
UgcPurchasableId = id,
|
||||
PurchasableItemId = id,
|
||||
Id = id,
|
||||
InternalId = 0L,
|
||||
RoomId = 0L,
|
||||
CreatorPlayerId = 0,
|
||||
Name = string.Empty,
|
||||
Description = string.Empty,
|
||||
ImageName = string.Empty,
|
||||
Price = 0,
|
||||
CurrencyType = 2,
|
||||
ItemType = itemType,
|
||||
IsFeatured = false,
|
||||
SortOrder = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private async Task<UgcPurchasableEntity?> FindItemAsync(Dictionary<string, string> fields)
|
||||
{
|
||||
var publicId = ReadGuid(fields, "ugcPurchasableId", "UgcPurchasableId", "itemId", "ItemId", "id", "Id");
|
||||
|
||||
@@ -215,19 +215,7 @@ public class GoToController(
|
||||
// (roomInstanceId differs → reset state machine) plus "new
|
||||
// photon match" (photonRoomId differs → leave/rejoin →
|
||||
// SceneLoaded events that ultimately dismiss the dialog).
|
||||
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
|
||||
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
|
||||
resp.RoomInstance.Name = subRoomName;
|
||||
resp.RoomInstance.SubRoomId = subId;
|
||||
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
|
||||
// dataBlob on the matchmaking response tells the client which
|
||||
// PersistedRoomData blob to download for the entered scene.
|
||||
// RoomInstance.Deserialize at RVA 0x114DAB0 reads it via the
|
||||
// string-with-default reader (call 0x180EE6210), so empty is
|
||||
// technically legal — but if it's empty the watch falls back
|
||||
// to the parent room's RoomDataBlobName, which is the lobby's
|
||||
// blob, so the sub-scene visually never changes.
|
||||
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
|
||||
ApplySubRoomMutation(resp, subId, subRoomName, subLocation, subDataBlob);
|
||||
|
||||
// Private/new-instance mutation goes AFTER sub-room mutation so the
|
||||
// PhotonRoomId we register includes the _sub{N} suffix as its
|
||||
@@ -240,6 +228,135 @@ public class GoToController(
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
/// <summary>Mutate <paramref name="resp"/> in place so it represents
|
||||
/// the given sub-scene of its parent room. Both PhotonRoomId AND
|
||||
/// RoomInstanceId must differ from the lobby's or the watch's
|
||||
/// transition state machine never fires:
|
||||
/// • Same photon + same roomInstanceId → the new dataBlob downloads
|
||||
/// but the watch never transitions; "Going to X" hangs forever.
|
||||
/// • Different photon + same roomInstanceId → the watch sees the id
|
||||
/// unchanged, treats the response as a no-op presence update and
|
||||
/// skips the join flow entirely.
|
||||
/// Folding subId into RoomInstanceId AND forking PhotonRoomId gives
|
||||
/// the watch both signals it needs ("new instance" + "new photon
|
||||
/// match"). dataBlob tells the client which PersistedRoomData to
|
||||
/// download for the scene (RoomInstance.Deserialize, RVA 0x114DAB0,
|
||||
/// reads it with a string-with-default reader so empty is legal) —
|
||||
/// but an empty blob makes the watch fall back to the parent room's
|
||||
/// lobby blob, so the sub-scene never changes visually. Only override
|
||||
/// location/blob when we actually resolved one.</summary>
|
||||
private static void ApplySubRoomMutation(
|
||||
MatchmakingResponseDto resp, long subId, string subRoomName,
|
||||
string? subLocation, string? subDataBlob)
|
||||
{
|
||||
resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId;
|
||||
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}";
|
||||
resp.RoomInstance.Name = subRoomName;
|
||||
resp.RoomInstance.SubRoomId = subId;
|
||||
if (subLocation is not null) resp.RoomInstance.Location = subLocation;
|
||||
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
|
||||
}
|
||||
|
||||
/// <summary>POST <c>match.*/matchmake/room/{roomId}</c> — the 2023
|
||||
/// client's primary "join this public room" call
|
||||
/// (<c>RecNet.Matchmaking</c>, format string <c>"matchmake/room/{0}"</c>,
|
||||
/// same sender <c>HHJFFJEHLOB</c> as the working <c>matchmake/dorm</c>,
|
||||
/// so it's a POST returning <see cref="MatchmakingResponseDto"/>).
|
||||
/// It navigates by NUMERIC roomId straight from the room tile, NOT by
|
||||
/// name like the older <c>/goto/room/{name}</c> path. With no route
|
||||
/// here every public-room join 404s and the watch bounces the player
|
||||
/// back to their dorm ("Matchmaking request failed
|
||||
/// (matchmake/room/N): HTTP Error 404"). We resolve the id to its
|
||||
/// room and reuse <see cref="BuildResponseAsync"/> so dorm/private/
|
||||
/// dataBlob/JoinMode handling stays identical to <see cref="GoToRoom"/>.
|
||||
/// GET is accepted too as cheap insurance against a verb mismatch.</summary>
|
||||
[HttpPost("/matchmake/room/{roomId:long}")]
|
||||
[HttpGet("/matchmake/room/{roomId:long}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> MatchmakeRoom(
|
||||
long roomId,
|
||||
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
|
||||
[FromForm(Name = "JoinMode")] int joinMode = 0,
|
||||
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
|
||||
{
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null)
|
||||
{
|
||||
logger.LogWarning("[matchmake-room] unknown roomId={RoomId} — returning RoomDoesNotExist", roomId);
|
||||
// 4 = RoomDoesNotExist (CreateModifyRoomStatus); watch shows
|
||||
// its standard "room not found" toast instead of softlocking.
|
||||
return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null });
|
||||
}
|
||||
|
||||
var resp = await BuildResponseAsync(room.Name);
|
||||
await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance, joinMode);
|
||||
await RecordResponseAsync(resp);
|
||||
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
|
||||
logger.LogInformation(
|
||||
"[matchmake-room] player={Player} roomId={RoomId} name={Name} instance={Instance} photon={Photon}",
|
||||
this.CurrentPlayerId(), roomId, room.Name,
|
||||
resp.RoomInstance?.RoomInstanceId, resp.RoomInstance?.PhotonRoomId);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>match.*/matchmake/room/{roomId}/{subRoomId}</c> —
|
||||
/// sub-room form (<c>RecNet.Matchmaking</c> "matchmake/room/{0}/{1}").
|
||||
/// Unlike <see cref="GoToSubRoom"/> (which takes a sub-room NAME and
|
||||
/// fuzzy-matches it) the sub-room arrives here as a numeric
|
||||
/// <see cref="RoomSceneEntity.OrderIndex"/>, so we resolve the scene
|
||||
/// row by OrderIndex directly. subRoomId 0 is the lobby/entry scene —
|
||||
/// the base response already represents it, so we skip the
|
||||
/// mutation.</summary>
|
||||
[HttpPost("/matchmake/room/{roomId:long}/{subRoomId:long}")]
|
||||
[HttpGet("/matchmake/room/{roomId:long}/{subRoomId:long}")]
|
||||
public async Task<ActionResult<MatchmakingResponseDto>> MatchmakeSubRoom(
|
||||
long roomId, long subRoomId,
|
||||
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
|
||||
[FromForm(Name = "JoinMode")] int joinMode = 0,
|
||||
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
|
||||
{
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[matchmake-room] unknown roomId={RoomId} (sub={Sub}) — returning RoomDoesNotExist",
|
||||
roomId, subRoomId);
|
||||
return Ok(new MatchmakingResponseDto { ErrorCode = 4, RoomInstance = null });
|
||||
}
|
||||
|
||||
var resp = await BuildResponseAsync(room.Name);
|
||||
|
||||
if (subRoomId != 0)
|
||||
{
|
||||
var scene = await db.RoomScenes.AsNoTracking()
|
||||
.Where(s => s.RoomId == resp.RoomInstance.RoomId && s.OrderIndex == subRoomId)
|
||||
.Select(s => new { s.Name, s.RoomSceneLocationId, s.DataBlobName })
|
||||
.FirstOrDefaultAsync();
|
||||
if (scene is not null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[matchmake-subroom] {Room}/{Sub} → scene '{Name}' loc={Loc} blob={Blob}",
|
||||
roomId, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName);
|
||||
ApplySubRoomMutation(resp, subRoomId, scene.Name, scene.RoomSceneLocationId, scene.DataBlobName);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[matchmake-subroom] {Room}/{Sub} — no RoomScenes row with that OrderIndex; " +
|
||||
"forking ids onto the lobby scene so the watch still transitions",
|
||||
roomId, subRoomId);
|
||||
// Reuse the lobby location/blob (sub-scene won't differ
|
||||
// visually) but still fold subId in so the watch leaves
|
||||
// the current match instead of softlocking.
|
||||
ApplySubRoomMutation(resp, subRoomId, resp.RoomInstance.Name, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
await ApplyNewInstanceAsync(resp, subRoomId: subRoomId, createPrivateInstance, joinMode);
|
||||
await RecordResponseAsync(resp);
|
||||
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
/// <summary>Per-sub-room scene location overrides for baked
|
||||
/// AG-Original rooms with multiple scenes. Each entry maps a
|
||||
/// <c>"{room}/{subRoom}"</c> key (lowercase) to the
|
||||
@@ -1172,10 +1289,7 @@ public class GoToController(
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
|
||||
|
||||
if (room.IsDormRoom) return string.Empty;
|
||||
|
||||
var customisable = room.CreatorPlayerId != 1;
|
||||
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
|
||||
return $"room_{room.Id}_v1.dat";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -434,10 +434,7 @@ public class MatchPlayerController(
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
|
||||
|
||||
if (room.IsDormRoom) return string.Empty;
|
||||
|
||||
var customisable = room.CreatorPlayerId != 1;
|
||||
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
|
||||
return $"room_{room.Id}_v1.dat";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace DorkNet.Server.Controllers.PlayerSettings;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
|
||||
public class PlayerSettingsController(DorkNetDbContext db, ILogger<PlayerSettingsController> logger) : ControllerBase
|
||||
{
|
||||
private long CurrentPlayerId => this.RequireCurrentPlayerId();
|
||||
|
||||
@@ -64,7 +64,7 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
|
||||
foreach (var (key, value) in updates)
|
||||
await UpsertSettingAsync(accountId, key, value, save: false);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await SaveSettingsChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
|
||||
if (setting is null) return NotFound();
|
||||
|
||||
db.PlayerSettings.Remove(setting);
|
||||
await db.SaveChangesAsync();
|
||||
await SaveSettingsChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -91,7 +91,20 @@ public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase
|
||||
else
|
||||
existing.Value = value;
|
||||
|
||||
if (save) await db.SaveChangesAsync();
|
||||
if (save) await SaveSettingsChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SaveSettingsChangesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
logger.LogWarning(ex, "[playersettings] dropping low-priority setting write because sqlite is busy");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string? Key, string? Value)> ReadSettingKeyValueAsync()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Data;
|
||||
|
||||
internal static class SqliteErrors
|
||||
{
|
||||
public static bool IsBusy(DbUpdateException ex)
|
||||
{
|
||||
for (Exception? current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is SqliteException sqlite &&
|
||||
(sqlite.SqliteErrorCode == 5 || sqlite.SqliteErrorCode == 6))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -377,6 +377,10 @@ These all live on `match.rec.net` (locally: `match.localhost`).
|
||||
| POST | `goto/player/{playerId}` | REAL | `Match.GoToController.GoToPlayer` — uses `PlayerPresenceService` to find where the target is and routes the caller there |
|
||||
| POST | `goto/instance/{instanceId}` | REAL | `Match.GoToController.GoToInstance` — direct join to a specific room instance |
|
||||
| POST | `goto/invite/{inviteId}` | REAL | `Match.GoToController.GoToInvite` — resolves an invite to its target room/instance |
|
||||
| POST/GET | `matchmake/room/{roomId}` | REAL | `Match.GoToController.MatchmakeRoom` — the 2023 client's "join this public room" call (navigates by numeric id, not name). Reuses `BuildResponseAsync`; without it every public-room join 404s and the player bounces to dorm |
|
||||
| POST/GET | `matchmake/room/{roomId}/{subRoomId}` | REAL | `Match.GoToController.MatchmakeSubRoom` — sub-room form; resolves the `RoomScenes` row by `OrderIndex` |
|
||||
| POST | `matchmake/dorm` | REAL | `Match.MatchController.MatchmakeDorm` — builds the caller's personal-dorm `RoomInstance` |
|
||||
| POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it |
|
||||
| POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case |
|
||||
|
||||
## Catch-all wildcards (informational)
|
||||
|
||||
@@ -150,6 +150,13 @@
|
||||
<Link>data\%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<!-- Real captured PersistedRoomData served as the default room blob
|
||||
(RoomDataBlobService.GetDefaultBlob). The hand-built synthetic blob
|
||||
is too thin for the 2023 client's room-load apply path. -->
|
||||
<None Include="Data\*.room">
|
||||
<Link>data\%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="Data\images\*.png" Exclude="Data\images\image_*.png">
|
||||
<Link>data\images\%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
|
||||
@@ -37,8 +37,8 @@ message OverridableListStringData {
|
||||
|
||||
// ── Per-role permission grants (rec_room/player_roles/player_room_role_data.proto)
|
||||
//
|
||||
// Field numbers verified against the dump's `*FieldNumber` constants
|
||||
// (TypeDefIndex 15953-ish in the 2020 build) and against the 2026 .proto.
|
||||
// Field numbers verified against the 2023 WriteTo disassembly in
|
||||
// C:\tmp\recroom-2023-03-21-isil\IsilDump\RecRoom.Protobuf.Runtime.
|
||||
|
||||
message PlayerRoomRoleData {
|
||||
reserved 15;
|
||||
@@ -70,14 +70,17 @@ message PlayerRoomRoleData {
|
||||
OverridableBoolData disable_mic_auto_mute = 25;
|
||||
OverridableBoolData can_use_share_cam = 26;
|
||||
string role_guid = 27;
|
||||
OverridableBoolData unknown_permission_20 = 28;
|
||||
OverridableBoolData unknown_permission_21 = 29;
|
||||
OverridableBoolData unknown_permission_22 = 30;
|
||||
OverridableBoolData unknown_permission_23 = 31;
|
||||
}
|
||||
|
||||
// ── Role collection (rec_room/player_roles/room_role_collection_data.proto)
|
||||
|
||||
message RoomRoleCollectionData {
|
||||
repeated PlayerRoomRoleData room_roles = 1;
|
||||
// field 2 (rec_net_migration_version) intentionally omitted — its enum
|
||||
// type doesn't exist in the 2020 build, and proto3 default 0 is fine.
|
||||
int32 rec_net_migration_version = 2;
|
||||
}
|
||||
|
||||
// ── PersistedRoomData top-level (rec_room/persistence/persisted_room_data.proto)
|
||||
|
||||
@@ -17,14 +17,10 @@ public sealed class ImageSignatureService
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_IMAGE_SIGNING_KEY_ID")
|
||||
?? "p1");
|
||||
|
||||
// `.rec.net` in the client's `KEY:RSA:p1.rec.net` literal gets byte-rewritten
|
||||
// by patch-client.ps1 to match the server domain (e.g. `.localhost`). The
|
||||
// header suffix we emit has to match that, or the client errors as
|
||||
// "Signature malformed". By default we derive the suffix from the request
|
||||
// Host, which works for the standard same-domain patcher setup. Override
|
||||
// with ImageSigning:KeyIdHostSuffix if your client metadata was patched to
|
||||
// a different value than the server's host (e.g. running the server at
|
||||
// `dorknet.example` while the client metadata still says `localhost`).
|
||||
// 2023 compares Content-Signature's key-id against the embedded
|
||||
// KEY:RSA:p1.rec.net / d1.rec.net literals. If a specific client build
|
||||
// is bytepatched to another suffix, ImageSigning:KeyIdHostSuffix can
|
||||
// override this default.
|
||||
configuredKeyIdHostSuffix =
|
||||
config["ImageSigning:KeyIdHostSuffix"]
|
||||
?? Environment.GetEnvironmentVariable("DORKNET_IMAGE_SIGNING_KEY_ID_SUFFIX");
|
||||
@@ -272,19 +268,12 @@ public sealed class ImageSignatureService
|
||||
|
||||
var suffix = configuredKeyIdHostSuffix is { Length: > 0 }
|
||||
? configuredKeyIdHostSuffix
|
||||
: ExtractParentDomain(response.HttpContext.Request.Host.Host);
|
||||
: "rec.net";
|
||||
|
||||
response.Headers["Content-Signature"] =
|
||||
$"key-id=KEY:RSA:{headerKeyId}.{suffix}; data={Convert.ToBase64String(signature)}";
|
||||
}
|
||||
|
||||
private static string ExtractParentDomain(string host)
|
||||
{
|
||||
if (string.IsNullOrEmpty(host)) return "rec.net";
|
||||
var dot = host.IndexOf('.');
|
||||
return dot > 0 && dot < host.Length - 1 ? host[(dot + 1)..] : host;
|
||||
}
|
||||
|
||||
private static byte[] PlaceholderSignature(byte[] bytes)
|
||||
{
|
||||
var signature = new byte[128];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.IO;
|
||||
using Google.Protobuf;
|
||||
using RecRoom.Protobuf;
|
||||
|
||||
@@ -5,40 +6,115 @@ namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the binary `PersistedRoomData` protobuf the client downloads when
|
||||
/// it loads a room scene. The 2020 RecRoom client gates the Maker Pen,
|
||||
/// invention spawning, room-role editing and a dozen other tools behind
|
||||
/// `RoomRoleCollectionData.RoomRoles[i].CanUseMakerPen.InnerValue` etc.,
|
||||
/// all of which default to `false` if the bytes we serve are empty.
|
||||
///
|
||||
/// We emit a minimal blob with one PlayerRoomRoleData entry that flips
|
||||
/// every relevant `OverridableBoolData` to `(overrides=true, inner_value=true)`,
|
||||
/// which the client picks up regardless of which RoomRoleId the local
|
||||
/// player ends up assigned to. Net result: the room's creator (and
|
||||
/// everyone else, since this is a private server) gets all build/admin
|
||||
/// tools the moment they spawn into the scene.
|
||||
///
|
||||
/// Wire compatibility: our trimmed .proto only defines the messages and
|
||||
/// field numbers we set — the 2020 client's full PersistedRoomData has
|
||||
/// many more fields, but proto3 silently ignores unknown wire entries and
|
||||
/// defaults missing ones, so the bytes we generate deserialize cleanly.
|
||||
/// it loads a room scene. When a real room blob is missing, serve captured
|
||||
/// root room data with saved objects stripped and a 2023-compatible role
|
||||
/// collection instead of fabricating saved objects into the scene.
|
||||
/// </summary>
|
||||
public class RoomDataBlobService
|
||||
{
|
||||
private const int December2020PersistenceVersion = 26;
|
||||
|
||||
/// <summary>
|
||||
/// Cached bytes of the "all-permissions-on" blob. Identical for every
|
||||
/// room — there's no per-room state we vary yet, so building once at
|
||||
/// startup beats rebuilding on every download request.
|
||||
/// </summary>
|
||||
private readonly byte[] _allPermsBlob = BuildAllPermsBlob();
|
||||
private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData();
|
||||
private readonly byte[] _rroEditableBlob = BuildRroEditableBlob();
|
||||
private readonly RoomRoleCollectionData _rroEditableRoleData = BuildRroEditableRoleData();
|
||||
|
||||
public byte[] GetDefaultBlob() => _allPermsBlob;
|
||||
/// <summary>The blob served when a room_<id>_v1.dat misses S3
|
||||
/// (unsaved dorms / fresh customisable rooms). Uses the captured
|
||||
/// <c>data/default_room.room</c> as an input, but strips top-level
|
||||
/// persistence_views before serving it. Those views can reference
|
||||
/// prefabs absent from older clients and crash spawn with
|
||||
/// "Invalid Prefab Name: \"\""; room-role data uses the 2023 migration
|
||||
/// marker so the client does not re-run the legacy role importer.</summary>
|
||||
private readonly byte[] _defaultBlob;
|
||||
|
||||
public RoomDataBlobService()
|
||||
{
|
||||
_defaultBlob = LoadDefaultBlob();
|
||||
}
|
||||
|
||||
public byte[] GetDefaultBlob() => _defaultBlob;
|
||||
public byte[] GetRroEditableBlob() => _rroEditableBlob;
|
||||
|
||||
private static byte[] LoadDefaultBlob()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "data", "default_room.room");
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
if (bytes.Length > 0)
|
||||
{
|
||||
var msg = PersistedRoomData.Parser.ParseFrom(StripTopLevelField(bytes, 2));
|
||||
EnsureEditablePersistenceVersion(msg);
|
||||
msg.RoomRoleData = BuildDefaultRoomRoleData();
|
||||
return msg.ToByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to a minimal valid blob — never let a missing/locked
|
||||
// file take down room loads entirely.
|
||||
}
|
||||
return BuildMinimalDefaultBlob();
|
||||
}
|
||||
|
||||
private static byte[] StripTopLevelField(byte[] input, int fieldNumberToStrip)
|
||||
{
|
||||
using var output = new MemoryStream(input.Length);
|
||||
var pos = 0;
|
||||
while (pos < input.Length)
|
||||
{
|
||||
var fieldStart = pos;
|
||||
var tag = ReadVarint(input, ref pos);
|
||||
var fieldNumber = (int)(tag >> 3);
|
||||
var wireType = (int)(tag & 0x07);
|
||||
SkipField(input, ref pos, wireType);
|
||||
if (fieldNumber != fieldNumberToStrip)
|
||||
output.Write(input, fieldStart, pos - fieldStart);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static ulong ReadVarint(byte[] input, ref int pos)
|
||||
{
|
||||
ulong value = 0;
|
||||
var shift = 0;
|
||||
while (pos < input.Length)
|
||||
{
|
||||
var b = input[pos++];
|
||||
value |= (ulong)(b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) return value;
|
||||
shift += 7;
|
||||
if (shift > 63) throw new InvalidDataException("Invalid protobuf varint.");
|
||||
}
|
||||
throw new EndOfStreamException("Unexpected end of protobuf varint.");
|
||||
}
|
||||
|
||||
private static void SkipField(byte[] input, ref int pos, int wireType)
|
||||
{
|
||||
switch (wireType)
|
||||
{
|
||||
case 0:
|
||||
_ = ReadVarint(input, ref pos);
|
||||
break;
|
||||
case 1:
|
||||
pos += 8;
|
||||
break;
|
||||
case 2:
|
||||
pos += checked((int)ReadVarint(input, ref pos));
|
||||
break;
|
||||
case 5:
|
||||
pos += 4;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidDataException($"Unsupported protobuf wire type {wireType}.");
|
||||
}
|
||||
|
||||
if (pos > input.Length) throw new EndOfStreamException("Unexpected end of protobuf field.");
|
||||
}
|
||||
|
||||
public byte[] OverlayAllPermsRoleData(byte[] existingBlob)
|
||||
{
|
||||
var msg = PersistedRoomData.Parser.ParseFrom(existingBlob);
|
||||
@@ -66,14 +142,39 @@ public class RoomDataBlobService
|
||||
return msg.ToByteArray();
|
||||
}
|
||||
|
||||
private static byte[] BuildMinimalDefaultBlob()
|
||||
{
|
||||
var msg = new PersistedRoomData
|
||||
{
|
||||
DEPRECATEDVersion = December2020PersistenceVersion,
|
||||
RoomRoleData = BuildDefaultRoomRoleData(),
|
||||
};
|
||||
|
||||
return msg.ToByteArray();
|
||||
}
|
||||
|
||||
private static RoomRoleCollectionData BuildAllPermsRoleData()
|
||||
{
|
||||
var collection = new RoomRoleCollectionData();
|
||||
var collection = new RoomRoleCollectionData
|
||||
{
|
||||
RecNetMigrationVersion = 1,
|
||||
};
|
||||
collection.RoomRoles.Add(BuildPermissiveRole(0, 100, "Creator"));
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
private static RoomRoleCollectionData BuildDefaultRoomRoleData()
|
||||
{
|
||||
var collection = new RoomRoleCollectionData
|
||||
{
|
||||
RecNetMigrationVersion = 1,
|
||||
};
|
||||
collection.RoomRoles.Add(BuildPermissiveRole(2_097_152, 100, "Creator")); // AG_CREATOR
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
private static byte[] BuildRroEditableBlob()
|
||||
{
|
||||
var msg = new PersistedRoomData
|
||||
@@ -87,7 +188,10 @@ public class RoomDataBlobService
|
||||
|
||||
private static RoomRoleCollectionData BuildRroEditableRoleData()
|
||||
{
|
||||
var collection = new RoomRoleCollectionData();
|
||||
var collection = new RoomRoleCollectionData
|
||||
{
|
||||
RecNetMigrationVersion = 1,
|
||||
};
|
||||
collection.RoomRoles.Add(BuildPermissiveRole(2_097_152, 100, "Creator")); // AG_CREATOR
|
||||
collection.RoomRoles.Add(BuildPermissiveRole(4_194_304, 90, "Co-owner")); // AG_COOWNER
|
||||
collection.RoomRoles.Add(BuildPermissiveRole(8_388_608, 80, "Host")); // AG_HOST
|
||||
@@ -115,7 +219,7 @@ public class RoomDataBlobService
|
||||
DEPRECATEDIsRoleActive = true,
|
||||
DEPRECATEDIsAgRole = true,
|
||||
RoleName = name,
|
||||
RoleVersion = 1,
|
||||
RoleVersion = 20,
|
||||
RoleGuid = StableRoleGuid(roleId),
|
||||
|
||||
// Display name override — keeps the watch's role-list UI from
|
||||
@@ -133,6 +237,7 @@ public class RoomDataBlobService
|
||||
// perms is false.
|
||||
CanAssignRoles = OverrideTrue(),
|
||||
CanInvite = OverrideTrue(),
|
||||
DEPRECATEDCanEditCircuits = OverrideTrue(),
|
||||
CanStartGames = OverrideTrue(),
|
||||
CanTalk = OverrideTrue(),
|
||||
CanPrintPhotos = OverrideTrue(),
|
||||
@@ -145,6 +250,10 @@ public class RoomDataBlobService
|
||||
CanSaveInventions = OverrideTrue(),
|
||||
DisableMicAutoMute = OverrideTrue(),
|
||||
CanUseShareCam = OverrideTrue(),
|
||||
UnknownPermission20 = OverrideTrue(),
|
||||
UnknownPermission21 = OverrideTrue(),
|
||||
UnknownPermission22 = OverrideTrue(),
|
||||
UnknownPermission23 = OverrideTrue(),
|
||||
|
||||
// Vote-kick permission — int field, set to a permissive value.
|
||||
// 0 = AnyoneCanVoteKickAnyone in the 2020 enum (verified via
|
||||
@@ -168,7 +277,15 @@ public class RoomDataBlobService
|
||||
}
|
||||
|
||||
private static string StableRoleGuid(int roleId) =>
|
||||
GuidUtility.Create(GuidUtility.UrlNamespace, $"dorknet-room-role:{roleId}").ToString();
|
||||
roleId switch
|
||||
{
|
||||
2_097_152 => "D8B12451-23C7-4B1D-B573-8C3717A47915",
|
||||
4_194_304 => "88EAECE9-D885-4568-BC96-AF316AD56663",
|
||||
8_388_608 => "BD0F2F3A-F931-419E-B50C-ECDFF3F56B52",
|
||||
16_777_216 => "32300035-3BEA-457E-95C0-1630AFDFA6BD",
|
||||
0 => Guid.Empty.ToString(),
|
||||
_ => GuidUtility.Create(GuidUtility.UrlNamespace, $"dorknet-room-role:{roleId}").ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
internal static class GuidUtility
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Data;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Serilog;
|
||||
@@ -36,6 +37,7 @@ public static class DatabaseBootstrap
|
||||
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
await ConfigureSqliteForLocalConcurrencyAsync(db);
|
||||
BaselineExistingSchemaIfNeeded(db);
|
||||
db.Database.Migrate();
|
||||
}
|
||||
@@ -171,6 +173,27 @@ public static class DatabaseBootstrap
|
||||
Controllers.Health.HealthController.MigrationsComplete = true;
|
||||
}
|
||||
|
||||
private static async Task ConfigureSqliteForLocalConcurrencyAsync(DorkNetDbContext db)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
var closeAfter = conn.State != ConnectionState.Open;
|
||||
if (closeAfter) await conn.OpenAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "PRAGMA journal_mode=WAL;";
|
||||
await cmd.ExecuteScalarAsync();
|
||||
|
||||
cmd.CommandText = "PRAGMA busy_timeout=30000;";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (closeAfter) await conn.CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Idempotently create the SignupCodes + PendingDevices
|
||||
/// tables on whichever provider is in use. Safe to run every boot:
|
||||
/// CREATE TABLE / INDEX IF NOT EXISTS no-op once they exist (whether
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using DorkNet.Server.Compat;
|
||||
@@ -88,7 +89,14 @@ public static class ServiceCollectionExtensions
|
||||
if (string.IsNullOrWhiteSpace(dbPath))
|
||||
dbPath = Path.Combine(AppContext.BaseDirectory, "data", "dorknet.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
opt.UseSqlite($"Data Source={dbPath}", lite => lite.MigrationsAssembly("DorkNet.Server"));
|
||||
var conn = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = dbPath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Cache = SqliteCacheMode.Shared,
|
||||
DefaultTimeout = 30,
|
||||
}.ToString();
|
||||
opt.UseSqlite(conn, lite => lite.MigrationsAssembly("DorkNet.Server"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user