diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 8fd74f4..8e1e218 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -6,8 +6,9 @@ // 2. TLS trust bypass — make BouncyCastle never reject server certs // // Plus the anti-cheat BYPASS patches (anti-tamper / ToxMod / file-hash) -// needed so a modded client isn't kicked, and two opt-in features that -// are OFF by default (Desktop Screen-Share FPS, RRO quest party size). +// needed so a modded client isn't kicked, and three opt-in features that +// are OFF by default (Desktop Screen-Share FPS, RRO quest party size, +// Rec Royale night mode). // // This is the STABLE, shipping mod: it carries no diagnostic/tracing // code. All of that lives in the separate DorkNet.DebugMod (drop it in @@ -109,6 +110,52 @@ public class Mod : MelonMod // generic "Quest" config is intentionally NOT in the default list. public static string[] QuestMaxTeamSizeRooms = { "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" }; + + // ── Rec Royale night mode (client-side visual) ── + // Re-skins Rec Royale with one of the game's own shipped night + // MoodSetting assets (sky dome, sun, fog, bloom, colour grading) + // through SceneMoodSettings' apply path, and unlocks the weapon + // flashlight prefab while it is on. Purely local: the networked room + // mood index is never touched. See RecRoyaleNight. + public static bool RecRoyaleNightMode = false; + // Resources asset name of the MoodSetting to apply. Shipped options: + // Night_Calm_Outdoor_Mood, Night_Spooky_Outdoor_Mood, + // Night_Wild_Outdoor_Mood, Night_StuntRunner_Outdoor_Mood, OuterSpace_Mood. + public static string RecRoyaleNightMood = "Night_Calm_Outdoor_Mood"; + // Unity scene names (substring, case-insensitive) that count as Rec + // Royale. 2023-03-21 has a single map: RecRoyale_Frontier. + public static string[] RecRoyaleNightScenes = { "RecRoyale" }; + // Force WeaponFlashlightController.FlashlightAllowed while night is on. + public static bool RecRoyaleNightFlashlights = true; + // Apply the mood's fog block too. Off = the mood's FogSettings are + // swapped for a block captured from the scene, so fog stays as-is. + public static bool RecRoyaleNightFog = false; + // Darkening knobs applied to the mood data before it is used. Baked + // lightmaps can't be dimmed, so post-exposure (EV, added to the mood's + // ColorGradingSettings.exposure) is what actually darkens the whole + // frame; the sun/ambient multipliers tone down realtime lighting. + public static float RecRoyaleNightExposure = -3.5f; // EV offset (0 = mood default) + public static float RecRoyaleNightSunScale = 0.15f; // × SunSettings.sunIntensity (1 = unchanged) + public static float RecRoyaleNightAmbientScale = 0.2f; // × SkyBoxSettings.ambientLightIntensity (1 = unchanged) + // Post-exposure only renders if the scene Volume is on and the camera + // renders post-processing; force both while night mode is active. + public static bool RecRoyaleNightForcePostProcessing = true; + // The engine only drives RenderSettings.sun (Frontier: the sun-disc + // light). Copy its rotation/colour onto every other directional light + // and scale their intensity by RecRoyaleNightSunScale. + public static bool RecRoyaleNightMatchSunLights = true; + // Turn fog off entirely while night is on (RenderSettings.fog=false and + // the mood's fog block pushed out of range). Ignored when + // RecRoyaleNightFog is true. + public static bool RecRoyaleNightDisableFog = false; + // Renderers whose GameObject or material name contains any of these + // (case-insensitive) are hidden while night is on. Frontier's daytime + // cloud meshes are CloudPlane/Clouds/…_Cloud_RecRoyale_Frontier_Mat. + public static string[] RecRoyaleNightHideObjects = { "Cloud" }; + // Optional UnityEngine.KeyCode name to toggle night at runtime (e.g. + // "N"). Empty = no hotkey. A hotkey also arms the feature when + // RecRoyaleNightMode is false, so it can be switched on in-game. + public static string RecRoyaleNightToggleKey = ""; } public override void OnInitializeMelon() { @@ -178,6 +225,19 @@ public class Mod : MelonMod postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix)); } + // Rec Royale night mode: armed when enabled OR when a hotkey is set + // (so it can be switched on in-game). Scene detection + mood apply + // run from OnSceneWasLoaded / OnUpdate below. + if (Cfg.RecRoyaleNightMode || !string.IsNullOrWhiteSpace(Cfg.RecRoyaleNightToggleKey)) + { + _recRoyaleNightArmed = true; + RecRoyaleNight.Register(HarmonyInstance); + } + } + private bool _recRoyaleNightArmed; + public override void OnSceneWasLoaded(int buildIndex, string sceneName) + { + if (_recRoyaleNightArmed) RecRoyaleNight.OnSceneLoaded(sceneName); } private bool PatchFileHashCheckerCallback() { @@ -359,6 +419,8 @@ public class Mod : MelonMod } public override void OnUpdate() { + if (_recRoyaleNightArmed) RecRoyaleNight.Tick(); + if (!_antiTamperCallbackPatchesComplete && ++_antiTamperCallbackRetryFrame >= 300) { _antiTamperCallbackRetryFrame = 0; @@ -404,6 +466,33 @@ public class Mod : MelonMod rooms.Add(e.GetString()!); Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none) } + if (TryGetConfigValue(r, "RecRoyaleNightMode", out v)) Cfg.RecRoyaleNightMode = v.GetBoolean(); + if (TryGetConfigValue(r, "RecRoyaleNightMood", out v)) Cfg.RecRoyaleNightMood = v.GetString() ?? Cfg.RecRoyaleNightMood; + if (TryGetConfigValue(r, "RecRoyaleNightFlashlights", out v)) Cfg.RecRoyaleNightFlashlights = v.GetBoolean(); + if (TryGetConfigValue(r, "RecRoyaleNightFog", out v)) Cfg.RecRoyaleNightFog = v.GetBoolean(); + if (TryGetConfigValue(r, "RecRoyaleNightExposure", out v)) Cfg.RecRoyaleNightExposure = (float)v.GetDouble(); + if (TryGetConfigValue(r, "RecRoyaleNightSunScale", out v)) Cfg.RecRoyaleNightSunScale = (float)v.GetDouble(); + if (TryGetConfigValue(r, "RecRoyaleNightAmbientScale", out v)) Cfg.RecRoyaleNightAmbientScale = (float)v.GetDouble(); + if (TryGetConfigValue(r, "RecRoyaleNightForcePostProcessing", out v)) Cfg.RecRoyaleNightForcePostProcessing = v.GetBoolean(); + if (TryGetConfigValue(r, "RecRoyaleNightMatchSunLights", out v)) Cfg.RecRoyaleNightMatchSunLights = v.GetBoolean(); + if (TryGetConfigValue(r, "RecRoyaleNightDisableFog", out v)) Cfg.RecRoyaleNightDisableFog = v.GetBoolean(); + if (TryGetConfigValue(r, "RecRoyaleNightHideObjects", out v) && v.ValueKind == JsonValueKind.Array) + { + var names = new List(); + foreach (var e in v.EnumerateArray()) + if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString())) + names.Add(e.GetString()!); + Cfg.RecRoyaleNightHideObjects = names.ToArray(); // explicit (incl. empty = hide nothing) + } + if (TryGetConfigValue(r, "RecRoyaleNightToggleKey", out v)) Cfg.RecRoyaleNightToggleKey = v.GetString() ?? ""; + if (TryGetConfigValue(r, "RecRoyaleNightScenes", out v) && v.ValueKind == JsonValueKind.Array) + { + var scenes = new List(); + foreach (var e in v.EnumerateArray()) + if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString())) + scenes.Add(e.GetString()!); + if (scenes.Count > 0) Cfg.RecRoyaleNightScenes = scenes.ToArray(); + } } catch (Exception ex) { @@ -573,7 +662,7 @@ public class Mod : MelonMod private MethodInfo GetPatchMethod(string name) { // Look in the core + feature patch holder classes. - foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize) }) + foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight) }) { var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); if (m is not null) return m; diff --git a/DorkNet.ClientMod/RecRoyaleNight.cs b/DorkNet.ClientMod/RecRoyaleNight.cs new file mode 100644 index 0000000..3928570 --- /dev/null +++ b/DorkNet.ClientMod/RecRoyaleNight.cs @@ -0,0 +1,1401 @@ +// Rec Royale night mode — client-side, opt-in. +// +// Rec Royale never shipped a night variant, but the March-2023 client +// carries everything needed to build one from the game's own tooling: +// +// • Every room scene owns a RecRoomSceneManager (name-preserved field +// `sceneMoodSettings` → RecRoom.Core.Scene.SceneMoodSettings). That +// object is the room "mood" engine behind the Maker Pen sky/sun/fog +// settings: it holds `availableMoods` (MoodSetting[]), `defaultMood` +// and `CurrentMood`, plus blenders for skybox, sun, fog, bloom and +// colour grading. +// • SceneMoodSettings.MLLBALMKEGJ(MoodSetting) is the apply step +// (ISIL: SceneMoodSettings.txt:3025-3581). It pushes the mood's six +// settings blocks into the blenders (ALCMNGKOEBK), swaps the URP +// VolumeProfile (OEKNGEMDMDB), sets RenderSettings.skybox to the +// global MoodConfig skybox material (LNMDIIFOFPD.NHAEHKJINFH, shader +// "Custom/SkyBox"), re-aims RenderSettings.sun, sets every +// ReflectionProbe.customBakedTexture to the mood's baked cubemap and +// force-updates the blenders. It does NOT touch the networked mood +// index, so calling it is purely local. +// • The build ships ready-made night moods as Resources assets +// (resources.assets; ResourceManager table in globalgamemanagers): +// Night_Calm_Outdoor_Mood ("Calm Night"), Night_Spooky_Outdoor_Mood +// ("Spooky Night"), Night_Wild_Outdoor_Mood ("Wild Night"), +// Night_StuntRunner_Outdoor_Mood and OuterSpace_Mood ("Outer Space"). +// • The only Rec Royale map in this build is +// Assets/Activities/RecRoyale/Maps/RecRoyale_Frontier/RecRoyale_Frontier.unity. +// +// RRO SCENES HAVE NO ROOM DATA BLOB. The mood engine's `isEnabled` flag +// (SceneMoodSettings+0x38) is only switched on by the room-data loader +// (EIMFCPIHEPF.JPKEMACDNCF, ISIL :2136-2310), which runs for custom rooms +// only. While it is off, get_CurrentMood returns null and nothing ticks. +// The loader's bring-up sequence is: +// LKIKMLNPCFK(false) // SetEnabled(false): applies a null mood +// LIEDFOLBGHL() // capture the scene's own skybox/sun/fog as the +// // blenders' "previous" data (checks shader name +// // "Custom/SkyBox" → HasRecRoomSkyboxShader) +// LKIKMLNPCFK(true) // SetEnabled(true) +// This mod replays exactly that before applying the night mood. +// +// Deliberately NOT used: SceneMoodSettings.IPAMPMBDPNB(Guid) / +// set_CurrentRoomMoodIndex. `_currentRoomMoodIndex` is a +// RecRoom.Networking.SynchronizedFields DFAOKLDJPEL, i.e. it +// replicates to every player in the instance. Applying the mood directly +// keeps night mode a per-client visual choice. +// +// Weapon flashlights: WeaponFlashlightController.get_FlashlightAllowed +// (ISIL: WeaponFlashlightController.txt:3-75) is +// `Matchmaking.FBELDNLEKAC() != null && [+0x28] == 15` — a game-mode +// gate for an activity that isn't Rec Royale. A Harmony postfix forces it +// true while night mode is active, so weapons picked up in the dark get +// the game's own flashlight prefab. +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Il2CppInterop.Runtime; +using Il2CppInterop.Runtime.InteropTypes; + +namespace DorkNet.ClientMod; + +internal static class RecRoyaleNight +{ + private const BindingFlags Any = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + + // Runtime state. `Active` starts from Cfg.RecRoyaleNightMode and flips + // with the hotkey; `_inRoyale` tracks whether the current scene is one + // of Cfg.RecRoyaleNightScenes. + public static bool Active { get; private set; } + private static bool _inRoyale; + private static string _sceneName = ""; + private static bool _appliedThisScene; + private static int _frame; + private static int _sceneFrames; // frames since the Royale scene loaded + private static int _reassertFrames; // frames since the last re-apply + + // Per-scene bring-up bookkeeping so toggling off can put things back. + private static bool _engineWasDisabled; // we enabled the mood engine ourselves + private static object? _originalSkybox; // RenderSettings.skybox before we touched it + private static bool _sunWasNull; // we assigned RenderSettings.sun ourselves + private static object? _originalFog; // MoodSetting.FogSettings before we swapped it + private static object? _fogPatchedMood; // the mood asset whose FogSettings we replaced + private static object? _darkPatchedMood; // the mood asset whose exposure/sun/ambient we changed + private static float? _origExposure, _origSunIntensity, _origAmbient; + private static bool _diagLogged; + + private const int PollEveryFrames = 30; // ~0.5 s @ 60 fps + private const int SettleFrames = 90; // let the scene's own lighting settle first + private const int PollGiveUpFrames = 60 * 120; // stop polling after ~2 min + private const int ReassertEveryFrames = 60 * 10; // re-apply every ~10 s + + // Resolved reflection handles (lazy, cached). + private static bool _resolved; + private static Type? _sceneManagerType; + private static PropertyInfo? _sceneManagerInstance; + private static FieldInfo? _sceneMoodSettingsField; + private static PropertyInfo? _sceneMoodSettingsProp; + private static Type? _moodSettingType; + private static MethodInfo? _applyMood; // MLLBALMKEGJ(MoodSetting) + private static MethodInfo? _setEnabled; // LKIKMLNPCFK(bool) + private static MethodInfo? _capture; // LIEDFOLBGHL() + private static FieldInfo? _isEnabled; // isEnabled + private static PropertyInfo? _roomMoodsEnabled; + private static PropertyInfo? _hasSkyShader; + private static PropertyInfo? _hasSunSource; + private static PropertyInfo? _volume; + private static FieldInfo? _availableMoods; + private static PropertyInfo? _currentMood; + private static PropertyInfo? _friendlyName; + private static PropertyInfo? _renderSkybox; // UnityEngine.RenderSettings.skybox + private static PropertyInfo? _renderSun; // UnityEngine.RenderSettings.sun + private static Type? _lightType; // UnityEngine.Light + private static Type? _fogSettingsType; // FogMoodSettings + private static MethodInfo? _fogCapture; // FogMoodSettings.NLDDIDEIEFH() — read RenderSettings fog + private static PropertyInfo? _moodFog; // MoodSetting.FogSettings + private static Type? _materialType; // UnityEngine.Material + private static Type? _shaderType; // UnityEngine.Shader + private static Type? _moodConfigHolder; // LNMDIIFOFPD (static MoodConfig holder) + private static Type? _cameraDataType; // UnityEngine.Rendering.Universal.UniversalAdditionalCameraData + private static object? _ourSkybox; // Material we created for the mood skybox + private static object? _nightMood; // MoodSetting interop wrapper + private static string _nightMoodLoadedName = ""; + + // Hotkey + private static bool _inputResolved; + private static MethodInfo? _getKeyDown; + private static object? _toggleKey; + + // ── registration ────────────────────────────────────────────────── + public static void Register(HarmonyLib.Harmony harmony) + { + Active = Mod.Cfg.RecRoyaleNightMode; + Mod.Log.Msg($"[royale-night] enabled={Active} mood={Mod.Cfg.RecRoyaleNightMood} " + + $"scenes=[{string.Join(",", Mod.Cfg.RecRoyaleNightScenes)}] " + + $"flashlights={Mod.Cfg.RecRoyaleNightFlashlights} " + + $"hotkey={(string.IsNullOrWhiteSpace(Mod.Cfg.RecRoyaleNightToggleKey) ? "" : Mod.Cfg.RecRoyaleNightToggleKey)}"); + + if (Mod.Cfg.RecRoyaleNightFlashlights) + TryPatchFlashlightGate(harmony); + } + + // POSTFIX on WeaponFlashlightController.get_FlashlightAllowed. The + // interop property carries the obfuscated name (KIMMMCCKMJL in the + // 2023-03-21 build) while the il2cpp method keeps `get_FlashlightAllowed`, + // so resolve by name first and fall back to "the only read-only bool + // property declared on the type". + private static void TryPatchFlashlightGate(HarmonyLib.Harmony harmony) + { + try + { + var type = Mod.ResolveType("WeaponFlashlightController"); + if (type is null) + { + Mod.Log.Warning("[patch-miss] WeaponFlashlightController: type not found"); + return; + } + + MethodInfo? getter = AccessTools.PropertyGetter(type, "FlashlightAllowed") + ?? AccessTools.PropertyGetter(type, "KIMMMCCKMJL"); + if (getter is null) + { + PropertyInfo? only = null; + int count = 0; + foreach (var p in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + if (p.PropertyType != typeof(bool) || !p.CanRead || p.CanWrite || p.GetIndexParameters().Length != 0) continue; + only = p; count++; + } + if (count == 1) getter = only!.GetGetMethod(true); + } + if (getter is null) + { + Mod.Log.Warning("[patch-miss] WeaponFlashlightController.get_FlashlightAllowed"); + return; + } + + var postfix = typeof(RecRoyaleNight).GetMethod(nameof(FlashlightAllowed_Postfix), BindingFlags.Public | BindingFlags.Static)!; + harmony.Patch(getter, postfix: new HarmonyMethod(postfix)); + } + catch (Exception ex) + { + Mod.Log.Error($"[patch-fail] WeaponFlashlightController.get_FlashlightAllowed: {ex.Message}"); + } + } + + public static void FlashlightAllowed_Postfix(ref bool __result) + { + if (Active && _inRoyale) __result = true; + } + + // ── lifecycle (called from Mod) ─────────────────────────────────── + public static void OnSceneLoaded(string sceneName) + { + _sceneName = sceneName ?? ""; + var was = _inRoyale; + _inRoyale = MatchesScene(_sceneName); + if (_inRoyale) + { + _appliedThisScene = false; + _sceneFrames = 0; + _reassertFrames = 0; + _engineWasDisabled = false; + _originalSkybox = null; + _sunWasNull = false; + _diagLogged = false; + RestoreMoodPatches(); + Mod.Log.Msg($"[royale-night] scene '{_sceneName}' loaded — night mode {(Active ? "will apply" : "is off (hotkey to enable)")}"); + } + else if (was) + { + _appliedThisScene = false; + _engineWasDisabled = false; + _originalSkybox = null; + _sunWasNull = false; + RestoreMoodPatches(); + } + } + + public static void Tick() + { + _frame++; + PollHotkey(); + + if (!_inRoyale || !Active) return; + _sceneFrames++; + if (_frame % PollEveryFrames != 0) return; + + if (!_appliedThisScene) + { + if (_sceneFrames < SettleFrames || _sceneFrames > PollGiveUpFrames) return; + if (TryApply(true)) _appliedThisScene = true; + return; + } + + // Cheap safeguard: the game re-applies its own mood on quality + // changes / studio events. Re-assert every ~10 s; when the blenders + // are already at target this is visually a no-op. + _reassertFrames += PollEveryFrames; + if (_reassertFrames >= ReassertEveryFrames) + { + _reassertFrames = 0; + TryApply(true); + } + } + + public static void Toggle() + { + Active = !Active; + Mod.Log.Msg($"[royale-night] toggled {(Active ? "ON" : "OFF")}"); + if (!_inRoyale) return; + if (TryApply(Active)) _appliedThisScene = Active; + else if (!Active) _appliedThisScene = false; + } + + // ── the actual apply ────────────────────────────────────────────── + // night=true → bring the mood engine up if needed, apply the night mood + // night=false → restore: scene mood if the engine was already running, + // otherwise disable the engine again + put the skybox back + private static bool TryApply(bool night) + { + try + { + if (!_resolved && !Resolve()) return false; + if (_applyMood is null) return false; + + var manager = GetSceneManager(); + if (manager is null) { Diag("scene manager instance is null"); return false; } + + var moods = _sceneMoodSettingsField?.GetValue(manager) ?? _sceneMoodSettingsProp?.GetValue(manager); + if (moods is null) { Diag("sceneMoodSettings is null"); return false; } + + // `isEnabled` reads differently through the interop property vs the + // field (seen True/False for the same state), so don't gate on it. + // RoomMoodsEnabled (= isEnabled && defaultMood != null) is computed + // game-side and is false for every RRO scene; bring the engine up + // exactly once per Royale scene whenever it is not true. + var enabled = GetMember(moods, "isEnabled") is true; + var roomMoodsEnabled = Read(_roomMoodsEnabled, moods) is true; + LogState(moods, enabled); + + if (night) + { + var target = GetNightMood(); + if (target is null) return false; + + if (!roomMoodsEnabled && !_engineWasDisabled) + { + // Replay the room-data loader's bring-up. Remember the + // scene's own skybox so toggle-off can restore it. + if (_originalSkybox is null && _renderSkybox is not null) + _originalSkybox = _renderSkybox.GetValue(null); + if (_setEnabled is null || _capture is null) + { + Mod.Log.Warning("[royale-night] mood engine is disabled in this scene and SetEnabled/Capture are unresolved — cannot bring it up"); + return false; + } + + // RRO scenes leave RenderSettings.sun unset, and the engine + // skips every sun change (direction, colour, intensity) when + // HasSunSource is false. Point it at the scene's directional + // light BEFORE capture so the flag comes out true. + EnsureSun(); + + // Keep the scene's own fog unless asked otherwise: replace + // the mood's FogSettings with a block captured from the + // current RenderSettings before anything changes them. + if (!Mod.Cfg.RecRoyaleNightFog) PreserveFog(target); + + // Baked lightmaps can't be dimmed, so darken the frame via + // the mood's own post-exposure and tone down sun/ambient. + PatchDarkness(target); + + _setEnabled.Invoke(moods, new object[] { false }); + _capture.Invoke(moods, null); + + // The per-frame update (CEKLEBHDAHA, ISIL :3895-4031) returns + // at once while get_CurrentMood() is null, and the one-shot + // apply only sets blend TARGETS — the update is what writes + // sky colours, rotates the sun and pushes exposure. In RRO + // scenes CurrentMood is null because defaultMood is unset. + // Register the night mood as this scene's defaultMood and + // sole availableMoods entry so the engine runs like a + // one-mood custom room. + InstallSceneMood(moods, target); + + _setEnabled.Invoke(moods, new object[] { true }); + _engineWasDisabled = true; + ProbeCircuitsSupport(); + var cm = _currentMood?.GetValue(moods); + Mod.Log.Msg($"[royale-night] mood engine brought up (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)} " + + $"roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} currentMood={(cm is null || IsUnityNull(cm) ? "" : Describe(cm))})"); + + // First apply swaps RenderSettings.skybox to + // MoodConfig.SkyBoxMaterial. In RRO scenes that material is + // not loaded (it lives in the custom-room scene's shared + // assets), so the skybox ends up null → black sky and the + // engine's HasRecRoomSkyboxShader / HasSunSource stay false + // (both need shader "Custom/SkyBox" on RenderSettings.skybox). + // Supply a material built from that shader, re-capture so + // the flags flip, then fall through to the real apply. + _applyMood.Invoke(moods, new[] { target }); + EnsureMoodSkybox(); + _capture.Invoke(moods, null); + Mod.Log.Msg($"[royale-night] re-captured (skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} skybox={DescribeSkybox()})"); + EnsurePostProcessing(moods); + } + + _applyMood.Invoke(moods, new[] { target }); + if (Mod.Cfg.RecRoyaleNightMatchSunLights) MatchOtherSunLights(); + if (Mod.Cfg.RecRoyaleNightDisableFog && !Mod.Cfg.RecRoyaleNightFog) ForceFogOff(); + HideObjects(); + Mod.Log.Msg($"[royale-night] applied mood '{Describe(target)}' in '{_sceneName}'"); + return true; + } + + // restore + if (_engineWasDisabled) + { + RestoreOtherSunLights(); + ShowObjects(); + RestoreFogState(); + _setEnabled?.Invoke(moods, new object[] { false }); // applies the captured "previous" data + UninstallSceneMood(moods); + if (_originalSkybox is not null && _renderSkybox is not null) + _renderSkybox.SetValue(null, _originalSkybox); + if (_sunWasNull && _renderSun is not null) + _renderSun.SetValue(null, null); + RestoreMoodPatches(); + _engineWasDisabled = false; + _originalSkybox = null; + _sunWasNull = false; + Mod.Log.Msg($"[royale-night] mood engine disabled again, scene skybox/sun restored in '{_sceneName}'"); + return true; + } + + var current = _currentMood?.GetValue(moods); + if (current is null || IsUnityNull(current)) { Diag("no CurrentMood to restore"); return false; } + _applyMood.Invoke(moods, new[] { current }); + Mod.Log.Msg($"[royale-night] restored scene mood '{Describe(current)}' in '{_sceneName}'"); + return true; + } + catch (Exception ex) + { + Mod.Log.Warning($"[royale-night] apply failed: {(ex.InnerException ?? ex).Message}"); + return false; + } + } + + private static void LogState(object moods, bool enabled) + { + if (_diagLogged) return; + _diagLogged = true; + try + { + var arr = GetMember(moods, "availableMoods"); + var cur = _currentMood?.GetValue(moods); + Mod.Log.Msg($"[royale-night] engine state: isEnabled={enabled} roomMoodsEnabled={Read(_roomMoodsEnabled, moods)} " + + $"availableMoods={Count(arr)} currentMood={(cur is null || IsUnityNull(cur) ? "" : Describe(cur))} " + + $"skyShader={Read(_hasSkyShader, moods)} sun={Read(_hasSunSource, moods)} volume={!(Read(_volume, moods) is null)}"); + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] state log failed: {ex.Message}"); } + } + + private static object? Read(PropertyInfo? p, object o) + { + try { return p?.GetValue(o); } catch { return null; } + } + + private static void Diag(string why) + { + if (_frame % (PollEveryFrames * 20) == 0) Mod.Log.Msg($"[royale-night] waiting: {why}"); + } + + private static bool Resolve() + { + _resolved = true; + + _sceneManagerType = Mod.ResolveType("RecRoomSceneManager"); + var moodSettingsType = Mod.ResolveType("RecRoom.Core.Scene.SceneMoodSettings"); + _moodSettingType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodSetting"); + if (_sceneManagerType is null || moodSettingsType is null || _moodSettingType is null) + { + Mod.Log.Warning($"[royale-night] types missing — sceneManager={_sceneManagerType is not null} " + + $"sceneMoodSettings={moodSettingsType is not null} moodSetting={_moodSettingType is not null}"); + return false; + } + + // RecRoomSceneManager.Instance: the il2cpp getter is get_Instance but + // the interop property name is obfuscated (PDCOCJBICGC in 2023-03-21). + // Pick the static property whose type is RecRoomSceneManager. + _sceneManagerInstance = AccessTools.Property(_sceneManagerType, "Instance"); + if (_sceneManagerInstance is null) + { + foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) + { + if (p.PropertyType == _sceneManagerType && p.CanRead && p.GetIndexParameters().Length == 0) + { _sceneManagerInstance = p; break; } + } + } + + _sceneMoodSettingsField = AccessTools.Field(_sceneManagerType, "sceneMoodSettings"); + if (_sceneMoodSettingsField is null) + { + foreach (var p in _sceneManagerType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + if (p.PropertyType == moodSettingsType && p.CanRead) { _sceneMoodSettingsProp = p; break; } + } + + _currentMood = AccessTools.Property(moodSettingsType, "CurrentMood"); + _roomMoodsEnabled = AccessTools.Property(moodSettingsType, "RoomMoodsEnabled"); + _hasSkyShader = AccessTools.Property(moodSettingsType, "HasRecRoomSkyboxShader"); + _hasSunSource = AccessTools.Property(moodSettingsType, "HasSunSource"); + _volume = AccessTools.Property(moodSettingsType, "Volume"); + _availableMoods = AccessTools.Field(moodSettingsType, "availableMoods"); + _isEnabled = AccessTools.Field(moodSettingsType, "isEnabled"); + _friendlyName = AccessTools.Property(_moodSettingType, "FriendlyName"); + + // Apply step: MLLBALMKEGJ(MoodSetting) in 2023-03-21. Fallback: the + // only non-setter void method taking exactly one MoodSetting. + _applyMood = AccessTools.Method(moodSettingsType, "MLLBALMKEGJ", new[] { _moodSettingType }); + if (_applyMood is null) + { + foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue; + var ps = m.GetParameters(); + if (ps.Length == 1 && ps[0].ParameterType == _moodSettingType) { _applyMood = m; break; } + } + } + + // SetEnabled(bool): LKIKMLNPCFK in 2023-03-21. Fallback: the only + // public non-setter void(bool) declared on the type. + _setEnabled = AccessTools.Method(moodSettingsType, "LKIKMLNPCFK", new[] { typeof(bool) }); + if (_setEnabled is null) + { + MethodInfo? only = null; int n = 0; + foreach (var m in moodSettingsType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + if (m.ReturnType != typeof(void) || m.Name.StartsWith("set_", StringComparison.Ordinal)) continue; + var ps = m.GetParameters(); + if (ps.Length == 1 && ps[0].ParameterType == typeof(bool)) { only = m; n++; } + } + if (n == 1) _setEnabled = only; + } + + // Capture current scene lighting: LIEDFOLBGHL() in 2023-03-21. Too + // many void() methods on the type to pick by shape — name only. + _capture = AccessTools.Method(moodSettingsType, "LIEDFOLBGHL", Type.EmptyTypes); + + var renderSettings = Mod.ResolveType("UnityEngine.RenderSettings"); + _renderSkybox = renderSettings is null ? null : AccessTools.Property(renderSettings, "skybox"); + _renderSun = renderSettings is null ? null : AccessTools.Property(renderSettings, "sun"); + _lightType = Mod.ResolveType("UnityEngine.Light"); + + // Fog preservation: FogMoodSettings() + NLDDIDEIEFH() reads + // RenderSettings.fog/fogStartDistance/fogEndDistance/fogColor into the + // block (ISIL FogMoodSettings.txt, called right after the ctor in + // LIEDFOLBGHL). MoodSetting.FogSettings is the serialized reference + // the apply step hands to the fog blender. + _fogSettingsType = Mod.ResolveType("RecRoom.Core.Creation.SceneMoods.MoodBlendData.FogMoodSettings"); + _fogCapture = _fogSettingsType is null ? null : AccessTools.Method(_fogSettingsType, "NLDDIDEIEFH", Type.EmptyTypes); + _moodFog = AccessTools.Property(_moodSettingType, "FogSettings"); + + _materialType = Mod.ResolveType("UnityEngine.Material"); + _shaderType = Mod.ResolveType("UnityEngine.Shader"); + _moodConfigHolder = Mod.ResolveType("LNMDIIFOFPD"); + _cameraDataType = Mod.ResolveType("UnityEngine.Rendering.Universal.UniversalAdditionalCameraData"); + + var ok = _sceneManagerInstance is not null + && (_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null) + && _applyMood is not null; + Mod.Log.Msg($"[royale-night] resolved: instance={_sceneManagerInstance is not null} " + + $"moodSettings={_sceneMoodSettingsField is not null || _sceneMoodSettingsProp is not null} " + + $"apply={_applyMood is not null} setEnabled={_setEnabled is not null} capture={_capture is not null} " + + $"isEnabled={_isEnabled is not null} currentMood={_currentMood is not null} skybox={_renderSkybox is not null} " + + $"sun={_renderSun is not null} light={_lightType is not null} fogCapture={_fogCapture is not null} moodFog={_moodFog is not null}"); + return ok; + } + + // RenderSettings.sun ← the scene's directional Light when unset. Frontier + // ships without the reference ("Sun Source reference has not been set in + // Lighting settings" is the engine's own warning), so nothing sun-related + // from the mood would otherwise apply. + private static void EnsureSun() + { + try + { + if (_renderSun is null || _lightType is null) return; + var current = _renderSun.GetValue(null); + if (current is not null && !IsUnityNull(current)) + { + Mod.Log.Msg($"[royale-night] RenderSettings.sun already set: '{ObjectName(current)}'"); + return; + } + + var unityObject = Mod.ResolveType("UnityEngine.Object"); + var il2cppLight = Il2CppTypeOf(_lightType); + if (unityObject is null || il2cppLight is null) return; + + object? directional = null; + foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition) continue; + var ps = m.GetParameters(); + if (ps.Length != 1) continue; + var arr = m.Invoke(null, new object[] { il2cppLight }); + var n = Count(arr); + float best = -1f; + for (int i = 0; i < n; i++) + { + var item = Item(arr, i); + if (item is null) continue; + var light = AsType(item, _lightType); + if (light is null) continue; + // LightType.Directional == 1 + var kind = GetMember(light, "type"); + if (kind is null || Convert.ToInt32(kind) != 1) continue; + var intensity = GetMember(light, "intensity") is float f ? f : 0f; + if (intensity > best) { best = intensity; directional = light; } + } + break; + } + + if (directional is null) + { + Mod.Log.Warning("[royale-night] no directional Light found in scene — sun direction/colour will not change"); + return; + } + _renderSun.SetValue(null, directional); + _sunWasNull = true; + Mod.Log.Msg($"[royale-night] RenderSettings.sun ← '{ObjectName(directional)}'"); + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureSun failed: {(ex.InnerException ?? ex).Message}"); } + } + + // Swap the mood asset's FogSettings for a block captured from the + // scene's current RenderSettings so applying the mood leaves fog as-is. + // The asset is shared for the session, so RestoreFog puts the original + // block back when we leave or toggle off. + private static void PreserveFog(object mood) + { + try + { + if (_fogSettingsType is null || _fogCapture is null || _moodFog is null || !_moodFog.CanWrite) + { + Mod.Log.Warning("[royale-night] fog preservation unavailable (FogMoodSettings/NLDDIDEIEFH/FogSettings unresolved) — mood fog will apply"); + return; + } + if (ReferenceEquals(_fogPatchedMood, mood) && _originalFog is not null) return; + + var captured = Activator.CreateInstance(_fogSettingsType); + if (captured is null) return; + _fogCapture.Invoke(captured, null); + if (Mod.Cfg.RecRoyaleNightDisableFog) + { + // The block has no exposed enabled flag; push it out of range so + // even if the blender re-enables fog each frame it is invisible. + SetMember(captured, "startDistance", 1_000_000f); + SetMember(captured, "fadeDistance", 1_000_000f); + } + + _originalFog = _moodFog.GetValue(mood); + _fogPatchedMood = mood; + _moodFog.SetValue(mood, captured); + Mod.Log.Msg("[royale-night] mood fog replaced with the scene's own fog settings"); + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] PreserveFog failed: {(ex.InnerException ?? ex).Message}"); } + } + + private static void RestoreFog() + { + try + { + if (_fogPatchedMood is not null && _originalFog is not null && _moodFog is not null && _moodFog.CanWrite) + _moodFog.SetValue(_fogPatchedMood, _originalFog); + } + catch { } + _fogPatchedMood = null; + _originalFog = null; + } + + private static void RestoreMoodPatches() + { + RestoreFog(); + RestoreDarkness(); + RestoreOtherSunLights(); + // Scene objects die with the scene; just forget them. + _probed = false; + _hidden.Clear(); + _hideScanned = false; + _fogStateSaved = false; + _origFogEnabled = null; + // The scene's SceneMoodSettings dies with the scene; just forget it. + _sceneMoodInstalled = false; + _sceneMoodsObj = null; + _origDefaultMood = _origAvailableMoods = null; + } + + // The engine drives exactly one light: RenderSettings.sun (Frontier: + // "SkyboxLight", the sun-disc light). The map's actual key light is a + // separate directional Light it never touches, so daylight keeps + // casting. Copy the engine-driven sun's rotation and colour onto every + // other directional light and scale their intensity down. Originals are + // kept per light and restored on toggle-off / scene change. + private sealed class LightState + { + public object Light = null!; + public object? Intensity, Color, Rotation, ShadowStrength; + } + private static readonly List _otherSuns = new(); + + private static void MatchOtherSunLights() + { + try + { + if (_renderSun is null || _lightType is null) return; + var sun = _renderSun.GetValue(null); + if (sun is null || IsUnityNull(sun)) return; + var sunPtr = (sun as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero; + var sunTransform = GetMember(sun, "transform"); + var sunRotation = sunTransform is null ? null : GetMember(sunTransform, "rotation"); + var sunColor = GetMember(sun, "color"); + + var unityObject = Mod.ResolveType("UnityEngine.Object"); + var il2cppLight = Il2CppTypeOf(_lightType); + if (unityObject is null || il2cppLight is null) return; + + MethodInfo? findAll = null; + foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static)) + if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; } + if (findAll is null) return; + + var arr = findAll.Invoke(null, new object[] { il2cppLight }); + var n = Count(arr); + var touched = new List(); + for (int i = 0; i < n; i++) + { + var item = Item(arr, i); + if (item is null) continue; + var light = AsType(item, _lightType); + if (light is null) continue; + var kind = GetMember(light, "type"); + if (kind is null || Convert.ToInt32(kind) != 1) continue; // Directional only + var ptr = (light as Il2CppObjectBase)?.Pointer ?? IntPtr.Zero; + if (ptr != IntPtr.Zero && ptr == sunPtr) continue; // engine already drives this one + + var state = _otherSuns.Find(s => (s.Light as Il2CppObjectBase)?.Pointer == ptr); + if (state is null) + { + state = new LightState + { + Light = light, + Intensity = GetMember(light, "intensity"), + Color = GetMember(light, "color"), + ShadowStrength = GetMember(light, "shadowStrength"), + Rotation = GetMember(light, "transform") is object tr ? GetMember(tr, "rotation") : null, + }; + _otherSuns.Add(state); + } + + if (state.Intensity is float orig) + SetMember(light, "intensity", orig * Mod.Cfg.RecRoyaleNightSunScale); + if (sunColor is not null) SetMember(light, "color", sunColor); + if (sunRotation is not null && GetMember(light, "transform") is object t) + SetMember(t, "rotation", sunRotation); + touched.Add(ObjectName(light)); + } + if (touched.Count > 0 && !_otherSunsLogged) + { + _otherSunsLogged = true; + Mod.Log.Msg($"[royale-night] matched {touched.Count} other directional light(s) to the mood sun: {string.Join(", ", touched)}"); + } + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] MatchOtherSunLights failed: {(ex.InnerException ?? ex).Message}"); } + } + private static bool _otherSunsLogged; + + // Register the mood as the scene's defaultMood + sole availableMoods entry + // (get_CurrentMood returns availableMoods[index] when the synced index is + // set, else defaultMood — either way our mood). Originals are kept so + // UninstallSceneMood can put the scene back to "no room moods". + private static object? _sceneMoodsObj; + private static object? _origDefaultMood, _origAvailableMoods; + private static bool _sceneMoodInstalled; + + private static void InstallSceneMood(object moods, object mood) + { + try + { + if (_sceneMoodInstalled && ReferenceEquals(_sceneMoodsObj, moods)) return; + _sceneMoodsObj = moods; + _origDefaultMood = GetMember(moods, "defaultMood"); + _origAvailableMoods = GetMember(moods, "availableMoods"); + + var okDefault = SetMember(moods, "defaultMood", mood); + + // availableMoods is an Il2CppReferenceArray; build a + // one-element array of the same interop type and fill slot 0. + var okArray = false; + var arrProp = moods.GetType().GetProperty("availableMoods", Any); + var arrType = arrProp?.PropertyType ?? _origAvailableMoods?.GetType(); + if (arrType is not null) + { + object? arr = null; + foreach (var sig in new[] { new[] { typeof(long) }, new[] { typeof(int) } }) + { + var ctor = arrType.GetConstructor(sig); + if (ctor is null) continue; + try { arr = ctor.Invoke(new object[] { sig[0] == typeof(long) ? 1L : 1 }); break; } catch { } + } + if (arr is not null) + { + var set = arrType.GetMethod("set_Item", new[] { typeof(int), _moodSettingType! }); + if (set is null) + { + foreach (var m in arrType.GetMethods()) + if (m.Name == "set_Item" && m.GetParameters().Length == 2 && m.GetParameters()[0].ParameterType == typeof(int)) { set = m; break; } + } + try { set?.Invoke(arr, new[] { 0, mood }); okArray = set is not null && SetMember(moods, "availableMoods", arr); } catch { } + } + } + + _sceneMoodInstalled = okDefault || okArray; + Mod.Log.Msg($"[royale-night] installed mood as scene default (defaultMood={okDefault} availableMoods={okArray})"); + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] InstallSceneMood failed: {(ex.InnerException ?? ex).Message}"); } + } + + private static void UninstallSceneMood(object moods) + { + try + { + if (!_sceneMoodInstalled) return; + if (_origDefaultMood is not null) SetMember(moods, "defaultMood", _origDefaultMood); + else SetMember(moods, "defaultMood", null!); + if (_origAvailableMoods is not null) SetMember(moods, "availableMoods", _origAvailableMoods); + } + catch { } + _sceneMoodInstalled = false; + _sceneMoodsObj = null; + _origDefaultMood = _origAvailableMoods = null; + } + + // ── fog off / hide objects ──────────────────────────────────────── + private static bool _fogStateSaved; + private static object? _origFogEnabled; + private static Type? _renderSettingsType; + + private static void ForceFogOff() + { + try + { + _renderSettingsType ??= Mod.ResolveType("UnityEngine.RenderSettings"); + if (_renderSettingsType is null) return; + var p = _renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static); + if (p is null) return; + if (!_fogStateSaved) { _origFogEnabled = p.GetValue(null); _fogStateSaved = true; } + if (p.GetValue(null) is true) p.SetValue(null, false); + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] ForceFogOff failed: {ex.Message}"); } + } + + private static void RestoreFogState() + { + try + { + if (_fogStateSaved && _renderSettingsType is not null && _origFogEnabled is bool b) + _renderSettingsType.GetProperty("fog", BindingFlags.Public | BindingFlags.Static)?.SetValue(null, b); + } + catch { } + _fogStateSaved = false; + _origFogEnabled = null; + } + + // Disable every Renderer whose GameObject or material name contains one + // of Cfg.RecRoyaleNightHideObjects. Runs each apply cycle but only scans + // once per scene (the matches are static scenery); ShowObjects re-enables. + private static readonly List _hidden = new(); + private static bool _hideScanned; + + private static void HideObjects() + { + if (_hideScanned) return; + _hideScanned = true; + try + { + var patterns = new List(); + foreach (var s in Mod.Cfg.RecRoyaleNightHideObjects) + if (!string.IsNullOrWhiteSpace(s)) patterns.Add(s.Trim()); + if (patterns.Count == 0) return; + + var rendererType = Mod.ResolveType("UnityEngine.Renderer"); + var unityObject = Mod.ResolveType("UnityEngine.Object"); + var il2cppRenderer = rendererType is null ? null : Il2CppTypeOf(rendererType); + if (rendererType is null || unityObject is null || il2cppRenderer is null) return; + + MethodInfo? findAll = null; + foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static)) + if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; } + if (findAll is null) return; + + var arr = findAll.Invoke(null, new object[] { il2cppRenderer }); + var n = Count(arr); + var names = new HashSet(); + for (int i = 0; i < n; i++) + { + var item = Item(arr, i); + if (item is null) continue; + var r = AsType(item, rendererType); + if (r is null) continue; + var name = ObjectName(r); + var matName = ""; + try { var mat = GetMember(r, "sharedMaterial"); if (mat is not null && !IsUnityNull(mat)) matName = ObjectName(mat); } catch { } + var hit = false; + foreach (var p in patterns) + if (name.Contains(p, StringComparison.OrdinalIgnoreCase) || matName.Contains(p, StringComparison.OrdinalIgnoreCase)) { hit = true; break; } + if (!hit) continue; + if (GetMember(r, "enabled") is true && SetMember(r, "enabled", false)) + { + _hidden.Add(r); + names.Add(name); + } + } + Mod.Log.Msg($"[royale-night] hid {_hidden.Count} renderer(s) matching [{string.Join(",", patterns)}]: {string.Join(", ", names)}"); + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] HideObjects failed: {(ex.InnerException ?? ex).Message}"); } + } + + private static void ShowObjects() + { + foreach (var r in _hidden) + { + try { if (!IsUnityNull(r)) SetMember(r, "enabled", true); } catch { } + } + _hidden.Clear(); + _hideScanned = false; + } + + // ── CV2 room-settings probe ─────────────────────────────────────── + // The Maker Pen mood chips ("Skydome Constant", "Room Skydome Modify", + // Sun/Fog/Background Objects equivalents) drive RoomSkydome / RoomSun / + // RoomFog components that hang off RoomElementSettingsManager, which + // CircuitsV2Manager brings up. Log whether any of that exists in this + // scene so we know what a chips-in-Rec-Royale build would have to add. + private static bool _probed; + private static void ProbeCircuitsSupport() + { + if (_probed) return; + _probed = true; + try + { + var unityObject = Mod.ResolveType("UnityEngine.Object"); + if (unityObject is null) return; + MethodInfo? findAll = null; + foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static)) + if (m.Name == "FindObjectsOfType" && !m.IsGenericMethodDefinition && m.GetParameters().Length == 1) { findAll = m; break; } + if (findAll is null) return; + + var parts = new List(); + foreach (var name in new[] + { + "RecRoom.CircuitsV2.CircuitsV2Manager", + "RecRoom.CircuitsV2.RoomSettings.RoomElementSettingsManager", + "RecRoom.CircuitsV2.RoomSettings.RoomSkydome", + "RecRoom.CircuitsV2.RoomSettings.RoomSun", + "RecRoom.CircuitsV2.RoomSettings.RoomFog", + "RecRoom.CircuitsV2.RoomSettings.RoomBackgroundObjects", + "MakerPen", + }) + { + var t = Mod.ResolveType(name); + if (t is null) { parts.Add($"{name.Substring(name.LastIndexOf('.') + 1)}="); continue; } + var il2 = Il2CppTypeOf(t); + if (il2 is null) { parts.Add($"{t.Name}=?"); continue; } + int n = 0; + try { n = Count(findAll.Invoke(null, new object[] { il2 })); } catch (Exception ex) { parts.Add($"{t.Name}=err:{(ex.InnerException ?? ex).Message}"); continue; } + parts.Add($"{t.Name}={n}"); + } + Mod.Log.Msg($"[royale-night] CV2 probe in '{_sceneName}': {string.Join(" ", parts)}"); + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] CV2 probe failed: {(ex.InnerException ?? ex).Message}"); } + } + + private static void RestoreOtherSunLights() + { + foreach (var s in _otherSuns) + { + try + { + if (IsUnityNull(s.Light)) continue; + if (s.Intensity is not null) SetMember(s.Light, "intensity", s.Intensity); + if (s.Color is not null) SetMember(s.Light, "color", s.Color); + if (s.ShadowStrength is not null) SetMember(s.Light, "shadowStrength", s.ShadowStrength); + if (s.Rotation is not null && GetMember(s.Light, "transform") is object t) SetMember(t, "rotation", s.Rotation); + } + catch { } + } + _otherSuns.Clear(); + _otherSunsLogged = false; + } + + // Make sure RenderSettings.skybox is a material on the "Custom/SkyBox" + // shader. The apply step assigns MoodConfig.SkyBoxMaterial + // (LNMDIIFOFPD.NHAEHKJINFH → SkyBoxMaterial); in RRO scenes that + // reference resolves to nothing, leaving the skybox null. Prefer any + // already-loaded material on that shader, else create one, and also + // write it into MoodConfig so later applies keep using it. + private static void EnsureMoodSkybox() + { + try + { + if (_renderSkybox is null || _materialType is null || _shaderType is null) return; + + var current = _renderSkybox.GetValue(null); + var currentShader = current is null || IsUnityNull(current) ? "" : ShaderNameOf(current); + if (currentShader == "Custom/SkyBox") return; + + object? mat = _ourSkybox is not null && !IsUnityNull(_ourSkybox) ? _ourSkybox : null; + + // 1. Any loaded material already on the shader (e.g. the real + // Skybox_Moods_Sunny_Default_Mat if some scene brought it in). + if (mat is null) + { + var resources = Mod.ResolveType("UnityEngine.Resources"); + var il2cppMat = Il2CppTypeOf(_materialType); + if (resources is not null && il2cppMat is not null) + { + foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue; + var arr = m.Invoke(null, new object[] { il2cppMat }); + var n = Count(arr); + for (int i = 0; i < n; i++) + { + var item = Item(arr, i); + if (item is null) continue; + var wrapped = AsType(item, _materialType); + if (wrapped is not null && ShaderNameOf(wrapped) == "Custom/SkyBox") { mat = wrapped; break; } + } + break; + } + } + if (mat is not null) Mod.Log.Msg($"[royale-night] using loaded skybox material '{ObjectName(mat)}'"); + } + + // 2. Build one: new Material(Shader.Find("Custom/SkyBox")). + if (mat is null) + { + var find = AccessTools.Method(_shaderType, "Find", new[] { typeof(string) }); + var shader = find?.Invoke(null, new object[] { "Custom/SkyBox" }); + if (shader is null || IsUnityNull(shader)) + { + Mod.Log.Warning("[royale-night] Shader.Find(\"Custom/SkyBox\") returned nothing — sky colours will not apply"); + return; + } + var ctor = _materialType.GetConstructor(new[] { _shaderType }) ?? _materialType.GetConstructor(new[] { shader.GetType() }); + if (ctor is null) + { + Mod.Log.Warning("[royale-night] Material(Shader) constructor not found"); + return; + } + mat = ctor.Invoke(new[] { shader }); + SetMember(mat, "name", "DorkNet_RecRoyaleNight_Skybox"); + Mod.Log.Msg("[royale-night] created skybox material on Custom/SkyBox"); + } + + _ourSkybox = mat; + _renderSkybox.SetValue(null, mat); + + // Keep the engine's own apply path pointing at it too. + if (_moodConfigHolder is not null) + { + foreach (var p in _moodConfigHolder.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) + { + if (!p.CanRead || p.GetIndexParameters().Length != 0) continue; + var cfg = p.GetValue(null); + if (cfg is null || IsUnityNull(cfg)) continue; + if (SetMember(cfg, "SkyBoxMaterial", mat)) Mod.Log.Msg("[royale-night] MoodConfig.SkyBoxMaterial ← our material"); + break; + } + } + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsureMoodSkybox failed: {(ex.InnerException ?? ex).Message}"); } + } + + private static string ShaderNameOf(object material) + { + try + { + var shader = GetMember(material, "shader"); + return shader is null || IsUnityNull(shader) ? "" : ObjectName(shader); + } + catch { return ""; } + } + + private static string DescribeSkybox() + { + try + { + var sky = _renderSkybox?.GetValue(null); + if (sky is null || IsUnityNull(sky)) return ""; + return $"'{ObjectName(sky)}' [{ShaderNameOf(sky)}]"; + } + catch { return "?"; } + } + + // Post-exposure lives in the URP Volume the mood engine drives. It only + // shows if the Volume is enabled with weight and the camera renders + // post-processing (Rec Room's graphics settings can turn that off). + private static void EnsurePostProcessing(object moods) + { + try + { + var volume = Read(_volume, moods); + if (volume is not null && !IsUnityNull(volume)) + { + var enabled = GetMember(volume, "enabled"); + var weight = GetMember(volume, "weight"); + var isGlobal = GetMember(volume, "isGlobal"); + var profile = GetMember(volume, "profile"); + Mod.Log.Msg($"[royale-night] volume: enabled={enabled} weight={weight} global={isGlobal} profile={(profile is null ? "" : ObjectName(profile))}"); + if (Mod.Cfg.RecRoyaleNightForcePostProcessing) + { + if (enabled is false) SetMember(volume, "enabled", true); + if (weight is float w && w < 1f) SetMember(volume, "weight", 1f); + } + } + else Mod.Log.Warning("[royale-night] scene mood Volume is null — post-exposure darkening unavailable"); + + if (_cameraDataType is null) { Mod.Log.Warning("[royale-night] UniversalAdditionalCameraData type not found"); return; } + var unityObject = Mod.ResolveType("UnityEngine.Object"); + var il2cppCam = Il2CppTypeOf(_cameraDataType); + if (unityObject is null || il2cppCam is null) return; + foreach (var m in unityObject.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name != "FindObjectsOfType" || m.IsGenericMethodDefinition || m.GetParameters().Length != 1) continue; + var arr = m.Invoke(null, new object[] { il2cppCam }); + var n = Count(arr); + int forced = 0; + var states = new List(); + for (int i = 0; i < n; i++) + { + var item = Item(arr, i); + if (item is null) continue; + var cam = AsType(item, _cameraDataType); + if (cam is null) continue; + var pp = GetMember(cam, "renderPostProcessing"); + states.Add($"{ObjectName(cam)}={pp}"); + if (pp is false && Mod.Cfg.RecRoyaleNightForcePostProcessing && SetMember(cam, "renderPostProcessing", true)) forced++; + } + Mod.Log.Msg($"[royale-night] camera post-processing: {string.Join(", ", states)}{(forced > 0 ? $" → forced on ({forced})" : "")}"); + break; + } + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] EnsurePostProcessing failed: {(ex.InnerException ?? ex).Message}"); } + } + + // Darken the mood data in place before it is applied: + // ColorGradingSettings.exposure += RecRoyaleNightExposure (EV; the + // ColorGrading blender writes it into ColorAdjustments.postExposure + // via VolumeParameter.Override — the only knob that also dims baked + // lightmaps, since it's a full-frame post effect) + // SunSettings.sunIntensity *= RecRoyaleNightSunScale + // SkyBoxSettings.ambientLightIntensity *= RecRoyaleNightAmbientScale + // Originals are kept and restored by RestoreDarkness so the shared asset + // is clean again for any custom room that uses the same mood. + private static void PatchDarkness(object mood) + { + try + { + if (ReferenceEquals(_darkPatchedMood, mood)) return; + RestoreDarkness(); + + var grading = GetMember(mood, "ColorGradingSettings"); + var sun = GetMember(mood, "SunSettings"); + var sky = GetMember(mood, "SkyBoxSettings"); + + if (grading is not null && Mod.Cfg.RecRoyaleNightExposure != 0f && GetMember(grading, "exposure") is float ev) + { + _origExposure = ev; + SetMember(grading, "exposure", ev + Mod.Cfg.RecRoyaleNightExposure); + } + if (sun is not null && Mod.Cfg.RecRoyaleNightSunScale != 1f && GetMember(sun, "sunIntensity") is float si) + { + _origSunIntensity = si; + SetMember(sun, "sunIntensity", si * Mod.Cfg.RecRoyaleNightSunScale); + } + if (sky is not null && Mod.Cfg.RecRoyaleNightAmbientScale != 1f && GetMember(sky, "ambientLightIntensity") is float ai) + { + _origAmbient = ai; + SetMember(sky, "ambientLightIntensity", ai * Mod.Cfg.RecRoyaleNightAmbientScale); + } + _darkPatchedMood = mood; + Mod.Log.Msg($"[royale-night] darkened mood: exposure {_origExposure?.ToString("0.##") ?? "?"}→{(_origExposure + Mod.Cfg.RecRoyaleNightExposure)?.ToString("0.##") ?? "?"} EV, " + + $"sun ×{Mod.Cfg.RecRoyaleNightSunScale} ({_origSunIntensity?.ToString("0.##") ?? "?"}), ambient ×{Mod.Cfg.RecRoyaleNightAmbientScale} ({_origAmbient?.ToString("0.##") ?? "?"})"); + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] PatchDarkness failed: {(ex.InnerException ?? ex).Message}"); } + } + + private static void RestoreDarkness() + { + try + { + if (_darkPatchedMood is not null) + { + var grading = GetMember(_darkPatchedMood, "ColorGradingSettings"); + var sun = GetMember(_darkPatchedMood, "SunSettings"); + var sky = GetMember(_darkPatchedMood, "SkyBoxSettings"); + if (grading is not null && _origExposure is float e) SetMember(grading, "exposure", e); + if (sun is not null && _origSunIntensity is float s) SetMember(sun, "sunIntensity", s); + if (sky is not null && _origAmbient is float a) SetMember(sky, "ambientLightIntensity", a); + } + } + catch { } + _darkPatchedMood = null; + _origExposure = _origSunIntensity = _origAmbient = null; + } + + private static bool SetMember(object o, string name, object value) + { + var t = o.GetType(); + var p = t.GetProperty(name, Any); + if (p is not null && p.CanWrite) { try { p.SetValue(o, value); return true; } catch { } } + var f = t.GetField(name, Any); + if (f is not null) { try { f.SetValue(o, value); return true; } catch { } } + return false; + } + + // Il2CppInterop exposes il2cpp fields as properties (and, on some + // versions, as fields). Read either. + private static object? GetMember(object o, string name) + { + var t = o.GetType(); + var p = t.GetProperty(name, Any); + if (p is not null && p.CanRead && p.GetIndexParameters().Length == 0) + { try { return p.GetValue(o); } catch { } } + var f = t.GetField(name, Any); + if (f is not null) { try { return f.GetValue(o); } catch { } } + return null; + } + + // Il2CppType.From(Type) → Il2CppSystem.Type, via reflection so this + // project keeps not referencing Il2Cppmscorlib at compile time. + private static object? Il2CppTypeOf(Type type) + { + foreach (var m in typeof(Il2CppType).GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name != "From" || m.IsGenericMethodDefinition) continue; + var ps = m.GetParameters(); + if (ps.Length >= 1 && ps[0].ParameterType == typeof(Type)) + { + var args = new object?[ps.Length]; + args[0] = type; + for (int i = 1; i < ps.Length; i++) args[i] = ps[i].HasDefaultValue ? ps[i].DefaultValue : (ps[i].ParameterType.IsValueType ? Activator.CreateInstance(ps[i].ParameterType) : null); + var r = m.Invoke(null, args); + if (r is not null) return r; + } + } + return null; + } + + private static object? GetSceneManager() + { + var inst = _sceneManagerInstance?.GetValue(null); + if (inst is null || IsUnityNull(inst)) return null; + return inst; + } + + // Loads the configured MoodSetting asset. Resources.Load(path, Type) + // pulls it out of resources.assets by name (the ResourceManager table + // keys are the lower-cased asset names, no folder). Fallback: + // Resources.FindObjectsOfTypeAll(MoodSetting) by name, which only sees + // assets already in memory. + private static object? GetNightMood() + { + var wanted = (Mod.Cfg.RecRoyaleNightMood ?? "").Trim(); + if (wanted.Length == 0) wanted = "Night_Calm_Outdoor_Mood"; + if (_nightMood is not null && _nightMoodLoadedName == wanted && !IsUnityNull(_nightMood)) + return _nightMood; + + _nightMood = null; + var resources = Mod.ResolveType("UnityEngine.Resources"); + if (resources is null || _moodSettingType is null) + { + Mod.Log.Warning("[royale-night] UnityEngine.Resources not found"); + return null; + } + + var il2cppType = Il2CppTypeOf(_moodSettingType); + if (il2cppType is null) + { + Mod.Log.Warning("[royale-night] Il2CppType.From(Type) not found"); + return null; + } + object? found = null; + + foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name != "Load" || m.IsGenericMethodDefinition) continue; + var ps = m.GetParameters(); + if (ps.Length != 2 || ps[0].ParameterType != typeof(string)) continue; + try + { + found = m.Invoke(null, new object[] { wanted, il2cppType }); + if (found is not null && !IsUnityNull(found)) break; + found = null; + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] Resources.Load failed: {(ex.InnerException ?? ex).Message}"); } + } + + if (found is null) + { + foreach (var m in resources.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name != "FindObjectsOfTypeAll" || m.IsGenericMethodDefinition) continue; + var ps = m.GetParameters(); + if (ps.Length != 1) continue; + try + { + var arr = m.Invoke(null, new object[] { il2cppType }); + var n = Count(arr); + var names = new List(); + for (int i = 0; i < n; i++) + { + var item = Item(arr, i); + if (item is null) continue; + var name = ObjectName(item); + names.Add(name); + if (string.Equals(name, wanted, StringComparison.OrdinalIgnoreCase)) { found = item; break; } + } + if (found is null) + Mod.Log.Warning($"[royale-night] mood '{wanted}' not in memory; loaded moods: {string.Join(", ", names)}"); + } + catch (Exception ex) { Mod.Log.Warning($"[royale-night] FindObjectsOfTypeAll failed: {(ex.InnerException ?? ex).Message}"); } + break; + } + } + + if (found is null) + { + Mod.Log.Warning($"[royale-night] could not load MoodSetting '{wanted}'"); + return null; + } + + // Resources.Load hands back a UnityEngine.Object wrapper; re-wrap the + // same il2cpp pointer as MoodSetting so the apply method accepts it. + _nightMood = AsType(found, _moodSettingType); + _nightMoodLoadedName = wanted; + return _nightMood; + } + + private static object? AsType(object obj, Type target) + { + if (target.IsInstanceOfType(obj)) return obj; + if (obj is Il2CppObjectBase il2cpp) + { + var ctor = target.GetConstructor(new[] { typeof(IntPtr) }); + if (ctor is not null) return ctor.Invoke(new object[] { il2cpp.Pointer }); + } + return obj; + } + + // ── hotkey ──────────────────────────────────────────────────────── + private static void PollHotkey() + { + var key = Mod.Cfg.RecRoyaleNightToggleKey; + if (string.IsNullOrWhiteSpace(key)) return; + try + { + if (!_inputResolved) ResolveInput(key); + if (_getKeyDown is null || _toggleKey is null) return; + if (_getKeyDown.Invoke(null, new[] { _toggleKey }) is true) Toggle(); + } + catch (Exception ex) + { + Mod.Log.Warning($"[royale-night] hotkey poll failed: {ex.Message}"); + _getKeyDown = null; // stop spamming + } + } + + private static void ResolveInput(string key) + { + _inputResolved = true; + var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode"); + var inputType = Mod.ResolveType("UnityEngine.Input"); + if (keyCodeType is null || inputType is null) + { + Mod.Log.Warning("[royale-night] UnityEngine.Input/KeyCode not found — hotkey disabled"); + return; + } + try { _toggleKey = Enum.Parse(keyCodeType, key, ignoreCase: true); } + catch + { + Mod.Log.Warning($"[royale-night] '{key}' is not a UnityEngine.KeyCode — hotkey disabled"); + return; + } + _getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType }); + if (_getKeyDown is null) Mod.Log.Warning("[royale-night] Input.GetKeyDown(KeyCode) not found — hotkey disabled"); + } + + // ── helpers ─────────────────────────────────────────────────────── + private static bool MatchesScene(string sceneName) + { + if (string.IsNullOrEmpty(sceneName)) return false; + foreach (var s in Mod.Cfg.RecRoyaleNightScenes) + { + if (string.IsNullOrWhiteSpace(s)) continue; + if (sceneName.Contains(s.Trim(), StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + // UnityEngine.Object wrappers are non-null C# objects even when the + // native object is destroyed/missing; `== null` is overloaded in the + // interop, so route through the static op_Equality when present. + private static bool IsUnityNull(object obj) + { + try + { + var t = obj.GetType(); + var eq = t.GetMethod("op_Equality", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy); + if (eq is not null && eq.GetParameters().Length == 2) + return eq.Invoke(null, new[] { obj, null }) is true; + } + catch { } + return false; + } + + private static string ObjectName(object obj) + { + try + { + var p = obj.GetType().GetProperty("name", Any); + return p?.GetValue(obj) as string ?? ""; + } + catch { return ""; } + } + + private static string Describe(object mood) + { + var friendly = ""; + try { friendly = _friendlyName?.GetValue(mood) as string ?? ""; } catch { } + var name = ObjectName(mood); + return friendly.Length > 0 ? $"{friendly} ({name})" : name; + } + + private static int Count(object? arr) + { + if (arr is null) return 0; + if (arr is Array a) return a.Length; + var p = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count"); + return (p?.GetValue(arr) as int?) ?? 0; + } + + private static object? Item(object? arr, int i) + { + if (arr is null) return null; + if (arr is Array a) return a.GetValue(i); + var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) }); + return gi?.Invoke(arr, new object[] { i }); + } +} diff --git a/DorkNet.ClientMod/dorknet-clientmod.json.template b/DorkNet.ClientMod/dorknet-clientmod.json.template index a4224ee..f3622c7 100644 --- a/DorkNet.ClientMod/dorknet-clientmod.json.template +++ b/DorkNet.ClientMod/dorknet-clientmod.json.template @@ -11,5 +11,18 @@ "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" - ] + ], + "RecRoyaleNightMode": false, + "RecRoyaleNightMood": "Night_Calm_Outdoor_Mood", + "RecRoyaleNightScenes": ["RecRoyale"], + "RecRoyaleNightFlashlights": true, + "RecRoyaleNightFog": false, + "RecRoyaleNightExposure": -3.5, + "RecRoyaleNightSunScale": 0.15, + "RecRoyaleNightAmbientScale": 0.2, + "RecRoyaleNightForcePostProcessing": true, + "RecRoyaleNightMatchSunLights": true, + "RecRoyaleNightDisableFog": false, + "RecRoyaleNightHideObjects": ["Cloud"], + "RecRoyaleNightToggleKey": "" } diff --git a/DorkNet.Models/Auth/RecNetResult.cs b/DorkNet.Models/Auth/RecNetResult.cs index b979a1d..1ac43da 100644 --- a/DorkNet.Models/Auth/RecNetResult.cs +++ b/DorkNet.Models/Auth/RecNetResult.cs @@ -88,6 +88,36 @@ public class RecNetAccount // Missing → LitJson throws and 2020.12 surfaces "Malformed Response". [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // 2023-03-21 account reader (RecNet.Runtime CICAKEDCJLA / MJKDDBKGNOP / + // AEGCKMKICFG) registers these keys in three casings each, e.g. + // "DisplayEmoji"/"displayEmoji"/"displayemoji" (CICAKEDCJLA.txt:1398-1550). + // Missing keys default on the client, which is why profile emoji, + // banner, pronouns and identity flags never rendered for anyone. + private string displayEmoji = string.Empty; + private string bannerImage = string.Empty; + + [JsonPropertyName("displayEmoji")] + public string DisplayEmoji + { + get => displayEmoji; + set => displayEmoji = value ?? string.Empty; + } + + [JsonPropertyName("bannerImage")] + public string BannerImage + { + get => bannerImage; + set => bannerImage = value ?? string.Empty; + } + + /// Pronoun flags enum, serialized as its Int32 value. + [JsonPropertyName("personalPronouns")] + public int PersonalPronouns { get; set; } + + /// Identity flags enum, serialized as its Int32 value. + [JsonPropertyName("identityFlags")] + public int IdentityFlags { get; set; } } /// @@ -112,6 +142,13 @@ public class RecNetSelfAccount : RecNetAccount [JsonPropertyName("parentAccountId")] public int? ParentAccountId { get; set; } = null; + + // 2023 self-account reader key (CICAKEDCJLA.txt:1310). An absent key + // defaults to 0, which the settings UI renders as "no username changes + // left". DorkNet does not rate-limit username changes, so report a + // generous allowance. + [JsonPropertyName("availableUsernameChanges")] + public int AvailableUsernameChanges { get; set; } = 99; } /// diff --git a/DorkNet.Server.Tests/CircuitsV2LimitsTests.cs b/DorkNet.Server.Tests/CircuitsV2LimitsTests.cs new file mode 100644 index 0000000..4cd9014 --- /dev/null +++ b/DorkNet.Server.Tests/CircuitsV2LimitsTests.cs @@ -0,0 +1,254 @@ +using DorkNet.Server.Services; +using System.Net.Http.Json; +using System.Text.Json; + +namespace DorkNet.Server.Tests; + +/// +/// The March-2023 client budgets every CircuitsV2 network send against +/// RecRoom.CircuitsV2.Config.LimitConfig, which it fills from the +/// UGC.CircuitsV2.Limit.* gameconfig keys. Retail ships 5 RPCs per +/// frame; past that the send is dropped and the room save is flagged +/// cv2_rpc_limited. DorkNet serves budgets that lift the cap, and +/// these tests pin both the wire shape the client's Newtonsoft reader +/// binds and the clamps that keep a hand-edited value from crashing it. +/// +public sealed class CircuitsV2LimitsTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public CircuitsV2LimitsTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Gameconfigs_endpoint_serves_the_network_budget() + { + using var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + + var rows = await client.GetFromJsonAsync>( + "api/gameconfigs/v1/all"); + + var network = rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Network"); + var entry = JsonDocument.Parse(network.Value).RootElement.EnumerateArray().Single(); + Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000); + Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32()); + // A server that never saved limits (empty column) serves the + // current defaults, not the retail values. + Assert.Equal("30000", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Chips").Value); + Assert.Equal("0", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.InkPerChip").Value); + } + + private static IReadOnlyList Emit(CircuitsV2LimitSettings limits) => + ServerSettingsService.ToGameConfigurations( + ServerSettingsService.DefaultRecCenterDoors(), + ServerSettingsService.DefaultDiscoveredGameConfigs(), + limits); + + private static string ValueOf(IReadOnlyList rows, string key) => + rows.Single(r => r.Key == key).Value; + + private static JsonElement NetworkOf(CircuitsV2LimitSettings limits) => + JsonDocument.Parse(ValueOf(Emit(limits), "UGC.CircuitsV2.Limit.Network")).RootElement; + + [Fact] + public void Defaults_lift_the_rpc_and_byte_budgets() + { + var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits()); + + Assert.Equal(JsonValueKind.Array, network.ValueKind); + var entry = Assert.Single(network.EnumerateArray()); + // Retail is 5 RPCs / 1024 bytes; anything at those values means the + // budget never actually got lifted. + Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000); + Assert.True(entry.GetProperty("Bytes").GetInt32() >= 1_000_000); + Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32()); + } + + [Fact] + public void Network_json_uses_the_field_names_the_client_binds() + { + var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits()) + .EnumerateArray().First(); + + // LimitConfig.NetworkConfig's il2cpp field names, verbatim. + Assert.True(entry.TryGetProperty("NumPlayersForActivation", out _)); + Assert.True(entry.TryGetProperty("Bytes", out _)); + Assert.True(entry.TryGetProperty("BytesTimeWindowFrame60s", out _)); + Assert.True(entry.TryGetProperty("Rpcs", out _)); + Assert.True(entry.TryGetProperty("RpcTimeWindowFrame60s", out _)); + } + + [Fact] + public void Scalar_limits_serialize_invariant_without_separators() + { + var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with + { + Chips = 123456, + CloudValuesAutosaveSeconds = 300f, + }); + + // The client parses these with int/float.TryParse under + // InvariantCulture, so a grouped or comma-decimal value silently + // falls back to its baked default. + Assert.Equal("123456", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips")); + Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds")); + Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute")); + Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip")); + } + + [Fact] + public void Defaults_raise_the_room_size_limits_past_retail() + { + var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits()); + + // Retail: Compute 50000, Chips 3000, CloudValues 100, + // Unculled/VisibleChips 500, InkPerChip 15. + Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute")); + Assert.Equal("30000", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips")); + Assert.Equal("1000", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues")); + Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.UnculledChips")); + Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.VisibleChips")); + // InkPerChip is a cost, so the lift is downward. + Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip")); + // A save cadence, not a capacity limit: left at retail. + Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds")); + } + + [Fact] + public void Default_visible_chips_do_not_exceed_the_chip_cap() + { + var limits = ServerSettingsService.DefaultCircuitsV2Limits(); + + // The add-chip path only charges InkPerChip when Chips < VisibleChips + // (CircuitsV2Manager_NestedType_AMEJFMPHNEO.txt ISIL 228-259), so the + // defaults keep that path off as well as zeroing the cost. + Assert.True(limits.VisibleChips <= limits.Chips); + Assert.True(limits.UnculledChips <= limits.Chips); + } + + [Fact] + public void Ink_cost_and_cloud_values_are_capped_so_their_product_fits_an_int() + { + var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with + { + InkPerChip = int.MaxValue, + CloudValues = int.MaxValue, + }); + + // The client computes InkPerChip * Math.Min(numChips, CloudValues) + // in plain int; InkPerChip past the hard-coded 30000-ink room cap + // also makes a single chip unaffordable. + var ink = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip")); + var cloud = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues")); + Assert.Equal(30_000, ink); + Assert.Equal(10_000, cloud); + Assert.True((long)ink * cloud < int.MaxValue / 2); + } + + [Fact] + public void Negative_ink_cost_is_floored_at_zero() + { + var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { InkPerChip = -5 }); + + Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip")); + } + + [Fact] + public void Chips_are_bounded_because_the_culling_manager_preallocates_by_them() + { + var low = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = 0 }); + var high = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = int.MaxValue }); + + // Four List(Chips) at culling-manager construction: 0 or less + // throws, and int.MaxValue is an out-of-memory kill. + Assert.Equal("1", ValueOf(low, "UGC.CircuitsV2.Limit.Chips")); + Assert.Equal("1000000", ValueOf(high, "UGC.CircuitsV2.Limit.Chips")); + } + + [Fact] + public void Compute_stays_finite_because_it_is_the_runaway_loop_guard() + { + var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Compute = int.MaxValue }); + + Assert.Equal("10000000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute")); + } + + [Fact] + public void Zero_rpcs_is_clamped_because_the_debug_subscreen_divides_by_it() + { + var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with + { + Network = [new(0, 1024, 60, Rpcs: 0, RpcTimeWindowFrame60s: 1)], + }).EnumerateArray().First(); + + Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1); + } + + [Fact] + public void Time_windows_are_capped_so_the_ring_buffer_stays_small() + { + var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with + { + Network = [new(0, 1024, int.MaxValue, 5, int.MaxValue)], + }).EnumerateArray().First(); + + // Each window frame is one int in a client-side ring buffer, so an + // unbounded window is an out-of-memory kill at circuit init. + Assert.Equal(3600, entry.GetProperty("RpcTimeWindowFrame60s").GetInt32()); + Assert.Equal(3600, entry.GetProperty("BytesTimeWindowFrame60s").GetInt32()); + } + + [Fact] + public void Lowest_entry_is_forced_to_activate_at_zero_players() + { + var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with + { + Network = + [ + new(20, 4096, 60, 50, 1), + new(8, 2048, 60, 25, 1), + ], + }); + + // Without an entry at 0 the client rejects the whole array and + // falls back to the retail 5-RPC budget. + var entries = network.EnumerateArray().ToList(); + Assert.Equal(0, entries[0].GetProperty("NumPlayersForActivation").GetInt32()); + // The rewrite keeps that entry's own budget. + Assert.Equal(25, entries[0].GetProperty("Rpcs").GetInt32()); + Assert.Equal(20, entries[1].GetProperty("NumPlayersForActivation").GetInt32()); + } + + [Fact] + public void Duplicate_thresholds_collapse_and_entries_sort_ascending() + { + var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with + { + Network = + [ + new(16, 4096, 60, 50, 1), + new(0, 1024, 60, 10, 1), + new(16, 8192, 60, 99, 1), + ], + }); + + var thresholds = network.EnumerateArray() + .Select(e => e.GetProperty("NumPlayersForActivation").GetInt32()) + .ToList(); + Assert.Equal([0, 16], thresholds); + } + + [Fact] + public void Empty_network_list_falls_back_to_the_default_budget() + { + var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with + { + Network = [], + }); + + var entry = Assert.Single(network.EnumerateArray()); + Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32()); + Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000); + } +} diff --git a/DorkNet.Server.Tests/GameClientEndpointContractTests.cs b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs index 3cde1df..b6be662 100644 --- a/DorkNet.Server.Tests/GameClientEndpointContractTests.cs +++ b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs @@ -66,8 +66,13 @@ public sealed class GameClientEndpointContractTests : if (!string.Equals(route.Method, "DELETE", StringComparison.OrdinalIgnoreCase)) return false; + // Case-insensitive to match the method comparison above: a route + // declared "Account/v1/me" would otherwise slip through and delete + // the probe player mid-run. var template = route.Template.Trim('/'); - return template is "account/me" or "account/v1/me" or "account/v2/me"; + return template.Equals("account/me", StringComparison.OrdinalIgnoreCase) + || template.Equals("account/v1/me", StringComparison.OrdinalIgnoreCase) + || template.Equals("account/v2/me", StringComparison.OrdinalIgnoreCase); } private static async Task ProbeAsync( diff --git a/DorkNet.Server.Tests/IncentivizedReferralsTests.cs b/DorkNet.Server.Tests/IncentivizedReferralsTests.cs new file mode 100644 index 0000000..1358912 --- /dev/null +++ b/DorkNet.Server.Tests/IncentivizedReferralsTests.cs @@ -0,0 +1,80 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using Microsoft.Extensions.DependencyInjection; + +namespace DorkNet.Server.Tests; + +/// +/// GET api/incentivizedreferrals/referrals?take=&continuationToken= is +/// assembled by the client's URL builder (RecNet.Runtime/LINKIECPDML.txt:677-737), +/// so it never showed up as a route literal and had no handler. The reply is the +/// GPJIMAHMAIO page: {Referrals:[{InviteeAccountId, CreatedAt, VerifiedAt}], +/// ContinuationToken} (KOEEACLMAFE.txt:203-238, ADAFNGNADFH.txt:279-346). +/// +public sealed class IncentivizedReferralsTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public IncentivizedReferralsTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Referrals_page_lists_credited_invitees_with_a_continuation_token() + { + using var setup = Client(); + var referrer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var inviteeA = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var inviteeB = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + // Same row LinkController.CreditReferralAsync writes on the invitee. + foreach (var invitee in new[] { inviteeA, inviteeB }) + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = invitee.PlayerId, + Key = $"referral:credited:{referrer.PlayerId}:{invitee.PlayerId}", + Value = DateTime.UtcNow.ToString("O"), + }); + await db.SaveChangesAsync(); + } + + using var client = Client(referrer); + var first = JsonDocument.Parse( + await client.GetStringAsync("/api/incentivizedreferrals/referrals?take=1")).RootElement; + var firstPage = Get(first, "Referrals"); + Assert.Equal(1, firstPage.GetArrayLength()); + var token = Get(first, "ContinuationToken").GetString(); + Assert.False(string.IsNullOrEmpty(token)); + + var second = JsonDocument.Parse(await client.GetStringAsync( + $"/api/incentivizedreferrals/referrals?take=1&continuationToken={token}")).RootElement; + var secondPage = Get(second, "Referrals"); + Assert.Equal(1, secondPage.GetArrayLength()); + Assert.Equal(JsonValueKind.Null, Get(second, "ContinuationToken").ValueKind); + + var ids = new[] { firstPage[0], secondPage[0] } + .Select(e => Get(e, "InviteeAccountId").GetInt64()) + .OrderBy(x => x) + .ToArray(); + Assert.Equal(new[] { inviteeA.PlayerId, inviteeB.PlayerId }.OrderBy(x => x), ids); + // CreatedAt is a non-nullable DateTime on the client: it must be present. + Assert.Equal(JsonValueKind.String, Get(firstPage[0], "CreatedAt").ValueKind); + } + + // The client formatter registers PascalCase, camelCase and lowercase. + private static JsonElement Get(JsonElement e, string pascal) => + e.TryGetProperty(pascal, out var v) ? v : e.GetProperty(char.ToLowerInvariant(pascal[0]) + pascal[1..]); + + private HttpClient Client(GameClientSession? session = null) + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } +} diff --git a/DorkNet.Server.Tests/OutfitMenu2023Tests.cs b/DorkNet.Server.Tests/OutfitMenu2023Tests.cs new file mode 100644 index 0000000..76b404b --- /dev/null +++ b/DorkNet.Server.Tests/OutfitMenu2023Tests.cs @@ -0,0 +1,222 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using DorkNet.Server.Controllers.API.Avatar.V4; +using DorkNet.Server.Data; +using DorkNet.Server.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DorkNet.Server.Tests; + +/// +/// Wire contract of the 2023-03-21 client's outfit / wardrobe menu, as read +/// from RecNet.Runtime ISIL: saved-outfit slots (BEPMJICDNDD via converter +/// IMMOJHBENFI), the custom-avatar-item gates the menu queries when it opens, +/// the locked-item lookup, the owned list's per-entry ids, and the gift +/// generate call the menu's gift flow makes. +/// +public sealed class OutfitMenu2023Tests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public OutfitMenu2023Tests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Creation_allowed_gate_is_a_success_error_envelope() + { + // MPBLNLMCEDL.GGMDLMLJDDK parses DJMHAFPGLLN {Success, Error} + // (LMCCNLLHLCJ.txt:191-254); a bare boolean is what logged + // "Failed to query if local account can create Custom Avatar Items". + using var setup = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + using var client = ApiClient(player); + + var body = await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationAllowedForAccount"); + Assert.Equal(JsonValueKind.Object, body.ValueKind); + Assert.Equal(JsonValueKind.True, body.GetProperty("Success").ValueKind); + Assert.Equal(JsonValueKind.Null, body.GetProperty("Error").ValueKind); + } + + [Fact] + public async Task Sibling_gates_and_min_price_are_bare_scalars() + { + using var client = ApiClient(); + Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationEnabled")).ValueKind); + Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isRenderingEnabled")).ValueKind); + + // b__69_0(Int32 minimumPrice) — a bare integer. + var min = await GetJsonAsync(client, "/api/customAvatarItems/v1/minPriceForPublicItem"); + Assert.Equal(JsonValueKind.Number, min.ValueKind); + Assert.True(min.GetInt32() > 0); + } + + [Fact] + public async Task Saved_outfit_round_trips_every_client_key() + { + using var setup = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + using var client = ApiClient(player); + + var shirt = Guid.NewGuid(); + // Exactly what the client's writer (IMMOJHBENFI.txt:781-910) emits. + var outfit = new Dictionary + { + ["Slot"] = 3, + ["PreviewImageName"] = "preview-3.jpg", + ["OutfitSelections"] = "", + ["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"a\",\"b\"]}", + ["FaceFeatures"] = "{\"ver\":3}", + ["SkinColor"] = "skin-guid", + ["HairColor"] = "hair-guid", + ["CustomAvatarItems"] = new[] { new { CustomAvatarItemId = shirt, BodyPart = 2 } }, + }; + await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit); + + var list = await GetJsonAsync(client, "/api/avatar/v3/saved"); + var saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3); + Assert.Equal("preview-3.jpg", saved.GetProperty("PreviewImageName").GetString()); + Assert.Equal("{\"v\":2,\"items\":[\"a\",\"b\"]}", saved.GetProperty("OutfitSelectionsV2").GetString()); + Assert.Equal("{\"ver\":3}", saved.GetProperty("FaceFeatures").GetString()); + Assert.Equal("skin-guid", saved.GetProperty("SkinColor").GetString()); + Assert.Equal("hair-guid", saved.GetProperty("HairColor").GetString()); + var items = saved.GetProperty("CustomAvatarItems"); + Assert.Equal(1, items.GetArrayLength()); + Assert.Equal(shirt, items[0].GetProperty("CustomAvatarItemId").GetGuid()); + Assert.Equal(2, items[0].GetProperty("BodyPart").GetInt32()); + + // Overwriting the slot replaces it rather than adding a second row. + outfit["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"c\"]}"; + outfit["CustomAvatarItems"] = Array.Empty(); + await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit); + list = await GetJsonAsync(client, "/api/avatar/v3/saved"); + saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3); + Assert.Equal("{\"v\":2,\"items\":[\"c\"]}", saved.GetProperty("OutfitSelectionsV2").GetString()); + Assert.Equal(0, saved.GetProperty("CustomAvatarItems").GetArrayLength()); + } + + [Fact] + public async Task Gift_generate_accepts_the_clients_form_body() + { + // Live trace: this exact form body was answered 415 under [FromBody]. + using var setup = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + using var client = ApiClient(player); + + using var response = await client.PostAsync("/api/avatar/v2/gifts/generate", + new FormUrlEncodedContent(new Dictionary + { + ["GiftContext"] = "12000", + ["IsGameGift"] = "True", + ["Message"] = "Activity completed!", + })); + Assert.NotEqual(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + response.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.True(doc.RootElement.GetProperty("Id").GetInt64() > 0); + } + + [Fact] + public async Task Locked_items_carry_the_full_owned_item_shape() + { + using var setup = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + using var client = ApiClient(player); + + HashSet owned; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var inv = await db.Avatars.Where(a => a.PlayerId == player.PlayerId) + .Select(a => a.InventoryJson).FirstAsync(); + owned = (JsonSerializer.Deserialize>(inv) ?? new()) + .Select(d => d.Replace("wardrobe-", "").Split(',')[0]) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + var notOwned = AvatarItemsController.Catalog.Keys + .Where(g => Guid.TryParse(g, out _) && !owned.Contains(g)).Take(3).ToList(); + Assert.Equal(3, notOwned.Count); + + var query = string.Join("&", notOwned.Select(g => "desc=" + Uri.EscapeDataString(g + ",,,"))); + var rows = await GetJsonAsync(client, "/api/avatar/v1/lockeditems?" + query); + Assert.Equal(3, rows.GetArrayLength()); + var ids = new List(); + foreach (var row in rows.EnumerateArray()) + { + // Same IFNGBPJGINL keys v4/items carries (CLIANCNHNIE reader). + Assert.Equal(JsonValueKind.String, row.GetProperty("TagList").ValueKind); + Assert.True(row.GetProperty("IsBaseAvatarItem").ValueKind is JsonValueKind.True or JsonValueKind.False); + ids.Add(row.GetProperty("AvatarItemId").GetInt32()); + } + Assert.DoesNotContain(0, ids); + Assert.Equal(ids.Count, ids.Distinct().Count()); + } + + [Fact] + public async Task Owning_a_base_item_and_its_colorways_yields_unique_ids() + { + using var setup = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + // Two colorways of one base item that the runtime catalog knows. + var group = StoreService.ColorVariantCatalog.Values + .Where(v => !string.IsNullOrWhiteSpace(v.descriptor) + && AvatarItemsController.Catalog.ContainsKey(v.item_guid)) + .GroupBy(v => v.item_guid, StringComparer.OrdinalIgnoreCase) + .First(g => g.Select(v => v.descriptor).Distinct().Count() >= 2); + var baseGuid = group.Key; + var variants = group.Select(v => v.descriptor).Distinct().Take(2).ToList(); + + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId); + var inv = JsonSerializer.Deserialize>(avatar.InventoryJson) ?? new(); + inv.Add(baseGuid); + inv.AddRange(variants); + avatar.InventoryJson = JsonSerializer.Serialize(inv); + await db.SaveChangesAsync(); + } + + using var client = ApiClient(player); + var items = await GetJsonAsync(client, "/api/avatar/v4/items"); + var sameBase = items.EnumerateArray() + .Where(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0] + .Equals(baseGuid, StringComparison.OrdinalIgnoreCase)) + .ToList(); + Assert.Equal(3, sameBase.Count); + + var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList(); + Assert.DoesNotContain(0, ids); + Assert.Equal(ids.Count, ids.Distinct().Count()); + } + + private HttpClient ApiClient(GameClientSession? session = null) + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } + + private static async Task PostJsonAsync(HttpClient client, string path, object body) + { + using var response = await client.PostAsync(path, + new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json")); + response.EnsureSuccessStatusCode(); + } + + private static async Task GetJsonAsync(HttpClient client, string path) + { + using var response = await client.GetAsync(path); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return document.RootElement.Clone(); + } +} diff --git a/DorkNet.Server.Tests/PresenceTransitionTests.cs b/DorkNet.Server.Tests/PresenceTransitionTests.cs new file mode 100644 index 0000000..a88ef45 --- /dev/null +++ b/DorkNet.Server.Tests/PresenceTransitionTests.cs @@ -0,0 +1,289 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using DorkNet.Server.Controllers.Match; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Tests; + +/// +/// Presence must follow the player through every transition, for the player +/// themselves (the heartbeat) and for everyone else (bulk /player and the +/// PresenceUpdate push). +/// +/// The heartbeat side is decided by the client's compare +/// (RecNet.Runtime/RecNet/Matchmaking.txt:18415-18467): a cached instance with +/// a negative id — the offline room — is in sync ONLY with roomInstance null, +/// and anything else makes the client cancel what it is doing and chase the +/// server's room. matchmake/none is how the client tells us it went to that +/// offline room, so after it the heartbeat must stay null. +/// +public sealed class PresenceTransitionTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public PresenceTransitionTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Leaving_to_the_offline_room_makes_the_heartbeat_report_no_room() + { + using var client = await SignedInMatchClientAsync(); + + // Enter the dorm for real: matchmake, then report the join. + var dormInstance = await MatchmakeDormAsync(client); + await ReportJoinAsync(client, dormInstance); + var before = await HeartbeatAsync(client); + Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(before))); + + using var none = await client.PostAsync("/matchmake/none", Form()); + Assert.True(none.IsSuccessStatusCode, $"POST /matchmake/none -> {(int)none.StatusCode}"); + + // Twice: the first beat must not re-seed a dorm the second one returns. + for (var i = 0; i < 2; i++) + { + var beat = await HeartbeatAsync(client); + var room = RoomInstanceOf(beat); + Assert.True(room.ValueKind == JsonValueKind.Null, + $""" + After matchmake/none the client holds its offline room (instance -1) + and is only in sync with a null roomInstance. The heartbeat answered + with a room, which makes the client cancel the offline load and + chase it: + + {beat.GetRawText()} + """); + } + } + + [Fact] + public async Task Leaving_while_a_join_is_pending_abandons_the_join() + { + using var client = await SignedInMatchClientAsync(); + + // The client gave up on this dorm join (e.g. a Photon timeout) and fell + // back to the offline room before reporting a result. + await MatchmakeDormAsync(client); + using var none = await client.PostAsync("/matchmake/none", Form()); + Assert.True(none.IsSuccessStatusCode); + + var beat = await HeartbeatAsync(client); + Assert.True(RoomInstanceOf(beat).ValueKind == JsonValueKind.Null, + $"heartbeat still echoed the abandoned join target: {beat.GetRawText()}"); + } + + [Fact] + public async Task Matchmaking_again_after_leaving_restores_the_room() + { + using var client = await SignedInMatchClientAsync(); + + using var none = await client.PostAsync("/matchmake/none", Form()); + Assert.True(none.IsSuccessStatusCode); + Assert.True(RoomInstanceOf(await HeartbeatAsync(client)).ValueKind == JsonValueKind.Null); + + var dormInstance = await MatchmakeDormAsync(client); + await ReportJoinAsync(client, dormInstance); + + var beat = await HeartbeatAsync(client); + Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(beat))); + } + + [Fact] + public async Task Friends_see_a_player_in_the_offline_room_as_online_with_no_room() + { + using var subject = await SignedInMatchClientAsync(); + using var viewer = await SignedInMatchClientAsync(); + var subjectId = await PlayerIdOfAsync(subject); + + var dormInstance = await MatchmakeDormAsync(subject); + await ReportJoinAsync(subject, dormInstance); + using var none = await subject.PostAsync("/matchmake/none", Form()); + Assert.True(none.IsSuccessStatusCode); + + var row = await BulkPresenceAsync(viewer, subjectId); + Assert.True(row.GetProperty("isOnline").GetBoolean(), + $"a player sitting in the offline room is online: {row.GetRawText()}"); + Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind); + } + + [Fact] + public async Task Logging_out_shows_the_player_offline_and_roomless() + { + using var subject = await SignedInMatchClientAsync(); + using var viewer = await SignedInMatchClientAsync(); + var subjectId = await PlayerIdOfAsync(subject); + + var dormInstance = await MatchmakeDormAsync(subject); + await ReportJoinAsync(subject, dormInstance); + Assert.True((await BulkPresenceAsync(viewer, subjectId)).GetProperty("isOnline").GetBoolean()); + + using var logout = await subject.PostAsync("/player/logout", Form()); + Assert.True(logout.IsSuccessStatusCode); + + var row = await BulkPresenceAsync(viewer, subjectId); + Assert.False(row.GetProperty("isOnline").GetBoolean(), row.GetRawText()); + Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind); + } + + [Fact] + public async Task Bulk_presence_reports_the_stored_status_visibility() + { + using var subject = await SignedInMatchClientAsync(); + using var viewer = await SignedInMatchClientAsync(); + var subjectId = await PlayerIdOfAsync(subject); + + using var put = await subject.PutAsync("/player/statusvisibility", + Form(("statusVisibility", "2"))); + Assert.True(put.IsSuccessStatusCode, $"PUT statusvisibility -> {(int)put.StatusCode}"); + + var row = await BulkPresenceAsync(viewer, subjectId); + Assert.Equal(2, row.GetProperty("statusVisibility").GetInt32()); + + // And the player's own heartbeat agrees. + var beat = await HeartbeatAsync(subject); + Assert.Equal(2, beat.GetProperty("statusVisibility").GetInt32()); + } + + [Fact] + public void Presence_push_carries_online_state_independently_of_the_room() + { + var room = new RoomInstanceDto + { + RoomInstanceId = 42, + RoomId = 7, + Name = "DormRoom", + PhotonRoomId = "^dormroom_p1", + }; + + // Online, no room: the offline-room state. Used to be sent as OFFLINE + // because isOnline was derived from the room. + var onlineNoRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, null, isOnline: true)); + Assert.True(onlineNoRoom.GetProperty("isOnline").GetBoolean()); + Assert.Equal(JsonValueKind.Null, onlineNoRoom.GetProperty("roomInstance").ValueKind); + + // Offline never leaks a room, even if one is still cached. + var offline = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: false)); + Assert.False(offline.GetProperty("isOnline").GetBoolean()); + Assert.Equal(JsonValueKind.Null, offline.GetProperty("roomInstance").ValueKind); + + // Online in a room: redacted join coordinates, visibility passed through, + // appVersion a STRING. + var inRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: true, statusVisibility: 1)); + Assert.True(inRoom.GetProperty("isOnline").GetBoolean()); + Assert.Equal(42, inRoom.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64()); + Assert.Equal(string.Empty, inRoom.GetProperty("roomInstance").GetProperty("photonRoomId").GetString()); + Assert.Equal(1, inRoom.GetProperty("statusVisibility").GetInt32()); + Assert.Equal(JsonValueKind.String, inRoom.GetProperty("appVersion").ValueKind); + + static JsonElement Msg(string payload) + { + var root = JsonDocument.Parse(payload).RootElement; + // String-keyed channel: the client registers "PresenceUpdate". + Assert.Equal("PresenceUpdate", root.GetProperty("Id").GetString()); + return root.GetProperty("Msg"); + } + } + + [Fact] + public void Presence_pushes_honour_the_players_status_visibility() + { + // Live PresenceUpdate frames must hide a player exactly as GET /player + // does, or "appear offline" leaks every room change to friends. + long[] friends = [10, 11, 12]; + var favorited = new HashSet { 11 }; + + var pub = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Public, favorited); + Assert.Equal(friends, pub.VisibleTo); + Assert.Empty(pub.HiddenFrom); + + // Every recipient is already a friend, so FriendsOnly hides nobody. + var friendsOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FriendsOnly, favorited); + Assert.Equal(friends, friendsOnly.VisibleTo); + Assert.Empty(friendsOnly.HiddenFrom); + + var favoritesOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FavoriteFriendsOnly, favorited); + Assert.Equal([11L], favoritesOnly.VisibleTo); + Assert.Equal([10L, 12L], favoritesOnly.HiddenFrom); + + var offline = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Offline, favorited); + Assert.Empty(offline.VisibleTo); + Assert.Equal(friends, offline.HiddenFrom); + } + + // ── helpers ────────────────────────────────────────────────────── + + private async Task SignedInMatchClientAsync() + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://match.{_factory.ApexDomain}"); + var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } + + private static async Task PlayerIdOfAsync(HttpClient client) + { + var beat = await HeartbeatAsync(client); + return beat.GetProperty("playerId").GetInt64(); + } + + private static async Task MatchmakeDormAsync(HttpClient client) + { + using var resp = await client.PostAsync("/matchmake/dorm", Form(("MaxPersistenceVersion", "45"))); + var body = await resp.Content.ReadAsStringAsync(); + Assert.True(resp.IsSuccessStatusCode, $"POST /matchmake/dorm -> {(int)resp.StatusCode}: {body}"); + var id = FindInstanceId(JsonDocument.Parse(body).RootElement); + Assert.False(string.IsNullOrEmpty(id), $"no roomInstanceId in the dorm reply: {body}"); + return id!; + } + + private static async Task ReportJoinAsync(HttpClient client, string instanceId) + { + using var resp = await client.PostAsync($"/roominstance/{instanceId}/reportjoinresult", Form(("result", "0"))); + Assert.True(resp.IsSuccessStatusCode, $"reportjoinresult -> {(int)resp.StatusCode}"); + } + + private static async Task HeartbeatAsync(HttpClient client) + { + using var resp = await client.PostAsync("/player/heartbeat", Form(("LoginLock", Guid.NewGuid().ToString()))); + var body = await resp.Content.ReadAsStringAsync(); + Assert.True(resp.IsSuccessStatusCode, $"POST /player/heartbeat -> {(int)resp.StatusCode}: {body}"); + return JsonDocument.Parse(body).RootElement.Clone(); + } + + private static async Task BulkPresenceAsync(HttpClient viewer, long subjectId) + { + using var resp = await viewer.GetAsync($"/player?{subjectId}"); + var body = await resp.Content.ReadAsStringAsync(); + Assert.True(resp.IsSuccessStatusCode, $"GET /player -> {(int)resp.StatusCode}: {body}"); + foreach (var row in JsonDocument.Parse(body).RootElement.EnumerateArray()) + { + if (row.GetProperty("playerId").GetInt64() == subjectId) return row.Clone(); + } + Assert.Fail($"no row for {subjectId}: {body}"); + return default; + } + + private static JsonElement RoomInstanceOf(JsonElement presence) + { + Assert.True(presence.TryGetProperty("roomInstance", out var room), + $"no roomInstance key: {presence.GetRawText()}"); + return room; + } + + private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) => + new(fields.Select(f => new KeyValuePair(f.Key, f.Value))); + + private static string? FindInstanceId(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) return null; + foreach (var property in element.EnumerateObject()) + { + if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase)) + return property.Value.ToString(); + if (property.Value.ValueKind == JsonValueKind.Object + && FindInstanceId(property.Value) is { } nested) + return nested; + } + return null; + } +} diff --git a/DorkNet.Server.Tests/ProfileEdit2023Tests.cs b/DorkNet.Server.Tests/ProfileEdit2023Tests.cs new file mode 100644 index 0000000..8c050c7 --- /dev/null +++ b/DorkNet.Server.Tests/ProfileEdit2023Tests.cs @@ -0,0 +1,257 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using DorkNet.Server.Controllers.Match; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Tests; + +/// +/// In-game profile edits for the 2023-03-21 client. Every edit goes through +/// OPMAPIOEIFG.OBAGJBBIFGB → PUT "account/me/" + suffix with a single +/// urlencoded field whose name differs from the suffix (displayEmoji, +/// pronounFlags, identityFlags, imageName). None of these routes existed, so +/// every edit failed with "Failed to modify <suffix>", and no account +/// DTO emitted the fields, so nothing showed up for anyone. Online-status +/// visibility (PUT player/statusvisibility) was persisted but GET /player +/// always reported Public and never hid the player. +/// +public sealed class ProfileEdit2023Tests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public ProfileEdit2023Tests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Emoji_edit_persists_and_is_visible_to_other_players() + { + var (owner, viewer) = await TwoPlayersAsync(); + using var ownerClient = AccountsClient(owner); + using var viewerClient = AccountsClient(viewer); + + await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "🤪"); + + var me = await GetJsonAsync(ownerClient, "/account/me"); + Assert.Equal("🤪", me.GetProperty("displayEmoji").GetString()); + + var seen = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}"); + Assert.Equal("🤪", seen.GetProperty("displayEmoji").GetString()); + + // Empty clears it. + await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", ""); + me = await GetJsonAsync(ownerClient, "/account/me"); + Assert.Equal("", me.GetProperty("displayEmoji").GetString()); + } + + [Fact] + public async Task Emoji_edit_rejects_non_whitelisted_and_canonicalizes_variation_selector() + { + var (owner, _) = await TwoPlayersAsync(); + using var client = AccountsClient(owner); + + using (var bad = await PutFormAsync(client, "/account/me/emoji", "displayEmoji", "not-an-emoji")) + Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); + + // "✌" without U+FE0F resolves to the list's "✌️". + await PutFormOkAsync(client, "/account/me/emoji", "displayEmoji", "✌"); + var me = await GetJsonAsync(client, "/account/me"); + Assert.Equal("✌️", me.GetProperty("displayEmoji").GetString()); + } + + [Fact] + public async Task Pronouns_identity_flags_and_banner_round_trip_on_every_account_read() + { + var (owner, viewer) = await TwoPlayersAsync(); + using var ownerClient = AccountsClient(owner); + using var viewerClient = AccountsClient(viewer); + + await PutFormOkAsync(ownerClient, "/account/me/personalpronouns", "pronounFlags", "5"); + await PutFormOkAsync(ownerClient, "/account/me/identityflags", "identityFlags", "3"); + await PutFormOkAsync(ownerClient, "/account/me/bannerimage", "imageName", "banner-test.jpg"); + + void AssertProfile(JsonElement a) + { + Assert.Equal(5, a.GetProperty("personalPronouns").GetInt32()); + Assert.Equal(3, a.GetProperty("identityFlags").GetInt32()); + Assert.Equal("banner-test.jpg", a.GetProperty("bannerImage").GetString()); + } + + AssertProfile(await GetJsonAsync(ownerClient, "/account/me")); + AssertProfile(await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}")); + + var bulk = await GetJsonAsync(viewerClient, $"/account/bulk?id={owner.PlayerId}"); + AssertProfile(bulk.EnumerateArray().Single(a => a.GetProperty("accountId").GetInt64() == owner.PlayerId)); + } + + [Fact] + public async Task Flag_edits_reject_non_numeric_values() + { + var (owner, _) = await TwoPlayersAsync(); + using var client = AccountsClient(owner); + + using (var r = await PutFormAsync(client, "/account/me/personalpronouns", "pronounFlags", "HeHim")) + Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode); + using (var r = await PutFormAsync(client, "/account/me/identityflags", "identityFlags", "-1")) + Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode); + } + + [Fact] + public async Task Self_account_reports_username_changes_remaining() + { + var (owner, _) = await TwoPlayersAsync(); + using var client = AccountsClient(owner); + var me = await GetJsonAsync(client, "/account/me"); + // Absent/0 makes the settings UI say no username changes are left. + Assert.True(me.GetProperty("availableUsernameChanges").GetInt32() > 0); + } + + [Fact] + public async Task Bio_edit_round_trips_through_public_bio_lookup() + { + var (owner, viewer) = await TwoPlayersAsync(); + using var ownerClient = AccountsClient(owner); + using var viewerClient = AccountsClient(viewer); + + await PutFormOkAsync(ownerClient, "/account/me/bio", "bio", "hello from the profile"); + var bio = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}/bio"); + Assert.Equal("hello from the profile", bio.GetProperty("bio").GetString()); + } + + [Fact] + public async Task Status_visibility_is_reported_to_self_and_hides_appear_offline_player() + { + var (owner, viewer) = await TwoPlayersAsync(); + PutOnline(owner.PlayerId); + + using var ownerMatch = MatchClient(owner); + using var viewerMatch = MatchClient(viewer); + + // Baseline: Public → viewer sees owner online. + var before = await PresenceOfAsync(viewerMatch, owner.PlayerId); + Assert.True(before.GetProperty("isOnline").GetBoolean()); + + await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "3"); + + // Self keeps its own choice (the dialog reads the cached self presence). + var self = await PresenceOfAsync(ownerMatch, owner.PlayerId); + Assert.Equal(3, self.GetProperty("statusVisibility").GetInt32()); + Assert.True(self.GetProperty("isOnline").GetBoolean()); + + // Others see an offline player with no room. + var other = await PresenceOfAsync(viewerMatch, owner.PlayerId); + Assert.False(other.GetProperty("isOnline").GetBoolean()); + Assert.Equal(JsonValueKind.Null, other.GetProperty("roomInstance").ValueKind); + } + + [Fact] + public async Task Friends_only_visibility_shows_friends_and_hides_strangers() + { + var (owner, friend) = await TwoPlayersAsync(); + using var setup = AccountsClient(); + var stranger = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + PutOnline(owner.PlayerId); + + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Relationships.Add(new RelationshipEntity + { + RequesterId = owner.PlayerId, + TargetId = friend.PlayerId, + Status = RelationshipStatus.Friend, + }); + await db.SaveChangesAsync(); + } + + using var ownerMatch = MatchClient(owner); + await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "1"); + + using var friendMatch = MatchClient(friend); + using var strangerMatch = MatchClient(stranger); + Assert.True((await PresenceOfAsync(friendMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean()); + Assert.False((await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean()); + Assert.Equal(1, (await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("statusVisibility").GetInt32()); + } + + [Fact] + public void Presence_pref_key_matches_status_visibility_writer() + { + // MatchPlayerController writes the pref; PlayerPresenceController reads it. + var field = typeof(MatchPlayerController).GetField( + "PrefStatusVisibility", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert.NotNull(field); + Assert.Equal(PlayerPresenceController.PrefStatusVisibility, (string)field!.GetRawConstantValue()!); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private void PutOnline(long playerId) + { + using var scope = _factory.Services.CreateScope(); + var presence = scope.ServiceProvider.GetRequiredService(); + presence.SetRoom(playerId, new RoomInstanceDto + { + RoomInstanceId = 900_000 + playerId, + RoomId = 1, + SubRoomId = 0, + PhotonRoomId = $"^profiletest{playerId}", + PhotonRegionId = "us", + MaxCapacity = 8, + }); + presence.MarkActive(playerId); + } + + private static async Task PresenceOfAsync(HttpClient client, long playerId) + { + var list = await GetJsonAsync(client, $"/player?{playerId}"); + return list.EnumerateArray().Single(p => p.GetProperty("playerId").GetInt64() == playerId); + } + + private async Task<(GameClientSession, GameClientSession)> TwoPlayersAsync() + { + using var setup = AccountsClient(); + var a = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var b = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + return (a, b); + } + + private static async Task PutFormAsync(HttpClient client, string path, string field, string value) + => await client.PutAsync(path, new FormUrlEncodedContent(new Dictionary { [field] = value })); + + private static async Task PutFormOkAsync(HttpClient client, string path, string field, string value) + { + using var response = await PutFormAsync(client, path, field, value); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"PUT {path} → {(int)response.StatusCode}: {body}"); + } + + private HttpClient AccountsClient(GameClientSession? session = null) => Client("accounts", session); + + private HttpClient MatchClient(GameClientSession session) => Client("match", session); + + private HttpClient Client(string host, GameClientSession? session) + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } + + private static async Task GetJsonAsync(HttpClient client, string path) + { + using var response = await client.GetAsync(path); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"GET {path} → {(int)response.StatusCode}: {body}"); + using var document = JsonDocument.Parse(body); + return document.RootElement.Clone(); + } +} diff --git a/DorkNet.Server.Tests/RecNetResultEnvelopeTests.cs b/DorkNet.Server.Tests/RecNetResultEnvelopeTests.cs new file mode 100644 index 0000000..960ba62 --- /dev/null +++ b/DorkNet.Server.Tests/RecNetResultEnvelopeTests.cs @@ -0,0 +1,278 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DorkNet.Server.Tests; + +/// +/// Writes whose client method returns the untyped promise (LDGADANDBIO) +/// but whose reply is still parsed — as DJMHAFPGLLN, the +/// {Success, Error} RecNetResult — by BFDGEKKNAPB.PEFKBAEAOPD +/// (ExpandRecNetResult, RecNet.Runtime/BFDGEKKNAPB.txt:353). +/// +/// The failure modes are silent server-side: an empty 200 parses to null and +/// null-derefs, so the player sees "Failed to …" after the change was applied; +/// a body without Success parses as a failure whose Error is null, +/// and Promise.Rejected(null) throws "Error message cannot be null". +/// So the assertion that matters is the body, read the way the client's +/// formatter reads it (LMCCNLLHLCJ.txt: Success/success, Error/error). +/// +public sealed class RecNetResultEnvelopeTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public RecNetResultEnvelopeTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Club_member_writes_answer_a_success_result_not_an_empty_body() + { + using var setup = Client("clubs"); + var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var joiner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var clubId = await SeedOpenClubAsync(owner.PlayerId); + + using var joinerClient = Client("clubs", joiner); + // requesttojoin is verb 3 (PUT) through IKMMOCKDKAF.JNFEEDLCGHH, form-encoded. + using var join = await joinerClient.PutAsync( + $"/club/{clubId}/members/requesttojoin", new FormUrlEncodedContent([])); + await AssertRecNetSuccessAsync(join, "requesttojoin"); + + using var leave = await joinerClient.PostAsync( + $"/club/{clubId}/members/leave", new FormUrlEncodedContent([])); + await AssertRecNetSuccessAsync(leave, "leave"); + + using var clearHome = await joinerClient.DeleteAsync("/club/home/me"); + await AssertRecNetSuccessAsync(clearHome, "DELETE club/home/me"); + + using var ownerClient = Client("clubs", owner); + using var delete = await ownerClient.DeleteAsync($"/club/{clubId}"); + await AssertRecNetSuccessAsync(delete, "DELETE club/{id}"); + } + + [Fact] + public async Task Read_markers_and_privacy_toggle_answer_a_success_result() + { + using var setup = Client("api"); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + using var client = Client("api", player); + + using var commentRead = await client.PutAsync("/comments/read/1/1", content: null); + await AssertRecNetSuccessAsync(commentRead, "PUT comments/read"); + + using var accounts = Client("accounts", player); + using var privacy = await accounts.PutAsync( + "/accountprivacysettings/recenthistoryvisibility", + new FormUrlEncodedContent([new("isRecentHistoryVisible", "false")])); + await AssertRecNetSuccessAsync(privacy, "PUT recenthistoryvisibility"); + } + + [Fact] + public async Task A_refused_warning_carries_an_error_message() + { + using var setup = Client("api"); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + using var client = Client("api", player); + + using var response = await client.PostAsync("/api/playerwarnings", + new FormUrlEncodedContent([new("WarnedPlayerId", player.PlayerId.ToString())])); + var root = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + Assert.False(ReadSuccess(root)); + // Without Error the client's Promise.Rejected(null) throws instead of + // surfacing the refusal. + Assert.False(string.IsNullOrEmpty(ReadError(root))); + } + + [Fact] + public async Task Login_token_is_a_bare_string_redeemed_once_for_its_own_account() + { + using var setup = Client("auth"); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + using var auth = Client("auth", player); + + using var mint = await auth.PostAsync("/account/me/createlogintoken", content: null); + var raw = await mint.Content.ReadAsStringAsync(); + Assert.True(mint.IsSuccessStatusCode, raw); + // FGLDKEJLAKB LGKLFFPACGL(): a JSON string, not an object. + var token = JsonSerializer.Deserialize(raw); + Assert.False(string.IsNullOrEmpty(token)); + + using var anon = Client("auth"); + var form = new Dictionary + { + ["grant_type"] = "login_token", + ["account_id"] = player.PlayerId.ToString(), + ["login_token"] = token!, + }; + using var redeemed = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form)); + var redeemedBody = await redeemed.Content.ReadAsStringAsync(); + Assert.True(redeemed.IsSuccessStatusCode, redeemedBody); + Assert.Equal(player.PlayerId, await SubjectOfAsync(redeemedBody)); + + using var again = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form)); + Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode); + } + + [Fact] + public async Task Device_code_poll_waits_for_remoteauth_approval_then_signs_in_the_approver() + { + using var anon = Client("auth"); + var approver = await GameClientSessionFactory.CreateAsync(anon, _factory.ApexDomain); + + using var start = await anon.PostAsync("/connect/deviceauthorization", content: null); + var started = JsonDocument.Parse(await start.Content.ReadAsStringAsync()).RootElement; + var deviceCode = started.GetProperty("device_code").GetString()!; + var userCode = started.GetProperty("user_code").GetString()!; + + var poll = new Dictionary + { + ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code", + ["device_code"] = deviceCode, + }; + + // Before approval the client must be told to keep polling: a 400 + // whose error is authorization_pending (JAEKFNKDNCP.txt:97-107). + using (var pending = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll))) + { + Assert.Equal(HttpStatusCode.BadRequest, pending.StatusCode); + var body = JsonDocument.Parse(await pending.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("authorization_pending", body.GetProperty("error").GetString()); + } + + using var approverClient = Client("auth", approver); + using (var bad = await approverClient.PostAsync("/account/me/remoteauth", + new FormUrlEncodedContent([new("code", "NOPE0000")]))) + { + var root = JsonDocument.Parse(await bad.Content.ReadAsStringAsync()).RootElement; + Assert.False(ReadSuccess(root)); + Assert.False(string.IsNullOrEmpty(ReadError(root))); + } + + using var approve = await approverClient.PostAsync("/account/me/remoteauth", + new FormUrlEncodedContent([new("code", userCode.ToLowerInvariant())])); + await AssertRecNetSuccessAsync(approve, "remoteauth"); + + using var granted = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll)); + var grantedBody = await granted.Content.ReadAsStringAsync(); + Assert.True(granted.IsSuccessStatusCode, grantedBody); + Assert.Equal(approver.PlayerId, await SubjectOfAsync(grantedBody)); + + // Single use. + using var reused = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll)); + Assert.Equal(HttpStatusCode.BadRequest, reused.StatusCode); + } + + [Fact] + public async Task Gameconfig_carries_the_play_menu_carousel_ordering_in_the_parsed_format() + { + using var client = Client("api"); + var rows = JsonDocument.Parse(await client.GetStringAsync("/api/gameconfigs/v1/all")).RootElement; + var row = rows.EnumerateArray().FirstOrDefault(r => + string.Equals(Prop(r, "Key"), "PlayMenuCarouselOrdering_1", StringComparison.Ordinal)); + Assert.NotEqual(JsonValueKind.Undefined, row.ValueKind); + + // PlayMenuCarouselModel.TryGetDisplayPriorityMap: Split(';'), then each + // entry Split('=') must give exactly 2 parts with an Int32 priority, or + // the entry is logged as "Failed to parse priority mapping". + var entries = Prop(row, "Value")!.Split(';'); + Assert.All(entries, e => + { + var parts = e.Split('='); + Assert.Equal(2, parts.Length); + Assert.True(int.TryParse(parts[1].Trim(), out _), e); + }); + Assert.Contains(entries, e => e.StartsWith("hotrooms=", StringComparison.Ordinal)); + } + + [Fact] + public async Task Server_strings_download_answers_an_empty_table() + { + using var client = Client("strings"); + using var response = await client.GetAsync("/strings?locale=en-US&scopePrefix=client.&eTag="); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, body); + Assert.Equal(JsonValueKind.Object, JsonDocument.Parse(body).RootElement.ValueKind); + } + + /// PUT playerevents/v2/{id}/multiinstance sends the two broadcast + /// permission fields as boxed enums, i.e. member names (BNDIAONDFFF.txt:947 + /// falls back to Object.ToString). Parsing them as ints stored 0 every time. + [Theory] + [InlineData("RoomOwners", 256)] + [InlineData("All", int.MaxValue)] + [InlineData("None", 0)] + [InlineData("256", 256)] + [InlineData("roomowners, None", 256)] + public void Broadcast_permission_names_parse_to_their_values(string raw, int expected) => + Assert.Equal(expected, + DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController.ParseBroadcastPermissions(raw)); + + [Fact] + public void Unknown_broadcast_permission_name_keeps_the_stored_value() => + Assert.Null(DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController + .ParseBroadcastPermissions("SomethingElse")); + + private async Task SeedOpenClubAsync(long ownerId) + { + await using var scope = _factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var club = new ClubEntity + { + Name = $"Env{Guid.NewGuid():N}"[..16], + CreatorPlayerId = ownerId, + Joinability = 0, // Open + }; + db.Clubs.Add(club); + await db.SaveChangesAsync(); + return club.Id; + } + + private async Task SubjectOfAsync(string tokenBody) + { + var access = JsonDocument.Parse(tokenBody).RootElement.GetProperty("access_token").GetString(); + using var client = Client("auth"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access); + var info = JsonDocument.Parse(await client.GetStringAsync("/connect/userinfo")).RootElement; + return long.Parse(info.GetProperty("sub").GetString()!); + } + + private static async Task AssertRecNetSuccessAsync(HttpResponseMessage response, string what) + { + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"{what} -> {(int)response.StatusCode}: {body}"); + Assert.False(string.IsNullOrWhiteSpace(body), $"{what} answered an empty body"); + var root = JsonDocument.Parse(body).RootElement; + Assert.True(ReadSuccess(root), $"{what} body has no Success=true: {body}"); + } + + // The client formatter registers exactly these spellings. + private static bool ReadSuccess(JsonElement root) => + (root.TryGetProperty("Success", out var s) || root.TryGetProperty("success", out s)) + && s.ValueKind == JsonValueKind.True; + + private static string? ReadError(JsonElement root) => + root.TryGetProperty("Error", out var e) || root.TryGetProperty("error", out e) + ? e.ValueKind == JsonValueKind.String ? e.GetString() : null + : null; + + private static string? Prop(JsonElement row, string name) => + row.TryGetProperty(name, out var v) || row.TryGetProperty(char.ToLowerInvariant(name[0]) + name[1..], out v) + ? v.GetString() + : null; + + private HttpClient Client(string host, GameClientSession? session = null) + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } +} diff --git a/DorkNet.Server.Tests/RroRoomBlobTests.cs b/DorkNet.Server.Tests/RroRoomBlobTests.cs new file mode 100644 index 0000000..1d34310 --- /dev/null +++ b/DorkNet.Server.Tests/RroRoomBlobTests.cs @@ -0,0 +1,448 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using RecRoom.Protobuf; + +namespace DorkNet.Server.Tests; + +/// +/// Baked Rec Room Originals (Rec Royale, Paintball, …) ship with an empty +/// , and every details builder and +/// matchmaking resolver turns that into an empty DataBlob. The client +/// reads that as "no room data at all": no persistence views, no room-role +/// overlay, no Maker Pen, no CV2 chips, no room mood engine. Real Rec Room +/// served these rooms a blob — Rec Royale's game-rule chip lives in it, not +/// in the scene — so DorkNet needs a switch that gives such a room one. +/// +/// The property pinned here: the admin "enable blob" action stamps the +/// synthetic default name, that name flows through the game-client details +/// verbatim while IsRRO stays true (the client still loads the baked +/// map, and only additionally fetches the blob), the CDN answers that name +/// with the RRO-editable blob (permissive room roles, no objects), and +/// "reset" puts everything back to scene-only. +/// +public sealed class RroRoomBlobTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public RroRoomBlobTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Baked_original_gets_no_blob_until_an_admin_enables_one() + { + using var setup = Client("rooms"); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + var roomId = 9_600_000 + Random.Shared.Next(1, 99_999); + var roomName = $"Royale{Guid.NewGuid():N}"[..18]; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true; + + // Shape of a seeded baked original: system-owned, AG-flagged, + // tagged recroomoriginal, no blob anywhere. + db.Rooms.Add(new RoomEntity + { + Id = roomId, + Name = roomName, + CreatorPlayerId = 1, + IsAGRoom = true, + TagsCsv = "recroomoriginal,sport", + CurrentDataBlobName = string.Empty, + LocationReplicationId = "b010171f-4875-4e89-baba-61e878cd41e1", + }); + db.RoomScenes.Add(new RoomSceneEntity + { RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = string.Empty }); + await db.SaveChangesAsync(); + } + + var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId); + using var gameClient = Client("rooms", player); + using var adminClient = Client("admin", admin); + using var cdnClient = Client("cdn"); + + // ── before: baked scene only ──────────────────────────────────────── + var before = await GetJsonAsync(gameClient, $"/rooms/{roomId}"); + Assert.True(before.GetProperty("IsRRO").GetBoolean()); + Assert.Equal(string.Empty, SubRoomZero(before).GetProperty("DataBlob").GetString()); + + var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms"); + var row = listed.EnumerateArray().Single(); + Assert.True(row.GetProperty("isBakedOriginal").GetBoolean()); + Assert.Equal(string.Empty, row.GetProperty("effectiveBlobName").GetString()); + + // ── enable: the synthetic name lands on the scene AND the room ───── + var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable"); + Assert.True(enabled.GetProperty("changed").GetBoolean()); + Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString()); + Assert.Equal(synthetic, enabled.GetProperty("dataBlobName").GetString()); + Assert.Equal(synthetic, enabled.GetProperty("roomCurrentDataBlobName").GetString()); + + // Idempotent: a second enable changes nothing and keeps the name. + var again = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable"); + Assert.False(again.GetProperty("changed").GetBoolean()); + Assert.Equal(synthetic, again.GetProperty("effectiveBlobName").GetString()); + + // The game client now gets the blob name, and IsRRO is untouched — + // that flag is what makes it load the baked map (a false there sends + // it looking for geometry inside the blob and the join fails). + var after = await GetJsonAsync(gameClient, $"/rooms/{roomId}"); + Assert.True(after.GetProperty("IsRRO").GetBoolean()); + Assert.Equal(synthetic, SubRoomZero(after).GetProperty("DataBlob").GetString()); + + // ── the CDN answers that name with the RRO-editable blob ─────────── + using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}"); + var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync(); + Assert.True(blobResponse.IsSuccessStatusCode, + $"GET cdn /room/{synthetic} -> {(int)blobResponse.StatusCode}"); + var persisted = PersistedRoomData.Parser.ParseFrom(blobBytes); + Assert.NotNull(persisted.RoomRoleData); + Assert.NotEmpty(persisted.RoomRoleData.RoomRoles); + Assert.True(persisted.RoomRoleData.RoomRoles.All(r => r.CanUseMakerPen is { Overrides: true, InnerValue: true })); + + // The March-2023 client rejects a blob past PersistedRoomVersion 16 + // (the old stub was stamped 19 and loaded as "no room data"), and a + // real save carries the room's location id as activity_id. Our proto + // doesn't type activity_id, so look for it on the wire. + Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)persisted.Version); + var blobText = System.Text.Encoding.UTF8.GetString(blobBytes); + Assert.Contains("b010171f-4875-4e89-baba-61e878cd41e1", blobText); + + // Every real room save carries three GameRoleNode chips (default role + // named after the room, "In-Game", "Eliminated" — well-known GUIDs). + // The 2023 client grants Maker Pen off the player's GameRole, so a + // blob with no role objects leaves even a co-owner with nothing. + Assert.Equal(3, CountTopLevelPersistenceViews(blobBytes)); + Assert.Contains("In-Game", blobText); + Assert.Contains("Eliminated", blobText); + Assert.Contains("2c721342-c7ed-49fe-b8f0-93f8d282a6df", blobText); + Assert.Contains("0b9ec39e-2cc9-4825-8321-da11561f1c4f", blobText); + Assert.Contains("c48bac02-a743-4797-94c4-e2d6f0749004", blobText); + Assert.Contains(roomName, blobText); + + // ── reset: back to scene-only ─────────────────────────────────────── + var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset"); + Assert.True(reset.GetProperty("changed").GetBoolean()); + Assert.Equal(string.Empty, reset.GetProperty("effectiveBlobName").GetString()); + + var restored = await GetJsonAsync(gameClient, $"/rooms/{roomId}"); + Assert.True(restored.GetProperty("IsRRO").GetBoolean()); + Assert.Equal(string.Empty, SubRoomZero(restored).GetProperty("DataBlob").GetString()); + } + + /// The seeded Rec Royale rows have NO RoomScenes at all (the admin + /// page showed "Scenes 0 / No sub-rooms"). The admin list must synthesise + /// the entry scene the way the game-client details do, and enable must + /// create the row rather than 404. + [Fact] + public async Task Room_without_scene_rows_gets_a_virtual_entry_scene_and_enable_creates_it() + { + using var setup = Client("rooms"); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + var roomId = 9_670_000 + Random.Shared.Next(1, 99_999); + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true; + db.Rooms.Add(new RoomEntity + { + Id = roomId, + Name = $"NoScene{Guid.NewGuid():N}"[..18], + CreatorPlayerId = 1, + IsAGRoom = true, + TagsCsv = "recroomoriginal", + MaxCapacity = 24, + }); + await db.SaveChangesAsync(); + } + + var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId); + using var gameClient = Client("rooms", player); + using var adminClient = Client("admin", admin); + + var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms"); + var virtualRow = listed.EnumerateArray().Single(); + Assert.True(virtualRow.GetProperty("isVirtual").GetBoolean()); + Assert.Equal(0, virtualRow.GetProperty("subRoomId").GetInt32()); + Assert.Equal(24, virtualRow.GetProperty("maxPlayers").GetInt32()); + Assert.Equal(string.Empty, virtualRow.GetProperty("effectiveBlobName").GetString()); + + var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable"); + Assert.True(enabled.GetProperty("changed").GetBoolean()); + Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString()); + + // The row now exists with the game-side defaults, and the game client + // sees the blob on its entry scene. + var relisted = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms"); + var real = relisted.EnumerateArray().Single(); + Assert.False(real.TryGetProperty("isVirtual", out _)); + Assert.Equal("Home", real.GetProperty("name").GetString()); + Assert.Equal(24, real.GetProperty("maxPlayers").GetInt32()); + Assert.Equal(synthetic, real.GetProperty("dataBlobName").GetString()); + + var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}"); + Assert.True(details.GetProperty("IsRRO").GetBoolean()); + Assert.Equal(synthetic, SubRoomZero(details).GetProperty("DataBlob").GetString()); + + var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset"); + Assert.True(reset.GetProperty("changed").GetBoolean()); + Assert.Equal(string.Empty, SubRoomZero(await GetJsonAsync(gameClient, $"/rooms/{roomId}")).GetProperty("DataBlob").GetString()); + } + + /// Rec Royale's Frontier scene has no baked game rule; real Rec + /// Room delivered the RecRoyaleSolos/Squads_GameRuleWrapperCircuit chip + /// in the blob. The stub for those rooms must carry it (a fourth + /// persistence view with the palette tag the client generates). + [Theory] + [InlineData("RecRoyaleSolos", "recroyalesolosgamerulecir")] + [InlineData("RecRoyaleSquads", "recroyalesquadsgameruleci")] + public async Task Rec_royale_stub_carries_its_game_rule_chip(string roomNamePrefix, string expectedTag) + { + using var setup = Client("rooms"); + var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + var roomId = 9_680_000 + Random.Shared.Next(1, 99_999); + var roomName = $"{roomNamePrefix}-{Guid.NewGuid():N}"[..24]; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true; + db.Rooms.Add(new RoomEntity + { + Id = roomId, + Name = roomName, + CreatorPlayerId = 1, + IsAGRoom = true, + TagsCsv = "recroomoriginal", + LocationReplicationId = "253fa009-6e65-4c90-91a1-7137a56a267f", + }); + await db.SaveChangesAsync(); + } + + using var adminClient = Client("admin", admin); + using var cdnClient = Client("cdn"); + await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable"); + + var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId); + using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}"); + Assert.True(blobResponse.IsSuccessStatusCode); + var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync(); + Assert.Equal(4, CountTopLevelPersistenceViews(blobBytes)); // 3 roles + the rule chip + Assert.Contains(expectedTag, System.Text.Encoding.UTF8.GetString(blobBytes)); + } + + /// A real save from any client version can be attached to a baked + /// original: activity id rewritten to the target room, output log / + /// sub-room id / unknown fields dropped, version clamped, and in + /// basics mode only the role + rule objects kept with circuits + /// stripped. The room then loads that blob (with the CDN's role overlay). + [Fact] + public async Task Uploading_a_real_save_attaches_it_to_the_room_rewritten_for_2023() + { + using var setup = Client("rooms"); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + var roomId = 9_690_000 + Random.Shared.Next(1, 99_999); + const string location = "253fa009-6e65-4c90-91a1-7137a56a267f"; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true; + db.Rooms.Add(new RoomEntity + { + Id = roomId, + Name = $"Upload{Guid.NewGuid():N}"[..18], + CreatorPlayerId = 1, + IsAGRoom = true, + TagsCsv = "recroomoriginal", + LocationReplicationId = location, + }); + await db.SaveChangesAsync(); + } + + // A "modern" save: version 138, someone else's activity id, an output + // log, a sub-room id, a newer-only field 37, circuit payloads, and + // three views — a game role (field 50), a game rule (field 41) and a + // plain prop (field 15 only). + var modern = BuildModernSave(); + + using var adminClient = Client("admin", admin); + using var form = new MultipartFormDataContent(); + form.Add(new ByteArrayContent(modern), "file", "persisted_room_data.binpb"); + using var uploaded = await adminClient.PostAsync($"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/upload?mode=basics", form); + var uploadBody = await uploaded.Content.ReadAsStringAsync(); + Assert.True(uploaded.IsSuccessStatusCode, $"upload -> {(int)uploaded.StatusCode}: {uploadBody}"); + var result = JsonDocument.Parse(uploadBody).RootElement; + var blobName = result.GetProperty("dataBlobName").GetString()!; + Assert.StartsWith($"room_{roomId}_upload_", blobName); + var summary = result.GetProperty("summary"); + Assert.Equal(3, summary.GetProperty("viewsIn").GetInt32()); + Assert.Equal(2, summary.GetProperty("viewsKept").GetInt32()); + Assert.Equal(138, summary.GetProperty("versionIn").GetInt32()); + Assert.Equal(16, summary.GetProperty("versionOut").GetInt32()); + + // The game client is now pointed at the uploaded blob … + using var gameClient = Client("rooms", player); + var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}"); + Assert.Equal(blobName, SubRoomZero(details).GetProperty("DataBlob").GetString()); + + // … and the CDN serves it rewritten: 2 views, clamped header, our + // activity id, the role overlay applied, no field 37 / output log. + using var cdnClient = Client("cdn"); + using var served = await cdnClient.GetAsync($"/room/{blobName}"); + Assert.True(served.IsSuccessStatusCode, $"cdn -> {(int)served.StatusCode}"); + var bytes = await served.Content.ReadAsByteArrayAsync(); + var parsed = PersistedRoomData.Parser.ParseFrom(bytes); + Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)parsed.Version); + Assert.NotEmpty(parsed.RoomRoleData.RoomRoles); + Assert.Equal(2, CountTopLevelPersistenceViews(bytes)); + var text = System.Text.Encoding.UTF8.GetString(bytes); + Assert.Contains(location, text); + Assert.DoesNotContain("someone-elses-activity", text); + Assert.DoesNotContain("Serialized 3 persistence views", text); + } + + private static byte[] BuildModernSave() + { + var ms = new MemoryStream(); + void Varint(int field, ulong v) { WriteVarint(ms, ((ulong)field << 3) | 0); WriteVarint(ms, v); } + void Bytes(int field, byte[] payload) { WriteVarint(ms, ((ulong)field << 3) | 2); WriteVarint(ms, (ulong)payload.Length); ms.Write(payload); } + static byte[] View(int markerField) + { + var v = new MemoryStream(); + WriteVarint(v, (1UL << 3) | 2); WriteVarint(v, 16); v.Write(Guid.NewGuid().ToByteArray()); + WriteVarint(v, ((ulong)markerField << 3) | 2); WriteVarint(v, 0); + return v.ToArray(); + } + Varint(1, 38); + Bytes(2, View(50)); // game role + Bytes(2, View(41)); // game rule wrapper + Bytes(2, View(15)); // a prop (tagged tool only) + Bytes(4, System.Text.Encoding.UTF8.GetBytes("someone-elses-activity-0000-000000000000")); + Bytes(5, System.Text.Encoding.UTF8.GetBytes("Serialized 3 persistence views")); + Varint(6, 3991471883933115623UL); + Bytes(18, new byte[] { 0x0A, 0x02, 0x08, 0x01 }); + Bytes(28, new byte[] { 0x0A, 0x02, 0x08, 0x01 }); + Varint(30, 138); + Varint(31, 1); + Varint(37, 1); + return ms.ToArray(); + } + + private static void WriteVarint(Stream s, ulong value) + { + while (value >= 0x80) { s.WriteByte((byte)(value | 0x80)); value >>= 7; } + s.WriteByte((byte)value); + } + + [Fact] + public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob() + { + using var setup = Client("rooms"); + var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + var roomId = 9_650_000 + Random.Shared.Next(1, 99_999); + const string saved = "room_saved_by_a_player_v3.dat"; + await using (var scope = _factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true; + db.Rooms.Add(new RoomEntity + { + Id = roomId, + Name = $"Saved{Guid.NewGuid():N}"[..18], + CreatorPlayerId = 1, + IsAGRoom = true, + TagsCsv = "recroomoriginal", + CurrentDataBlobName = saved, + }); + db.RoomScenes.Add(new RoomSceneEntity + { RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = saved }); + await db.SaveChangesAsync(); + } + + using var adminClient = Client("admin", admin); + var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable"); + Assert.False(enabled.GetProperty("changed").GetBoolean()); + Assert.Equal(saved, enabled.GetProperty("effectiveBlobName").GetString()); + } + + /// Count top-level persistence_views (field 2) entries by + /// walking the wire format — our server proto doesn't type field 2. + private static int CountTopLevelPersistenceViews(byte[] blob) + { + var pos = 0; + var count = 0; + while (pos < blob.Length) + { + var tag = ReadVarint(blob, ref pos); + var field = (int)(tag >> 3); + var wireType = (int)(tag & 7); + switch (wireType) + { + case 0: ReadVarint(blob, ref pos); break; + case 1: pos += 8; break; + case 2: + var len = (int)ReadVarint(blob, ref pos); + if (field == 2) count++; + pos += len; + break; + case 5: pos += 4; break; + default: throw new InvalidDataException($"wire type {wireType}"); + } + } + return count; + } + + private static ulong ReadVarint(byte[] input, ref int pos) + { + ulong value = 0; + var shift = 0; + while (true) + { + var b = input[pos++]; + value |= (ulong)(b & 0x7F) << shift; + if ((b & 0x80) == 0) return value; + shift += 7; + } + } + + private static JsonElement SubRoomZero(JsonElement details) => + details.GetProperty("SubRooms").EnumerateArray() + .First(e => e.GetProperty("SubRoomId").GetInt64() == 0); + + private static async Task GetJsonAsync(HttpClient client, string path) + { + using var response = await client.GetAsync(path); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}"); + return JsonDocument.Parse(body).RootElement.Clone(); + } + + private static async Task PostJsonAsync(HttpClient client, string path) + { + using var response = await client.PostAsync(path, content: null); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}"); + return JsonDocument.Parse(body).RootElement.Clone(); + } + + private HttpClient Client(string host, GameClientSession? session = null) + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } +} diff --git a/DorkNet.Server.Tests/data/client-routes-2023.tsv b/DorkNet.Server.Tests/data/client-routes-2023.tsv index 2078948..a5c44ad 100644 --- a/DorkNet.Server.Tests/data/client-routes-2023.tsv +++ b/DorkNet.Server.Tests/data/client-routes-2023.tsv @@ -523,3 +523,45 @@ POST thread/{0}/snooze social-clubs-chat GET unity_assets/{0}/{1}/{2} inventions-media GET voice/config moderation-safety GET voice/requiresModeration moderation-safety +# Requests the plain route-literal sweep missed. Each verified in the ISIL: +# - leading-slash literals (RecNet.Runtime): LCOCDKINDMN_NestedType_CIPKPLFLIPC.txt:218, +# PBOGNAPEBAI_NestedType_JKNCNOHGPHK.txt:251 (GET under 100 ids, POST at 100+), +# NHILNDLLLHH.txt:329/801, OKOOJMAOKFE.txt:561, MNANGNGKKDA_NestedType_JAMKPNKPJPD.txt:179 +# - Assembly-CSharp CDN fetch: JFDPIJFDHAD.txt:1432 (/sigs/, boot file-signature check) +# - DELETE subscription/{0}: IKMMOCKDKAF.txt LPFOPPHGDHG (verb 4) +# - account/me/{field}: composed at runtime by OPMAPIOEIFG.OBAGJBBIFGB("account/me/" + field); +# verbs from each caller (username/displayname/bio/birthday/emoji/personalpronouns/identityflags/bannerimage PUT=3, +# email/phone/confirmphone POST=2). +GET api/playerReputation/v2/bulk identity-account +POST api/playerReputation/v2/bulk identity-account +GET api/players/v2/progression/bulk identity-account +POST api/players/v2/progression/bulk identity-account +GET strings config-telemetry +GET config/{0} config-telemetry +GET data/{0} config-telemetry +GET sigs/{0} config-telemetry +DELETE subscription/{0} commerce-subscription +PUT account/me/username identity-account +PUT account/me/displayname identity-account +PUT account/me/bio identity-account +PUT account/me/birthday identity-account +POST account/me/email identity-account +POST account/me/phone identity-account +PUT account/me/emoji identity-account +PUT account/me/personalpronouns identity-account +PUT account/me/identityflags identity-account +PUT account/me/bannerimage identity-account +POST account/me/confirmphone identity-account +# Routes composed from a base literal plus a format/segment (BPAIBFBKKCN.txt:51-53, HPEBLAHNBLC.txt:38-40, +# and the LPPEFJMCJFN URL builder in LINKIECPDML.txt:677-737). +GET api/itemWishlists/v1/wishlist/me avatar-inventory +GET api/itemWishlists/v1/wishlist/{0} avatar-inventory +PUT api/itemWishlists/v1/wishlist/me/{0} avatar-inventory +DELETE api/itemWishlists/v1/wishlist/me/{0} avatar-inventory +GET api/roomEarningsDistributions/v1/earningsDistribution/{0} progression-misc +GET api/incentivizedreferrals/referrals progression-misc +# Contact lookup (OPMAPIOEIFG.txt:11590-11620) and composed paths (FFFIMAGLKEG.txt:137, NOGJJIIENIL.txt:29). +POST account/bulk/email identity-account +POST account/bulk/phone identity-account +POST api/avatar/v2/gifts/consume avatar-inventory +GET video/{0} config-telemetry diff --git a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs index 04cd86f..7986db5 100644 --- a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs +++ b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs @@ -57,17 +57,7 @@ public class AccountApiController( { Success = true, Error = string.Empty, - Account = new RecNetAccount - { - AccountId = (int)player.Id, - RawUsername = player.Username, - Username = player.Username, - DisplayName = player.DisplayName ?? player.Username, - ProfileImage = string.Empty, - TreatAsJunior = false, - HasBirthday = true, - Platforms = 1, - }, + Account = AccountDtos.Public(player), }); } @@ -77,22 +67,7 @@ public class AccountApiController( { var player = await GetCurrentPlayerAsync(); if (player is null) return Unauthorized(); - return Ok(new RecNetSelfAccount - { - AccountId = (int)player.Id, - RawUsername = player.Username, - Username = player.Username, - DisplayName = player.DisplayName ?? player.Username, - ProfileImage = player.ProfileImageName ?? string.Empty, - TreatAsJunior = false, - HasBirthday = true, - Platforms = 1, - Email = player.Email ?? string.Empty, - Phone = string.Empty, - Birthday = new DateTime(2000, 1, 1), - JuniorState = 0, - ParentAccountId = null, - }); + return Ok(AccountDtos.Self(player)); } /// diff --git a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs index 28df979..1dc3c63 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs @@ -50,7 +50,18 @@ public class AvatarGiftsController( return Ok(rows.Select(ToWire)); } - public sealed record GenerateGiftRequest(int? GiftContext, bool? IsGameGift, int? AlternateGiftContext, string? Message); + /// Body keys are the client's AFGEDDANEKP param literals + /// (GiftContext / IsGameGift / AlternateGiftContext / Message). A class + /// with settable properties, not a positional record, because + /// constructs it empty and + /// fills it. + public sealed class GenerateGiftRequest + { + public int? GiftContext { get; set; } + public bool? IsGameGift { get; set; } + public int? AlternateGiftContext { get; set; } + public string? Message { get; set; } + } /// POST api/avatar/v2/gifts/generate — the end-of- /// activity gift box (chest). For a game/quest gift (IsGameGift) the @@ -64,8 +75,13 @@ public class AvatarGiftsController( // POST 404'd, breaking reward chests on that path). Same request body. [HttpPost("api/avatar/v2/gifts/generate")] [HttpPost("api/avatar/v3/gifts/generate")] + // The client adds each field with AFGEDDANEKP (FFFIMAGLKEG.txt:1930-2013, + // POST = ordinal 2), which goes out form-urlencoded: a live trace showed + // `GiftContext=12000&IsGameGift=True&Message=…` answered 415 while this + // was [FromBody]. The form-or-JSON binder accepts either encoding. [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] - public async Task Generate([FromBody] GenerateGiftRequest? req) + public async Task Generate( + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] GenerateGiftRequest? req) { var pid = this.RequireCurrentPlayerId(); var isGameGift = req?.IsGameGift ?? false; diff --git a/DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs b/DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs index 7a81f7f..ad331e1 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs @@ -4,16 +4,21 @@ using Microsoft.EntityFrameworkCore; using System.Text.Json; using System.Text.Json.Serialization; using DorkNet.Server.Auth; +using DorkNet.Server.Controllers.API.Avatar.V2; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; namespace DorkNet.Server.Controllers.API.Avatar.V3; /// -/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire -/// type RecNet.SavedOutfit (per agent ISIL extraction): -/// Slot(int), PreviewImageName(string), OutfitSelections(string), -/// FaceFeatures(string), SkinColor(string), HairColor(string). +/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Client wire +/// type BEPMJICDNDD, whose converter IMMOJHBENFI registers exactly +/// Slot, PreviewImageName, OutfitSelections, OutfitSelectionsV2, +/// FaceFeatures, SkinColor, HairColor, CustomAvatarItems +/// (RecNet.Runtime/IMMOJHBENFI.txt:587-766 reader, :781-910 writer). +/// CustomAvatarItems is a list of BGNNOMBFMLH +/// {CustomAvatarItemId, BodyPart}, the same element the avatar +/// settings (api/avatar/v2) use. /// /// State persists in as /// a JSON list of records keyed by @@ -38,12 +43,27 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase } /// POST v3/saved/set — upsert one outfit by Slot. - /// Body is the SavedOutfit shape directly. + /// Verb ordinal 2 = POST with a raw JSON body of one serialised + /// BEPMJICDNDD (RecNet.Runtime/FFFIMAGLKEG.txt:4071-4095, NCILAADFJMM). + /// + /// Parsed by hand rather than [FromBody] SavedOutfitDto: the old + /// DTO silently dropped OutfitSelectionsV2 and CustomAvatarItems, and the + /// 2023 client wears OutfitSelectionsV2 whenever it is non-empty + /// (BEPMJICDNDD.NLGCNDMLACA, BEPMJICDNDD.txt:237-266, falls back to + /// OutfitSelections only when V2 is empty). Loading a saved slot + /// therefore restored a stale V1 selection (or nothing) and lost every + /// custom shirt in it. [HttpPost("api/avatar/v3/saved/set")] [HttpPost("api/avatar/v2/saved/set")] - public async Task Set([FromBody] SavedOutfitDto body) + public async Task Set() { + string raw; + using (var reader = new StreamReader(Request.Body)) + raw = await reader.ReadToEndAsync(); + + var body = ParseOutfit(raw); if (body is null) return BadRequest("missing body"); + var pid = this.RequireCurrentPlayerId(); var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid); if (avatar is null) @@ -59,6 +79,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player"); list.Add(body); } + list.Sort((a, b) => a.Slot.CompareTo(b.Slot)); avatar.SavedOutfitsJson = JsonSerializer.Serialize(list); avatar.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); @@ -81,12 +102,91 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase return Ok(); } + /// Read one outfit object, matching keys case-insensitively + /// (the client reader accepts Pascal/camel/lower spellings, so any of + /// them is a valid write). String fields tolerate null; a nested + /// object/array is kept as its raw JSON text, mirroring how + /// avatar/v2/set treats the same fields. Returns null for a body + /// that is not a JSON object. + internal static SavedOutfitDto? ParseOutfit(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + try + { + using var doc = JsonDocument.Parse(raw); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return null; + + var dto = new SavedOutfitDto(); + foreach (var prop in root.EnumerateObject()) + { + switch (prop.Name.ToLowerInvariant()) + { + case "slot": + if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var slot)) dto.Slot = slot; + else if (prop.Value.ValueKind == JsonValueKind.String && int.TryParse(prop.Value.GetString(), out slot)) dto.Slot = slot; + break; + case "previewimagename": dto.PreviewImageName = AsString(prop.Value); break; + case "outfitselections": dto.OutfitSelections = AsString(prop.Value); break; + case "outfitselectionsv2": dto.OutfitSelectionsV2 = AsString(prop.Value); break; + case "facefeatures": dto.FaceFeatures = AsString(prop.Value); break; + case "skincolor": dto.SkinColor = AsString(prop.Value); break; + case "haircolor": dto.HairColor = AsString(prop.Value); break; + case "customavataritems": dto.CustomAvatarItems = AsCustomItems(prop.Value); break; + } + } + return dto; + } + catch (JsonException) + { + return null; + } + } + + private static string AsString(JsonElement v) => v.ValueKind switch + { + JsonValueKind.String => v.GetString() ?? string.Empty, + JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, + _ => v.GetRawText(), + }; + + /// BGNNOMBFMLH list. Elements without a parseable guid are + /// dropped instead of failing the whole save. + private static List AsCustomItems(JsonElement v) + { + var result = new List(); + if (v.ValueKind != JsonValueKind.Array) return result; + foreach (var el in v.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + Guid? id = null; + byte bodyPart = 0; + foreach (var p in el.EnumerateObject()) + { + if (p.Name.Equals("CustomAvatarItemId", StringComparison.OrdinalIgnoreCase) + && p.Value.ValueKind == JsonValueKind.String + && Guid.TryParse(p.Value.GetString(), out var g)) + id = g; + else if (p.Name.Equals("BodyPart", StringComparison.OrdinalIgnoreCase) + && p.Value.ValueKind == JsonValueKind.Number + && p.Value.TryGetByte(out var b)) + bodyPart = b; + } + if (id is { } guid) result.Add(new CustomAvatarItemRefDto { CustomAvatarItemId = guid, BodyPart = bodyPart }); + } + return result; + } + private static List ParseList(string? json) { if (string.IsNullOrWhiteSpace(json)) return new(); try { - return JsonSerializer.Deserialize>(json) ?? new(); + var list = JsonSerializer.Deserialize>(json) ?? new(); + // Rows written before CustomAvatarItems existed carry no key; + // keep the wire array non-null. + foreach (var o in list) o.CustomAvatarItems ??= new(); + return list; } catch { return new(); } } @@ -97,7 +197,14 @@ public sealed class SavedOutfitDto [JsonPropertyName("Slot")] public int Slot { get; set; } [JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty; [JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty; + /// The selection the 2023 client actually wears when non-empty + /// (BEPMJICDNDD.NLGCNDMLACA). Rows saved before this field existed + /// deserialize to "" and the client falls back to OutfitSelections. + [JsonPropertyName("OutfitSelectionsV2")] public string OutfitSelectionsV2 { get; set; } = string.Empty; [JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty; [JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty; [JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty; + /// Custom shirts worn in this slot. Always an array on the wire + /// (the client's ctor allocates an empty list, BEPMJICDNDD.txt:321-360). + [JsonPropertyName("CustomAvatarItems")] public List CustomAvatarItems { get; set; } = new(); } diff --git a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs index 7f358f8..5ee173e 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs @@ -173,7 +173,12 @@ public class AvatarItemsController( { AvatarItemType = item.AvatarItemType, AvatarItemDesc = ToWatchDesc(entry.FullDesc), - AvatarItemId = item.AvatarItemId, + // Hash the FULL desc, as the toggle-on path does: a + // player can own the base item and several colorways of + // it at once (entries dedupe by FullDesc), and reusing the + // base id made them collide in the client's id-keyed + // wardrobe. + AvatarItemId = StableAvatarItemIdFull(ToWatchDesc(entry.FullDesc)), PlatformMask = item.PlatformMask, FriendlyName = item.FriendlyName, Tooltip = item.Tooltip, @@ -328,20 +333,19 @@ public class AvatarItemsController( owned.Add(guid); } + // The client parses this as List — the SAME element as + // v4/items (FFFIMAGLKEG.txt:3222-3465, NBLIDNCEELM), read by + // CLIANCNHNIE which registers TagList / AvatarItemId / + // IsBaseAvatarItem too (CLIANCNHNIE.txt). The old 5-key subset left + // every locked row at AvatarItemId 0 with a null TagList, so ship + // the full catalog DTO. var catalog = _catalog.Value.Items; var rows = requested .Where(guid => !owned.Contains(guid)) .Distinct(StringComparer.OrdinalIgnoreCase) .Select(guid => catalog.TryGetValue(guid, out var item) ? item : null) .Where(item => item is not null) - .Select(item => new - { - item!.AvatarItemType, - item.AvatarItemDesc, - item.FriendlyName, - item.Tooltip, - item.Rarity, - }) + .Select(item => item!) .ToArray(); return Ok(rows); } @@ -561,9 +565,8 @@ public class AvatarItemsController( /// Stable, deterministic, non-zero 31-bit id for an owned /// avatar item, derived from its base GUID (FNV-1a). The 2023 client /// keys the wardrobe by AvatarItemId, so it must be unique per - /// item and stable across restarts. Colored variants reuse their base - /// item's id (a player owns the base OR a variant, never both in the - /// list), so no collision. + /// item and stable across restarts. Colored variants must NOT use this — a player can own the base and + /// several colorways together; see . public static int StableAvatarItemId(string? avatarItemDescOrGuid) { var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0]; diff --git a/DorkNet.Server/Controllers/API/Comments/CommentsController.cs b/DorkNet.Server/Controllers/API/Comments/CommentsController.cs index 54da8c8..06c69fa 100644 --- a/DorkNet.Server/Controllers/API/Comments/CommentsController.cs +++ b/DorkNet.Server/Controllers/API/Comments/CommentsController.cs @@ -153,22 +153,30 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase var me = Me; var lastRead = await LastReadAsync(me); - // Key + owner only: the payload column is irrelevant to counting. - var keys = await db.PlayerSettings - .Where(s => s.Key.StartsWith(CommentPrefix)) - .Select(s => new { s.Key, s.PlayerId }) - .ToListAsync(); - var counts = new Dictionary(); foreach (var id in roomIds) counts[id] = 0u; - foreach (var k in keys) + // One bounded query per requested room instead of materialising the + // entire comment keyspace: the room filter and the "your own notes + // are never unread" filter both run in SQL; only the numeric + // comment-id suffix still needs parsing here. Key + owner only: the + // payload column is irrelevant to counting. + foreach (var id in roomIds) { - if (!TrySplitCommentKey(k.Key, out var roomId, out var commentId)) continue; - if (!counts.ContainsKey(roomId)) continue; - if (k.PlayerId == me) continue; // your own notes are never unread - if (commentId <= lastRead.GetValueOrDefault(roomId)) continue; - counts[roomId]++; + var prefix = $"{CommentPrefix}{id}:"; + var keys = await db.PlayerSettings + .Where(s => s.Key.StartsWith(prefix) && s.PlayerId != me) + .Select(s => s.Key) + .ToListAsync(); + + var floor = lastRead.GetValueOrDefault(id); + foreach (var key in keys) + { + if (!TrySplitCommentKey(key, out var roomId, out var commentId)) continue; + if (roomId != id) continue; + if (commentId <= floor) continue; + counts[id]++; + } } var result = roomIds @@ -260,8 +268,11 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase /// return non-zero numbers for everyone else). /// /// OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId) - /// (OPELMBNJHNO.txt:1375) returns the status-only LDGADANDBIO, not a - /// DTO — the body is never parsed, only the status code. Verb is PUT + /// (OPELMBNJHNO.txt:1375) returns the untyped LDGADANDBIO promise, but + /// it is built from the body parsed as a RecNetResult: the continuation + /// <UpdateLatestReadComment>b__9_1(DJMHAFPGLLN) + /// (OPELMBNJHNO_NestedType___c.txt:333) null-derefs an empty body and + /// rejects on Success=false — see . Verb is PUT /// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments /// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so /// the request carries no body at all. @@ -272,7 +283,7 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase { await SetLastReadAsync(Me, roomId, commentId); await db.SaveChangesAsync(); - return Ok(); + return RecNetResults.Ok(); } /// Deletes a comment. diff --git a/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs b/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs index 52222c5..ac4d244 100644 --- a/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs +++ b/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs @@ -129,14 +129,19 @@ public class ConfigController(ConfigService configService) : ControllerBase /// Returns a broad default set so the picker is populated. [HttpGet("/emojiConfig/whitelistedEmojis")] [Microsoft.AspNetCore.Authorization.AllowAnonymous] - public IActionResult WhitelistedEmojis() => Ok(new[] + public IActionResult WhitelistedEmojis() => Ok(WhitelistedEmojiList); + + /// The emoji allow-list served above. Also the validation set + /// for PUT account/me/emoji, so a player can only pick an emoji + /// the client's own picker offered. + public static readonly IReadOnlyList WhitelistedEmojiList = new[] { "😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘", "😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯", "😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌", "🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨", "🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆", - }); + }; /// GET api/config/v1/freegiftbutton — the /// 2020.12 client deserializes this endpoint as a raw Boolean. diff --git a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs index 2d2c93d..a52a7d1 100644 --- a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs @@ -371,11 +371,22 @@ public class CustomAvatarItemsController( var allowed = player is not null && !player.IsJunior && (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow); - // Bare JSON boolean, like the two sibling gates below: the client's - // issuing method is FGLDKEJLAKB GGMDLMLJDDK() - // (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an - // object body threw and the can-create gate blocked shirt creation. - return Content(allowed ? "true" : "false", "application/json"); + // NOT a bare boolean, unlike the two sibling gates below. The issuing + // method GGMDLMLJDDK (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route on + // :6205) parses the body as DJMHAFPGLLN — {Boolean @0x10, String} — + // (generic token 0x188477F70 at :6216; Func at :6223) + // and its success continuation HONEEPBHNLB reads the bool at +0x10 + // (:9155-9216). + // DJMHAFPGLLN's converter LMCCNLLHLCJ registers exactly the keys + // "Success" and "Error" (LMCCNLLHLCJ.txt:191-254). A bare `true` + // failed that parse, so the client logged "Failed to query if local + // account can create Custom Avatar Items: An error occurred" and + // treated the account as unable to create shirts. + return Ok(new Dictionary + { + ["Success"] = allowed, + ["Error"] = allowed ? null : "Custom avatar item creation is not available for this account.", + }); } [HttpGet("isCreationEnabled")] @@ -386,22 +397,17 @@ public class CustomAvatarItemsController( [AllowAnonymous] public IActionResult IsRenderingEnabled() => Content("true", "application/json"); - /// The client DTO (IHLMDLKOLNB) is an object with exactly one - /// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere - /// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are - /// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT - /// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft - /// ignores the ones that don't match, and a total miss falls back to the - /// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing. + /// Bare JSON integer. The client's IHLMDLKOLNB pricing config is + /// built CLIENT-side: HCBOHHEACPK sends through FDKKOPAPDGF<Int32> + /// (RecNet.Runtime/MPBLNLMCEDL.txt:6922 — the same generic token + /// 0x18847B0B8 as the bare-Int32 itemOwnershipLimit call at :2981) and + /// continues with <GetPricingConfig>b__69_0(Int32 minimumPrice) + /// (MPBLNLMCEDL_NestedType___c.txt:286). An object body failed the Int32 + /// parse, so the designer's public-price floor never loaded. [HttpGet("minPriceForPublicItem")] [AllowAnonymous] - public IActionResult MinPriceForPublicItem() => Ok(new - { - MinPrice = MinPublicItemPrice, - Price = MinPublicItemPrice, - MinimumPrice = MinPublicItemPrice, - MinPriceForPublicItem = MinPublicItemPrice, - }); + public IActionResult MinPriceForPublicItem() + => Content(MinPublicItemPrice.ToString(System.Globalization.CultureInfo.InvariantCulture), "application/json"); /// GET api/customAvatarItems/v1/design — hand back the caller's /// saved in-progress shirt design. Verb ordinal 0 = GET diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs index 221d0ff..163490b 100644 --- a/DorkNet.Server/Controllers/API/Images/ImagesController.cs +++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs @@ -584,6 +584,20 @@ public class ImagesController( // ReportEntity has no TargetPhotoId column, so the photo id + blob name // ride in the message text the same way club reports carry "[club {id}]" // — a moderator needs the actual image to act on the report. + // + // One open report per (reporter, photo): the call is fire-and-forget + // and the client can't tell 200 from 404, so retries and re-opens are + // likely — same guard as the custom-avatar-item report path. + var marker = $"[photo {photo.Id} "; + var duplicate = await db.Reports.AnyAsync(r => + r.ReporterPlayerId == reporter && r.ResolvedAt == null && r.Message.StartsWith(marker)); + if (duplicate) + { + logger.LogInformation( + "[images] duplicate report of photo {PhotoId} by {Reporter} ignored", photo.Id, reporter); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + var message = $"[photo {photo.Id} {photo.BlobName}] reported from the in-game photo viewer"; db.Reports.Add(new ReportEntity { diff --git a/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs b/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs index 6ac11b8..e26d5cf 100644 --- a/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs +++ b/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs @@ -34,6 +34,58 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l }); } + /// The caller's referred players, paged. + /// + /// FGLDKEJLAKB<GPJIMAHMAIO> EGEEFJHAGAM(Int32? take, String continuationToken) + /// (RecNet.Runtime/LINKIECPDML.txt:337) builds this through the URL builder + /// LPPEFJMCJFN ("api/incentivizedreferrals/" + "referrals", :677-701, with + /// take and continuationToken query params at :716/:737) — which + /// is why the plain route-literal sweep never saw it — and issues a GET + /// (:164-170). Reply formatter KOEEACLMAFE.txt:203-238: + /// {Referrals: [...], ContinuationToken: string}; each entry + /// (ADAFNGNADFH.txt:279-346) is {InviteeAccountId: Int32, CreatedAt: + /// DateTime, VerifiedAt: DateTime?}. + /// + /// A referral is recorded when a brand-new player redeems the caller's + /// referral code (LinkController.CreditReferralAsync), and that redemption + /// is what counts it, so it is verified at the moment it is created. The + /// continuation token is the next offset; null once the list is exhausted. + [HttpGet("referrals")] + public async Task Referrals([FromQuery] int? take, [FromQuery] string? continuationToken) + { + var size = Math.Clamp(take ?? 20, 1, 100); + var offset = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0; + var prefix = $"referral:credited:{Me}:"; + + var rows = await db.PlayerSettings + .Where(s => s.Key.StartsWith(prefix)) + .OrderByDescending(s => s.Id) + .Skip(offset) + .Take(size + 1) + .Select(s => new { s.PlayerId, s.Value }) + .ToListAsync(); + + var page = rows.Take(size).Select(r => + { + var at = DateTime.TryParse(r.Value, null, + System.Globalization.DateTimeStyles.RoundtripKind, out var credited) + ? credited + : DateTime.UnixEpoch; + return new + { + InviteeAccountId = (int)r.PlayerId, + CreatedAt = at, + VerifiedAt = (DateTime?)at, + }; + }).ToList(); + + return Ok(new + { + Referrals = page, + ContinuationToken = rows.Count > size ? (offset + size).ToString() : null, + }); + } + // Client contract: the claim response deserializes as a top-level // List where each entry is // { rewardSelectionId: long, rewardType: string } (RecNet.Runtime diff --git a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs index 6248f2b..8baa25b 100644 --- a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs +++ b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs @@ -208,8 +208,10 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con /// DELETE api/keepsakes/{guid} - NCCLEJPIABA.JBKENCNIEPA(System.Guid) builds /// String.Format("{0}/{1}", "api/keepsakes", guid) and passes verb 4 (DELETE) to the /// BNDIAONDFFF ctor (NCCLEJPIABA.txt:1544, 1687-1698 - Move rdx, 4). Its return type is - /// the non-generic LDGADANDBIO promise, so no response body is deserialised; a bare 200 - /// is enough. Client-side the tool refuses unless the caller is at least a room co-owner + /// the non-generic LDGADANDBIO promise, but that promise comes from + /// BFDGEKKNAPB.PEFKBAEAOPD (ExpandRecNetResult) — the body IS parsed, as a + /// {success,error} RecNetResult, and an empty 200 null-derefs into a failed delete. + /// See . Client-side the tool refuses unless the caller is at least a room co-owner /// ("Must be at least a room co-owner to modify keepsakes.", /// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side. /// @@ -235,7 +237,7 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con db.Keepsakes.RemoveRange(orphanedCollections .Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId)); await db.SaveChangesAsync(); - return Ok(); + return RecNetResults.Ok(); } /// diff --git a/DorkNet.Server/Controllers/API/Photos/PhotosController.cs b/DorkNet.Server/Controllers/API/Photos/PhotosController.cs index d42e86b..bac9501 100644 --- a/DorkNet.Server/Controllers/API/Photos/PhotosController.cs +++ b/DorkNet.Server/Controllers/API/Photos/PhotosController.cs @@ -401,8 +401,10 @@ public class PhotosController( itemIds.AddRange(injectedIds.Where(pinned.Contains)); } + // Utc kind explicitly: the ticks came from a UtcNow-derived CreatedAt, + // and Npgsql refuses to write an Unspecified DateTime to timestamptz. DateTime? cutoffAt = resumed && cursorTicks > 0 && cursorTicks <= DateTime.MaxValue.Ticks - ? new DateTime(cursorTicks) + ? new DateTime(cursorTicks, DateTimeKind.Utc) : (DateTime?)null; var cutoffId = resumed ? cursorId : 0L; if (phase < 0) phase = 0; diff --git a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs index 313abd1..261da81 100644 --- a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs @@ -1145,10 +1145,17 @@ public class PlayerEventsController( extras.SupportMultiInstanceRoomChat = await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat", "SupportMultiInstanceRoomChat") ?? false; - extras.DefaultBroadcastPermissions = - await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0; - extras.CanRequestBroadcastPermissions = - await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0; + // These two are boxed enums (typeof(AKFCIMLMAKA) at + // CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:117-165), and a boxed non-string + // field goes through Object.ToString (BNDIAONDFFF.txt:947, ISIL 107-110), + // so they arrive as member NAMES. int.TryParse failed on every one and + // the settings were always stored as 0. + extras.DefaultBroadcastPermissions = ParseBroadcastPermissions( + await ReadStringFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions")) + ?? extras.DefaultBroadcastPermissions; + extras.CanRequestBroadcastPermissions = ParseBroadcastPermissions( + await ReadStringFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions")) + ?? extras.CanRequestBroadcastPermissions; await SaveExtrasAsync(evt, extras); return Ok(await WrapperAsync(evt)); @@ -1437,6 +1444,33 @@ public class PlayerEventsController( : null; } + /// Broadcast-permission mode as a number. Members per the + /// 2023.06.21 dump (enum KJJFEBEMPKM, mapped by + /// EventsConfig.BroadcastPermissionsModeFriendlyNameMapping): None = 0, + /// RoomOwners = 256, All = Int32.MaxValue. Accepts the number itself (JSON + /// bodies send numbers), a member name, or a comma-joined flags string. + /// Null when the value is absent or unrecognised, so the stored setting is + /// kept rather than silently zeroed. + public static int? ParseBroadcastPermissions(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) return n; + var total = 0; + foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + int? value = part.ToLowerInvariant() switch + { + "none" => 0, + "roomowners" => 256, + "all" => int.MaxValue, + _ => null, + }; + if (value is null) return null; + total |= value.Value; + } + return total; + } + private async Task ReadIntFieldAsync(params string[] names) { var value = await ReadStringFieldAsync(names); diff --git a/DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs b/DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs index 3062512..6ce9284 100644 --- a/DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs @@ -48,14 +48,18 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase .Where(p => p.Id == me) .Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam)) .FirstOrDefaultAsync(); + // Both calls are read as DJMHAFPGLLN = {Success, Error} (the formatter, + // RecNet.Runtime/LMCCNLLHLCJ.txt:191-218, knows no "Message" key). A + // failure without Error reached Promise.Rejected(null) and threw + // "Error message cannot be null" instead of showing the reason. if (moderator != true) - return Ok(new { Success = false, Message = "not_authorized" }); + return Ok(new { Success = false, Error = "not_authorized", Message = "not_authorized" }); var target = warnedPlayerId ?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0); - if (target <= 0) return Ok(new { Success = false, Message = "missing_player" }); + if (target <= 0) return Ok(new { Success = false, Error = "missing_player", Message = "missing_player" }); if (!await db.Players.AnyAsync(p => p.Id == target)) - return Ok(new { Success = false, Message = "player_not_found" }); + return Ok(new { Success = false, Error = "player_not_found", Message = "player_not_found" }); // Same "warning:{id}|message|category|createdAt|acknowledged" layout the // GET list handler parses in ToWarningWire. @@ -75,7 +79,7 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase "false"), }); await db.SaveChangesAsync(); - return Ok(new { Success = true, Message = string.Empty, WarningId = id }); + return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = id }); } /// The 2023-03-21 client's acknowledge call takes NO arguments — @@ -104,19 +108,19 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase .OrderByDescending(s => s.Id) .ToListAsync(); row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged); - if (row is null) return Ok(new { Success = true, Message = string.Empty }); + if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty }); } else { row = await db.PlayerSettings .FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}"); - if (row is null) return Ok(new { Success = true, Message = string.Empty }); + if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty }); } var parts = row.Value.Split('|'); row.Value = string.Join('|', parts.Take(3).Append("true")); await db.SaveChangesAsync(); - return Ok(new { Success = true, Message = string.Empty, WarningId = row.Key["warning:".Length..] }); + return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = row.Key["warning:".Length..] }); } private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row) diff --git a/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs b/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs index eacce1d..f385c74 100644 --- a/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs +++ b/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs @@ -226,6 +226,11 @@ public class ProgressionEventsController( IsValid = true, SupportsCurrentPlatform = true, }; + // The sentinel key needs the generated gift id, so this takes two + // saves — but they must commit together: a crash between them would + // leave a gift with no sentinel, and the next Collect would mint the + // reward a second time. + await using var tx = await db.Database.BeginTransactionAsync(); db.GiftPackages.Add(gift); await db.SaveChangesAsync(); @@ -237,6 +242,7 @@ public class ProgressionEventsController( ClearedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); + await tx.CommitAsync(); return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift)); } diff --git a/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs b/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs index 9b8d2e4..f0b5c39 100644 --- a/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs +++ b/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs @@ -284,7 +284,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : offer.IsDeleted = true; offer.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - return Ok(ToOfferWire(offer, currency)); + // MIHKAJFMIPE.OHCONDGELJB hands this body to BFDGEKKNAPB.PEFKBAEAOPD + // (ExpandRecNetResult): it is a {success,error} RecNetResult, not the + // offer. The offer wire has no Success key, so it parsed as a failure + // with a null Error and the delete toast threw instead of closing. + return RecNetResults.Ok(); } /// Both verbs are mapped on purpose: the client picks GET or POST at diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index 775dfb4..a0bbbe4 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -1642,7 +1642,21 @@ public class RoomsModerationController( var newBlob = CloneBlobName(cloneId, orderIndex); var (dstBucket, dstKey) = BlobRouter.Route(newBlob); - await storage.PutAsync(dstBucket, dstKey, bytes, "application/octet-stream"); + try + { + await storage.PutAsync(dstBucket, dstKey, bytes, "application/octet-stream"); + } + catch (Exception ex) + { + // Same reasoning as the read side: the clone row is already + // committed, so a storage outage must degrade to an empty + // sub-room, not a 500 that leaves a failed-looking clone still + // pointing at the source's data. + logger.LogWarning(ex, + "[room-clone] could not write blob {Blob}; room {Room} sub-room {Sub} starts empty", + newBlob, cloneId, orderIndex); + return null; + } db.RoomDataBlobs.Add(new RoomDataBlobEntity { diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs index 01196e4..17bfd51 100644 --- a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs @@ -654,13 +654,28 @@ public class StorefrontsBuyController( $"buyPurchaseReminder:{item.Slug}"); var giftId = await GrantWithGiftBoxAsync(pid, item, currencyType); - db.ObjectiveProgress.Add(new ObjectiveProgressEntity + // Upsert: (PlayerId, Key) is unique and the same reminder can be + // bought again — a blind Add threw "UNIQUE constraint failed" and + // turned the second purchase into a 500 AFTER the currency had + // already been taken and the gift saved. + var progressKey = $"purchaseReminder:{req.PurchaseReminderId}"; + var progress = await db.ObjectiveProgress + .FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == progressKey); + if (progress is null) { - PlayerId = pid, - Key = $"purchaseReminder:{req.PurchaseReminderId}", - IsCompleted = true, - ClearedAt = DateTime.UtcNow, - }); + db.ObjectiveProgress.Add(new ObjectiveProgressEntity + { + PlayerId = pid, + Key = progressKey, + IsCompleted = true, + ClearedAt = DateTime.UtcNow, + }); + } + else + { + progress.IsCompleted = true; + progress.ClearedAt = DateTime.UtcNow; + } await db.SaveChangesAsync(); return Ok(PurchaseReminderResponse(newBalance, currencyType, item.Slug, giftId)); diff --git a/DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs b/DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs index 127d22b..68fb877 100644 --- a/DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs +++ b/DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs @@ -177,7 +177,10 @@ public class SubscriptionsController( } await db.SaveChangesAsync(); - return Ok(); + // The client reads this body as DJMHAFPGLLN, the {success,error} + // RecNetResult (FGLDKEJLAKB at IKMMOCKDKAF.txt LPFOPPHGDHG, + // instr 098) — answer in that shape rather than an empty 200. + return RecNetResults.Ok(); } /// diff --git a/DorkNet.Server/Controllers/Accounts/AccountsController.cs b/DorkNet.Server/Controllers/Accounts/AccountsController.cs index 96970fc..d6c7af9 100644 --- a/DorkNet.Server/Controllers/Accounts/AccountsController.cs +++ b/DorkNet.Server/Controllers/Accounts/AccountsController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DorkNet.Models.Auth; @@ -60,7 +61,7 @@ public class AccountsController( { var player = await GetCurrentPlayerAsync(); if (player is null) return Unauthorized(); - return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); + return Ok(AccountDtos.Self(player)); } /// @@ -193,7 +194,7 @@ public class AccountsController( { var player = await playerService.GetByIdAsync(accountId); if (player is null) return NotFound(); - return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); + return Ok(AccountDtos.Public(player)); } // Accounts.GetAccountByUsername @@ -202,7 +203,7 @@ public class AccountsController( { var player = await playerService.GetByUsernameAsync(username); if (player is null) return NotFound(); - return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); + return Ok(AccountDtos.Public(player)); } // Accounts.CreateAccount — actual URL observed in server logs: @@ -262,7 +263,7 @@ public class AccountsController( { Success = true, Error = string.Empty, - Account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName), + Account = AccountDtos.Public(player), }); } @@ -285,7 +286,7 @@ public class AccountsController( var clamped = Math.Clamp(take, 1, 50); var matches = await playerService.SearchAsync(term.Trim(), clamped); return Ok(matches - .Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName)) + .Select(p => AccountDtos.Public(p)) .ToList()); } @@ -301,6 +302,12 @@ public class AccountsController( [HttpPost("/account/v1/emails")] [HttpPost("/account/v1/phones")] [HttpPost("/account/v1/byplatformid")] + // 2023 contact lookup: OPMAPIOEIFG.HHAAAPKCLMN (RecNet.Runtime/OPMAPIOEIFG.txt:11864) + // POSTs "account/bulk/" + "email" or "phone" (:11590-11620, verb 2) with the + // values as repeated form fields of the same name, and reads the same account + // list (List) as account/bulk. Both suffixes 404'd. + [HttpPost("/account/bulk/email")] + [HttpPost("/account/bulk/phone")] public async Task Bulk() { // The watch calls this route family with payloads keyed by @@ -400,7 +407,7 @@ public class AccountsController( { var p = await playerService.GetByIdAsync(id); if (p is not null) - accounts.Add(BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName)); + accounts.Add(AccountDtos.Public(p)); } return Ok(accounts); } @@ -432,8 +439,11 @@ public class AccountsController( } // Phone-confirm is the one mutation we don't persist — there's no - // SMS provider in the loop. Acknowledge silently. + // SMS provider in the loop. Acknowledge silently. The 2023 client + // composes the route as "account/me/" + "confirmphone" with POST + // (OPMAPIOEIFG.OBAGJBBIFGB) and reads the RecNetResult body. [HttpPost("/account/v1/confirmphone")] + [HttpPost("/account/me/confirmphone")] public IActionResult ConfirmPhone() => Ok(new RecNetResult { Success = true, Error = string.Empty }); @@ -628,6 +638,126 @@ public class AccountsController( return Ok(new { success = true, error = "" }); } + // ── 2023 profile edits ──────────────────────────────────────────── + // All four go through OPMAPIOEIFG.OBAGJBBIFGB, which builds + // "account/me/" + suffix (RecNet.Runtime OPMAPIOEIFG.txt:8579) and + // reports "Failed to modify " on any non-2xx (:8609). Every call + // site passes verb 3 = PUT. The form field name is NOT the suffix — each + // comes from the lambda that fills the request: + // emoji → "displayEmoji" (OPMAPIOEIFG_NestedType_KPGNAEPGLAC.txt:57) + // personalpronouns → "pronounFlags" (…_LCCEHLBIOPK.txt:67, boxed Int32) + // identityflags → "identityFlags" (…_HHDFMJPLFHG.txt:67, boxed Int32) + // bannerimage → "imageName" (…_NJKHNGEPODH.txt:57) + // The response body is never parsed (LDGADANDBIO handle), so the new + // value reaches the editor's UI and other players only through the + // AccountUpdate / SelfAccountUpdate push in NotificationService. + // None of these routes existed before, so every edit 404'd. + + /// PUT /account/me/emoji — form displayEmoji. + /// Empty clears the emoji; anything else must be on the + /// emojiConfig/whitelistedEmojis list the picker was filled + /// from, and is stored in that list's canonical spelling. + [HttpPut("/account/me/emoji")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task ChangeEmoji([FromForm(Name = "displayEmoji")] string? displayEmoji) + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + + var submitted = displayEmoji?.Trim() ?? string.Empty; + string stored; + if (submitted.Length == 0) + { + stored = string.Empty; + } + else + { + var canonical = ResolveWhitelistedEmoji(submitted); + if (canonical is null) + return BadRequest(new RecNetResult { Success = false, Error = "emoji_not_allowed" }); + stored = canonical; + } + + player.DisplayEmoji = stored; + await db.SaveChangesAsync(); + await notifications.AccountChanged(player); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + /// Whitelist match that ignores U+FE0F variation selectors, so + /// "❤" and "❤️" resolve to the same entry. Returns the list's spelling + /// (the client highlights the current pick by string equality against + /// the list it fetched). + internal static string? ResolveWhitelistedEmoji(string value) + { + static string Strip(string s) => s.Replace("️", string.Empty, StringComparison.Ordinal); + var needle = Strip(value); + foreach (var e in API.Config.V1.ConfigController.WhitelistedEmojiList) + if (string.Equals(Strip(e), needle, StringComparison.Ordinal)) return e; + return null; + } + + /// PUT /account/me/personalpronouns — form + /// pronounFlags (pronoun flags enum as Int32). + [HttpPut("/account/me/personalpronouns")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task ChangePersonalPronouns([FromForm(Name = "pronounFlags")] string? pronounFlags) + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + if (!TryParseFlags(pronounFlags, out var flags)) + return BadRequest(new RecNetResult { Success = false, Error = "invalid_pronouns" }); + + player.PersonalPronouns = flags; + await db.SaveChangesAsync(); + await notifications.AccountChanged(player); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + /// PUT /account/me/identityflags — form + /// identityFlags (identity flags enum as Int32). + [HttpPut("/account/me/identityflags")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task ChangeIdentityFlags([FromForm(Name = "identityFlags")] string? identityFlags) + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + if (!TryParseFlags(identityFlags, out var flags)) + return BadRequest(new RecNetResult { Success = false, Error = "invalid_identity_flags" }); + + player.IdentityFlags = flags; + await db.SaveChangesAsync(); + await notifications.AccountChanged(player); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + /// Flags arrive as the boxed enum's Int32 ToString(). Negative + /// values are not valid bitmasks for either enum. + private static bool TryParseFlags(string? raw, out int flags) + => int.TryParse(raw?.Trim(), System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out flags) + && flags >= 0; + + /// PUT /account/me/bannerimage — form imageName, + /// the storage name of an image the player already uploaded (the + /// portfolio-banner picker offers the player's own photos). Empty clears + /// the banner. + [HttpPut("/account/me/bannerimage")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task ChangeBannerImage([FromForm(Name = "imageName")] string? imageName) + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + var name = imageName?.Trim() ?? string.Empty; + if (name.Length > 256) + return BadRequest(new RecNetResult { Success = false, Error = "invalid_image" }); + + player.BannerImageName = name.Length == 0 ? null : name; + await db.SaveChangesAsync(); + await notifications.AccountChanged(player); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + // ParentalControl.Deserialize at RVA 0x14543A0 — required keys: // accountId (int, GetKey) // disallowInAppPurchases (bool, GetKey) @@ -679,63 +809,56 @@ public class AccountsController( var token = Guid.NewGuid().ToString("N"); var expiresAt = DateTime.UtcNow.AddMinutes(10); - await SetPlayerSettingAsync(player.Id, "remote_login_token", $"{token}|{expiresAt:O}"); - return Ok(new - { - Success = true, - Error = string.Empty, - Token = token, - LoginToken = token, - ExpiresAt = expiresAt, - }); + await SetPlayerSettingAsync(player.Id, RemoteLoginTokenKey, $"{token}|{expiresAt:O}"); + // FGLDKEJLAKB LGKLFFPACGL() (RecNet.Runtime/ + // LBNJFPOLCDL.txt:8382) deserialises the reply as a BARE JSON string. + // The old {Success, Token, ...} object hit the string reader's '"' + // check and every "open on the web" link failed with "Failed to + // CreateLoginToken". The token is redeemed by connect/token + // grant_type=login_token (AuthController.Token). + return Content(System.Text.Json.JsonSerializer.Serialize(token), "application/json"); } + /// PlayerSettings key of the one-shot token minted above. + /// Value is {token}|{expiresAtUtc:O}. + public const string RemoteLoginTokenKey = "remote_login_token"; + + /// In-game approval of another device's login code. + /// + /// LDGADANDBIO BJMMDKNPCLD(String) (RecNet.Runtime/LBNJFPOLCDL.txt:7896) + /// POSTs the single form field code and reads the reply through + /// BFDGEKKNAPB.PEFKBAEAOPD as a {success,error} RecNetResult + /// (failure toast "Failed to authorize login"). The code is the + /// user_code that connect/deviceauthorization handed the other + /// device, which meanwhile polls connect/token with + /// grant_type=urn:ietf:params:oauth:grant-type:device_code + /// (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451). Approving binds the + /// pending authorization to the CALLER, and that device's next poll is + /// issued the caller's tokens (). + /// + /// This used to look the code up among createlogintoken tokens — which a + /// user_code never matches, so every approval 401'd — and answered with a + /// token pair, which is not a RecNetResult either. [HttpPost("/account/me/remoteauth")] [HttpGet("/account/me/remoteauth")] public async Task RemoteAuth() { - var token = Request.Query["token"].FirstOrDefault() - ?? Request.Query["loginToken"].FirstOrDefault() - ?? Request.Query["code"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(token) && Request.HasFormContentType) + var approver = await GetCurrentPlayerAsync(); + if (approver is null) return Unauthorized(); + + var code = Request.Query["code"].FirstOrDefault() + ?? Request.Query["user_code"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(code) && Request.HasFormContentType) { var form = await Request.ReadFormAsync(); - token = form["token"].FirstOrDefault() - ?? form["loginToken"].FirstOrDefault() - ?? form["code"].FirstOrDefault(); + code = form["code"].FirstOrDefault() ?? form["user_code"].FirstOrDefault(); } + if (string.IsNullOrWhiteSpace(code)) + return RecNetResults.Fail("missing_code"); - if (string.IsNullOrWhiteSpace(token)) - { - var player = await GetCurrentPlayerAsync(); - if (player is null) return Unauthorized(); - var pair = authService.GenerateTokenPair(player.Id); - return Ok(new LoginResponse - { - AccessToken = pair.AccessToken, - RefreshToken = pair.RefreshToken, - }); - } - - var prefix = token.Trim() + "|"; - var row = await db.PlayerSettings - .Where(s => s.Key == "remote_login_token" && s.Value.StartsWith(prefix)) - .OrderByDescending(s => s.Id) - .FirstOrDefaultAsync(); - if (row is null) return Unauthorized(); - - var rawExpiry = row.Value[prefix.Length..]; - if (!DateTime.TryParse(rawExpiry, out var expiresAt) || expiresAt < DateTime.UtcNow) - return Unauthorized(); - - db.PlayerSettings.Remove(row); - await db.SaveChangesAsync(); - var (access, refresh) = authService.GenerateTokenPair(row.PlayerId); - return Ok(new LoginResponse - { - AccessToken = access, - RefreshToken = refresh, - }); + return await DeviceAuthorizations.ApproveAsync(db, code, approver.Id, DateTime.UtcNow) + ? RecNetResults.Ok() + : RecNetResults.Fail("invalid_or_expired_code"); } [HttpPost("/account/recoverpassword")] @@ -808,8 +931,12 @@ public class AccountsController( /// Unknown accounts get an empty string — "no platform identity" is the /// honest answer and keeps the promise resolved rather than rejected. /// + // LastPlatformId is a third-party identifier (Steam/Oculus id); without + // an auth gate this was an anonymous enumeration oracle over every + // account. The 2023 client only ever calls it with a token. [HttpGet("/platformid/{accountId:long}")] [HttpGet("/account/v1/{accountId:long}/platformid")] + [Authorize] public async Task GetPlatformId(long accountId) { var platformId = await db.Players @@ -948,8 +1075,15 @@ public class AccountsController( var me = this.RequireCurrentPlayerId(); var visible = await ReadRecentHistoryVisibleBodyAsync(); await SetPlayerSettingAsync(me, RecentHistoryVisibleKey, visible ? "true" : "false"); + // OPMAPIOEIFG.PNEDPCJCKHI passes this body to BFDGEKKNAPB.PEFKBAEAOPD + // (ExpandRecNetResult) — it is read as {Success, Error}. Without a + // Success key the toggle parsed as a failure with a null Error, which + // throws "Error message cannot be null" instead of saving quietly. + // The settings keys stay for any reader of the old shape. return Ok(new { + Success = true, + Error = string.Empty, AccountId = (int)me, IsRecentHistoryVisible = visible, }); @@ -990,35 +1124,4 @@ public class AccountsController( // Previously had a /{*path} catch-all that returned [] / {}. Removed — // unknown accounts URLs now 404 so we notice them in logs and wire // them properly rather than silently masking missing endpoints. - - private static RecNetAccount BuildAccount(long id, string username, string? displayName, string? profileImage = null) - => new() - { - AccountId = (int)id, - RawUsername = username, - Username = username, - DisplayName = displayName ?? username, - ProfileImage = profileImage ?? string.Empty, - TreatAsJunior = false, - HasBirthday = true, - Platforms = 1, - }; - - private static RecNetSelfAccount BuildSelfAccount(long id, string username, string? displayName, string? profileImage = null) - => new() - { - AccountId = (int)id, - RawUsername = username, - Username = username, - DisplayName = displayName ?? username, - ProfileImage = profileImage ?? string.Empty, - TreatAsJunior = false, - HasBirthday = true, - Platforms = 1, - Email = string.Empty, - Phone = string.Empty, - Birthday = new DateTime(2000, 1, 1), - JuniorState = 0, - ParentAccountId = null, - }; } diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 40a8195..6a5fb11 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -7,6 +7,7 @@ using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Hubs; using DorkNet.Server.Services; +using RecRoom.Protobuf; namespace DorkNet.Server.Controllers.Admin; @@ -974,7 +975,8 @@ public class AdminController( double? HotScore, int? State, string? ImageName, - int? MaxCapacity); + int? MaxCapacity, + bool? IsAGRoom); /// POST api/admin/v1/rooms/{id}/props — partial /// update of room properties. Only the non-null fields in the @@ -1004,6 +1006,20 @@ public class AdminController( room.MaxCapacity = Math.Clamp(cap, 1, 80); changes.Add($"maxCapacity={room.MaxCapacity}"); } + if (body.IsAGRoom is bool ag) + { + // IsAGRoom is what the client reads as IsRRO. True: load the + // baked scene from the client's own assets AND consult the + // client-baked AGRoomRuntimeConfig table (which, per original, + // decides e.g. whether creation is allowed — Rec Center yes, Rec + // Royale no). False: treat the room as a custom room (roles and + // blob decide everything). Flipping a CLONE of a baked original + // to false is the server-side experiment for "editable Rec + // Royale"; whether the client still loads the baked scene for a + // non-original is what that experiment answers. + room.IsAGRoom = ag; + changes.Add($"isAGRoom={ag}"); + } if (changes.Count == 0) return Ok(new { room.Id, unchanged = true }); @@ -1117,6 +1133,36 @@ public class AdminController( .OrderBy(s => s.OrderIndex) .ToListAsync(); + var room = await db.Rooms.AsNoTracking().FirstAsync(r => r.Id == id); + var bakedOriginal = RoomService.IsBakedOriginalRoom(room); + + // Seeded baked originals (Rec Royale, …) have NO RoomScenes rows at + // all; the game-client details builder synthesises a "Home" entry + // scene for them (RoomsController.BuildSubRoomWire with a null scene) + // and the mutation handlers create the row on first write. Mirror + // that here so the SPA has something to act on — the blob/enable + // action below creates the real row. + if (scenes.Count == 0) + { + return Ok(new[] + { + new + { + id = "0", + subRoomId = 0, + name = "Home", + maxPlayers = room.MaxCapacity, + canMatchmakeInto = true, + isSandbox = false, + dataBlobName = string.Empty, + effectiveBlobName = EffectiveSubRoomBlobName(room, null, bakedOriginal), + isBakedOriginal = bakedOriginal, + isVirtual = true, + dataModifiedAt = room.UpdatedAt, + }, + }); + } + return Ok(scenes.Select(s => new { // Ids go out as strings: the admin SPA is JavaScript and silently @@ -1128,10 +1174,260 @@ public class AdminController( canMatchmakeInto = s.CanMatchmakeInto, isSandbox = s.IsSandbox, dataBlobName = s.DataBlobName, + // What the client will actually be told to download for this + // sub-room (same rule as RoomsController.BuildSubRoomWire): the + // scene's own blob, else the room's, else a synthetic default — + // except baked Rec Room Originals, which get NO blob until an + // admin enables one (see EnableSubRoomBlob). + effectiveBlobName = EffectiveSubRoomBlobName(room, s, bakedOriginal), + isBakedOriginal = bakedOriginal, dataModifiedAt = s.DataModifiedAt, }).ToList()); } + private static string EffectiveSubRoomBlobName(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal) + { + if (!string.IsNullOrWhiteSpace(scene?.DataBlobName)) return scene!.DataBlobName; + if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; + if (bakedOriginal) return string.Empty; + return RoomService.SyntheticDefaultRoomDataBlobName(room.Id); + } + + /// Blob name handed to a baked RRO sub-room when an admin enables + /// editing: the synthetic default for the entry scene (the same name the + /// CDN already recognises as "serve the RRO-editable blob on S3 miss"), + /// and the clone-style _sub{n} form for the others. + private static string SyntheticSubRoomBlobName(long roomId, int subRoomId) => + subRoomId == 0 + ? RoomService.SyntheticDefaultRoomDataBlobName(roomId) + : $"room_{roomId}_dorknet_v8_sub{subRoomId}.dat"; + + /// POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable + /// — give a baked Rec Room Original sub-room a data blob so the client + /// runs its room-data path there (Maker Pen, CV2 chips, room saves). + /// + /// Why this exists: baked RRO rooms (Rec Royale, Paintball, …) ship with + /// an empty , and every details + /// builder + matchmaking resolver turns that into an empty + /// DataBlob, which the client treats as "no room data at all" — + /// no persistence views, no room-role overlay, no Maker Pen, no room + /// mood engine. Real Rec Room served these rooms a blob (Rec Royale's + /// game-rule chip RecRoyaleSolos_GameRuleWrapperCircuit lives in + /// it, not in the scene). Stamping the synthetic default name is enough: + /// every resolver returns it verbatim, and CdnController already + /// answers that name for AG rooms with the RRO-editable blob (permissive + /// room roles, no objects) when nothing has been saved yet. The first + /// in-game save then replaces the name with a real upload. + /// + /// No-op when the sub-room already has a blob. Idempotent. + [HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/enable")] + public async Task EnableSubRoomBlob(long id, int subRoomId) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); + if (room is null) return NotFound(); + var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId); + if (scene is null && subRoomId != 0) return NotFound(); + + var bakedOriginal = RoomService.IsBakedOriginalRoom(room); + if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName)) + return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: false)); + + var blobName = SyntheticSubRoomBlobName(id, subRoomId); + var now = DateTime.UtcNow; + if (scene is null) + { + // Seeded baked originals carry no RoomScenes row; create the + // entry scene with the same defaults the game-side mutation + // path uses (RoomsController.GetOrCreateSceneForMutationAsync). + scene = new RoomSceneEntity + { + RoomId = room.Id, + OrderIndex = 0, + Name = "Home", + RoomSceneLocationId = room.LocationReplicationId, + MaxPlayers = room.MaxCapacity, + IsSandbox = false, + CanMatchmakeInto = true, + }; + db.RoomScenes.Add(scene); + } + scene.DataBlobName = blobName; + scene.DataModifiedAt = now; + if (subRoomId == 0 && string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) + { + room.CurrentDataBlobName = blobName; + room.UpdatedAt = now; + } + + await LogAsync("enable_subroom_blob", "room", id, $"subRoom={subRoomId} blob={blobName}"); + await db.SaveChangesAsync(); + return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: true)); + } + + /// POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset + /// — clear the sub-room's data blob reference. For a baked Rec Room + /// Original that puts the room back to "scene only, no blob" (the escape + /// hatch if a save left the room unplayable); for any other room the + /// resolvers fall back to the synthetic default, i.e. an empty room. + /// Saved blobs stay in RoomDataBlobs; only the pointers move, so + /// the in-game restore list can bring an old save back. + [HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/reset")] + public async Task ResetSubRoomBlob(long id, int subRoomId) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); + if (room is null) return NotFound(); + var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId); + if (scene is null && subRoomId != 0) return NotFound(); + + var bakedOriginal = RoomService.IsBakedOriginalRoom(room); + var previousScene = scene?.DataBlobName ?? string.Empty; + var previousRoom = room.CurrentDataBlobName; + var changed = false; + var now = DateTime.UtcNow; + + if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName)) + { + scene.DataBlobName = string.Empty; + scene.DataModifiedAt = now; + changed = true; + } + if (subRoomId == 0 && !string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) + { + room.CurrentDataBlobName = string.Empty; + room.UpdatedAt = now; + changed = true; + } + + if (changed) + { + await LogAsync("reset_subroom_blob", "room", id, + $"subRoom={subRoomId} scene '{previousScene}' -> '' room '{previousRoom}' -> '{room.CurrentDataBlobName}'"); + await db.SaveChangesAsync(); + } + return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed)); + } + + /// POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload + /// — multipart file = a real PersistedRoomData save (any + /// client version; .binpb / .room / .dat), optional + /// form/query mode = full (default) or basics. + /// Rewrites it for this room (activity id, 2023 version clamp, drops + /// output log / sub-room id / unknown fields; basics also keeps + /// only the game-role and game-rule objects and drops the circuit + /// payloads — see ), + /// stores it under a fresh room_{id}_upload_*.dat name, records + /// the RoomDataBlobs row and points the sub-room (and, for sub-room 0, + /// the room) at it. The CDN's RRO role overlay and version clamp then + /// apply on serve exactly as for Rec Center's saved blob. + [HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/upload")] + [RequestSizeLimit(64 * 1024 * 1024)] + public async Task UploadSubRoomBlob(long id, int subRoomId, IFormFile? file, [FromQuery] string? mode) + { + if (file is null || file.Length == 0) return BadRequest("missing file"); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); + if (room is null) return NotFound(); + var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId); + if (scene is null && subRoomId != 0) return NotFound(); + + byte[] raw; + using (var ms = new MemoryStream((int)file.Length)) + { + await file.CopyToAsync(ms); + raw = ms.ToArray(); + } + try { _ = PersistedRoomData.Parser.ParseFrom(raw); } + catch (Exception ex) { return BadRequest($"not a PersistedRoomData blob: {ex.Message}"); } + + var basics = string.Equals(mode ?? Request.Form["mode"].ToString(), "basics", StringComparison.OrdinalIgnoreCase); + var (bytes, summary) = RoomDataBlobService.PrepareUploadedRoomBlob(raw, room.LocationReplicationId, basics); + + var now = DateTime.UtcNow; + var blobName = $"room_{id}_upload_{now:yyyyMMddHHmmss}.dat"; + var (bucket, key) = BlobRouter.Route(blobName); + try + { + await storage.PutAsync(bucket, key, bytes, "application/octet-stream"); + } + catch (Exception ex) + { + adminLogger.LogError(ex, "[admin] blob upload S3 PUT failed room={Room} blob={Blob}", id, blobName); + return StatusCode(502, new { error = "storage_put_failed", detail = ex.Message }); + } + + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = id, + BlobName = blobName, + SubRoomId = subRoomId, + UploadedByPlayerId = CurrentAdminId, + UploadedAt = now, + }); + + if (scene is null) + { + scene = new RoomSceneEntity + { + RoomId = room.Id, + OrderIndex = 0, + Name = "Home", + RoomSceneLocationId = room.LocationReplicationId, + MaxPlayers = room.MaxCapacity, + IsSandbox = false, + CanMatchmakeInto = true, + }; + db.RoomScenes.Add(scene); + } + scene.DataBlobName = blobName; + scene.DataModifiedAt = now; + if (subRoomId == 0) + { + room.CurrentDataBlobName = blobName; + room.UpdatedAt = now; + } + + await LogAsync("upload_subroom_blob", "room", id, + $"subRoom={subRoomId} blob={blobName} mode={(basics ? "basics" : "full")} bytes={bytes.Length} views={summary.ViewsKept}/{summary.ViewsIn} roles={summary.RoleViews} rules={summary.GameRuleViews} version={summary.VersionIn}->{summary.VersionOut}"); + await db.SaveChangesAsync(); + + var bakedOriginal = RoomService.IsBakedOriginalRoom(room); + return Ok(new + { + id = scene.Id.ToString(), + subRoomId = scene.OrderIndex, + name = scene.Name, + dataBlobName = scene.DataBlobName, + roomCurrentDataBlobName = room.CurrentDataBlobName, + effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal), + isBakedOriginal = bakedOriginal, + bytes = bytes.Length, + // Explicit camelCase: the API's naming policy is "as declared", + // and the rest of this payload is camelCase. + summary = new + { + viewsIn = summary.ViewsIn, + viewsKept = summary.ViewsKept, + roleViews = summary.RoleViews, + gameRuleViews = summary.GameRuleViews, + versionIn = summary.VersionIn, + versionOut = summary.VersionOut, + circuitsStripped = summary.CircuitsStripped, + activityId = summary.ActivityId, + }, + }); + } + + private static object SubRoomBlobWire(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal, bool changed) => new + { + id = (scene?.Id ?? 0).ToString(), + subRoomId = scene?.OrderIndex ?? 0, + name = scene?.Name ?? "Home", + dataBlobName = scene?.DataBlobName ?? string.Empty, + roomCurrentDataBlobName = room.CurrentDataBlobName, + effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal), + isBakedOriginal = bakedOriginal, + changed, + }; + public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers); /// PUT api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers @@ -1158,10 +1454,12 @@ public class AdminController( var previous = scene.MaxPlayers; scene.MaxPlayers = Math.Clamp(body.MaxPlayers, 1, 80); scene.DataModifiedAt = DateTime.UtcNow; - await db.SaveChangesAsync(); + // LogAsync only stages the AdminActionEntity; save afterwards so the + // capacity change and its audit record commit together. await LogAsync("update_subroom_maxplayers", "room", id, $"subRoom={subRoomId} maxPlayers {previous} -> {scene.MaxPlayers}"); + await db.SaveChangesAsync(); return Ok(new { @@ -1957,6 +2255,22 @@ public class AdminController( return Ok(settings); } + /// GET api/admin/v1/settings/circuits-limits — the + /// CircuitsV2 creation limits served as the UGC.CircuitsV2.Limit.* + /// gameconfig keys, including the per-frame RPC and byte budgets. + [HttpGet("settings/circuits-limits")] + public async Task GetCircuitsV2Limits() + => Ok(await serverSettings.GetCircuitsV2LimitsAsync()); + + [HttpPost("settings/circuits-limits")] + public async Task SetCircuitsV2Limits([FromBody] CircuitsV2LimitSettings body) + { + var settings = await serverSettings.SetCircuitsV2LimitsAsync(body); + await LogAsync("gameconfigs_updated", "system", 0, "circuitsv2-limits"); + await db.SaveChangesAsync(); + return Ok(settings); + } + // ── Signup codes ───────────────────────────────────────────────────── /// GET api/admin/v1/signup-codes — every issued code diff --git a/DorkNet.Server/Controllers/Auth/AuthController.cs b/DorkNet.Server/Controllers/Auth/AuthController.cs index 40f3955..d3cf65d 100644 --- a/DorkNet.Server/Controllers/Auth/AuthController.cs +++ b/DorkNet.Server/Controllers/Auth/AuthController.cs @@ -101,7 +101,9 @@ public class AuthController( [FromForm] int platform = 0, [FromForm] string? platformId = null, [FromForm(Name = "platform_id")] string? platform_id_snake = null, - [FromForm] long? account_id = null) + [FromForm] long? account_id = null, + [FromForm] string? device_code = null, + [FromForm] string? login_token = null) { // The client mixes camelCase and snake_case parameter naming // depending on grant type — accept both and merge. @@ -139,6 +141,55 @@ public class AuthController( return TokenOk(acc1, refr1); } + // Device-authorization poll (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451). + // The client keeps polling on HTTP 400 with error authorization_pending + // or slow_down (LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116); any other + // error ends the attempt. This grant used to fall through to the + // device-id fallback, which signed the polling device straight in as + // whatever account its device id mapped to — no approval involved. + if (string.Equals(grant_type, DeviceCodeGrantType, StringComparison.Ordinal)) + { + var (status, approverId) = await DeviceAuthorizations.RedeemAsync(db, device_code, DateTime.UtcNow); + switch (status) + { + case DeviceAuthorizations.RedeemStatus.Pending: + return BadRequest(new { error = "authorization_pending" }); + case DeviceAuthorizations.RedeemStatus.Expired: + return BadRequest(new { error = "expired_token" }); + case DeviceAuthorizations.RedeemStatus.Invalid: + return BadRequest(new { error = "invalid_grant" }); + } + var approver = await playerService.GetByIdAsync(approverId); + if (approver is null) return BadRequest(new { error = "invalid_grant" }); + await TryAwardLoginXpAsync(approver.Id); + var (accD, refrD) = authService.GenerateTokenPair(approver.Id); + return TokenOk(accD, refrD); + } + + // One-shot token from account/me/createlogintoken, redeemed with the + // account it was minted for (LBNJFPOLCDL.txt:7593-7606: grant_type, + // account_id, login_token). Also used to fall through to the device-id + // fallback, ignoring both account_id and the token. + if (string.Equals(grant_type, "login_token", StringComparison.Ordinal)) + { + if (account_id is not long tokenAccountId || string.IsNullOrWhiteSpace(login_token)) + return BadRequest(new { error = "invalid_request", error_description = "invalid login_token" }); + var prefix = login_token.Trim() + "|"; + var row = await db.PlayerSettings.FirstOrDefaultAsync(s => + s.PlayerId == tokenAccountId + && s.Key == Accounts.AccountsController.RemoteLoginTokenKey + && s.Value.StartsWith(prefix)); + if (row is null + || !DateTime.TryParse(row.Value[prefix.Length..], null, + System.Globalization.DateTimeStyles.RoundtripKind, out var tokenExpiry) + || tokenExpiry < DateTime.UtcNow) + return BadRequest(new { error = "invalid_grant", error_description = "invalid login_token" }); + db.PlayerSettings.Remove(row); + await db.SaveChangesAsync(); + var (accL, refrL) = authService.GenerateTokenPair(tokenAccountId); + return TokenOk(accL, refrL); + } + if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrWhiteSpace(refresh_token)) @@ -364,26 +415,20 @@ public class AuthController( [HttpGet("/connect/deviceauthorization")] public async Task DeviceAuthorization() { - var deviceCode = Guid.NewGuid().ToString("N"); - var userCode = deviceCode[..8].ToUpperInvariant(); - db.PlayerSettings.Add(new PlayerSettingEntity - { - PlayerId = RelationshipQueries.SystemAccountId, - Key = $"deviceauthorization:{deviceCode}", - Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}", - }); - await db.SaveChangesAsync(); + var created = await DeviceAuthorizations.CreateAsync(db, DateTime.UtcNow); return Ok(new { - device_code = deviceCode, - user_code = userCode, + device_code = created.DeviceCode, + user_code = created.UserCode, verification_uri = $"https://{domain.Sub("auth")}/device", - verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}", - expires_in = 900, + verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={created.UserCode}", + expires_in = created.ExpiresInSeconds, interval = 5, }); } + public const string DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code"; + private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new() { Platform = platform, diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs index ff3c5e8..f1069d2 100644 --- a/DorkNet.Server/Controllers/Cdn/CdnController.cs +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using System.Text; using System.Text.Json; using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.Cdn; @@ -557,11 +558,18 @@ public class CdnController( return new FileContentResult(TransparentPng, "image/png"); } - if (await ShouldOverlayRroRoomRoleDataAsync(fileName)) + var rroRoom = await ResolveRroRoomAsync(fileName); + if (rroRoom is not null) { - logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable role blob", - Request.Host.Host, fileName); - var rroBlob = roomDataBlob.GetRroEditableBlob(); + // Per-room stub: a real save's skeleton at the 2023 version + // ceiling with the room's own activity_id. The old generic stub + // (version 19, roles only) was fetched and silently rejected by + // the client, which is why an enabled baked room still had no + // Maker Pen. See RoomDataBlobService.BuildRroEditableBlob. + logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable blob (room {Room}, activity {Activity})", + Request.Host.Host, fileName, rroRoom.Id, rroRoom.LocationReplicationId); + var rroBlob = roomDataBlob.GetRroEditableBlob(rroRoom.LocationReplicationId, rroRoom.Name); + rroBlob = ClampForClientUnlessDisabled(rroBlob, fileName); Response.Headers.CacheControl = "public, max-age=60"; signatures.AddContentSignature(Response, rroBlob); return new FileContentResult(rroBlob, "application/octet-stream"); @@ -569,14 +577,34 @@ public class CdnController( logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob", Request.Host.Host, fileName); - var fallbackBlob = roomDataBlob.GetDefaultBlob(); + var fallbackBlob = ClampForClientUnlessDisabled(roomDataBlob.GetDefaultBlob(), fileName); signatures.AddContentSignature(Response, fallbackBlob); return new FileContentResult(fallbackBlob, "application/octet-stream"); } - private async Task ShouldOverlayRroRoomRoleDataAsync(string fileName) + /// Same 2023 version clamp the S3-hit path applies, for the + /// synthesised blobs on the miss path — a stub past the client's ceiling + /// is rejected exactly like a modern export would be. + private byte[] ClampForClientUnlessDisabled(byte[] bytes, string fileName) { - if (string.IsNullOrWhiteSpace(fileName)) return false; + if (!IsRoomDataName(fileName)) return bytes; + if (serverSettings.IsRoomBlobVersionClampDisabledAsync().GetAwaiter().GetResult()) return bytes; + var (clamped, changed) = RoomDataBlobService.ClampVersionsFor2023(bytes); + if (changed) + logger.LogInformation("[cdn] clamped synthesised blob versions to 2023 maxima file={File}", fileName); + return clamped; + } + + private async Task ShouldOverlayRroRoomRoleDataAsync(string fileName) => + await ResolveRroRoomAsync(fileName) is not null; + + /// The AG/RRO room a blob name belongs to, or null when the name + /// is not an RRO room's (current blob, synthetic room_{id}_* form, + /// or a sub-room scene blob). Drives both the role overlay on S3 hits and + /// the per-room stub on misses. + private async Task ResolveRroRoomAsync(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) return null; // MakerPen across ALL Rec Room Originals: overlay the permissive // room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO @@ -587,9 +615,9 @@ public class CdnController( // blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable- // RoleData) — the game-rule circuit data and GameRoleData are left // intact — so quest rooms keep working, exactly as Stunt Runner does. - if (await db.Rooms.AsNoTracking().AnyAsync(r => - r.IsAGRoom && r.CurrentDataBlobName == fileName)) - return true; + var byCurrentBlob = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => + r.IsAGRoom && r.CurrentDataBlobName == fileName); + if (byCurrentBlob is not null) return byCurrentBlob; // Synthetic/default room-data blob names for rooms with no saved blob: // the CURRENT form is room_{id}_dorknet_v8.dat @@ -603,18 +631,19 @@ public class CdnController( { var rest = fileName["room_".Length..]; var underscore = rest.IndexOf('_'); - if (underscore > 0 && - long.TryParse(rest[..underscore], out var roomId) && - await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom)) - return true; + if (underscore > 0 && long.TryParse(rest[..underscore], out var roomId)) + { + var byId = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId && r.IsAGRoom); + if (byId is not null) return byId; + } } return await ( from scene in db.RoomScenes.AsNoTracking() join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id where room.IsAGRoom && scene.DataBlobName == fileName - select scene.Id - ).AnyAsync(); + select room + ).FirstOrDefaultAsync(); } /// diff --git a/DorkNet.Server/Controllers/Clubs/ClubsController.cs b/DorkNet.Server/Controllers/Clubs/ClubsController.cs index 5f29214..cfd7811 100644 --- a/DorkNet.Server/Controllers/Clubs/ClubsController.cs +++ b/DorkNet.Server/Controllers/Clubs/ClubsController.cs @@ -91,11 +91,13 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle /// 2023 client sends the target club in a form-urlencoded /// clubId field (boxed Nullable<Int64> pushed through /// BNDIAONDFFF.AFGEDDANEKP at IKMMOCKDKAF.txt:16923-16927) - /// and parses NO response DTO — the issuing method is - /// LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>), the status-only - /// promise type, so an empty 200 is the contract. Verb ordinal 3 (PUT) - /// is the HasValue branch of the same method at :16911; the null branch - /// is the DELETE below (:16838). + /// — the issuing method is LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>). + /// Despite the untyped promise the body IS parsed: both branches hand + /// the response to BFDGEKKNAPB.PEFKBAEAOPD (:16858, :16943), the + /// {success,error} RecNetResult reader, so an empty 200 surfaced as + /// a failure (). Verb ordinal 3 (PUT) is the + /// HasValue branch of the same method at :16911; the null branch is the + /// DELETE below (:16838). [HttpPut("/club/home/me")] [Authorize] public async Task HomeMeSet() @@ -116,11 +118,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle clubId = fromQuery; if (clubId <= 0) return BadRequest(new { error = "missing_club_id" }); if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound(); - return Ok(); + return RecNetResults.Ok(); } /// DELETE /club/home/me — "Remove Home Clubhouse Room". - /// No body, no response DTO (IKMMOCKDKAF.txt:16830-16841: route + /// No request body; RecNetResult reply (IKMMOCKDKAF.txt:16830-16858: route /// literal, host 13, verb ordinal 4, error string "Failed to Remove Home /// Clubhouse Room"). Idempotent so a double-tap is harmless. [HttpDelete("/club/home/me")] @@ -128,7 +130,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle public async Task HomeMeClear() { await clubs.ClearHomeClubAsync(Me); - return Ok(); + return RecNetResults.Ok(); } [HttpGet("/club/mine/member")] @@ -347,7 +349,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle { var pid = Me; await clubs.MarkAnnouncementReadAsync(pid, announcementId); - return Ok(new { Read = true }); + return RecNetResults.Ok(); } // ── Single-club reads ───────────────────────────────────────────── @@ -369,8 +371,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle /// the club NAME used only for the client-side confirmation copy and is /// never transmitted; the request is a bare DELETE with no body /// (IKMMOCKDKAF.txt:17107-17120, verb ordinal 4, host 13, error - /// string "Failed to Delete Club"). LDGADANDBIO is the status-only - /// promise so an empty 200 is the whole contract. + /// string "Failed to Delete Club"). The promise is built by + /// BFDGEKKNAPB.PEFKBAEAOPD (ExpandRecNetResult, :17137), so the + /// body is a {success,error} RecNetResult — an empty 200 made the + /// client report "Failed to Delete Club" after the club was gone. See + /// . /// /// Owner-only, and a soft delete — see /// . @@ -382,7 +387,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle { var disbanded = await clubs.DisbandAsync(clubId, Me); if (disbanded is null) return NotFound(); - return Ok(); + return RecNetResults.Ok(); } catch (UnauthorizedAccessException) { return Forbid(); } } @@ -1144,7 +1149,15 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle /// is reachable — see . private static int RequestStatusFor(ClubMembershipEntity row) => 1; - // ── Member mutations (void / IPromise) ─────────────────────────── + // ── Member mutations ───────────────────────────────────────────── + // + // Every one of these goes through the client's shared club-write helper + // IKMMOCKDKAF.JNFEEDLCGHH ("Failed to " + action), which hands the + // response to BFDGEKKNAPB.PEFKBAEAOPD (IKMMOCKDKAF.txt:26696) — the + // RecNetResult reader. They are not status-only: an empty 200 parses to + // null and the client shows "Failed to request to join club" (etc.) after + // the server has applied the change. Success bodies are + // RecNetResults.Ok(). /// POST /club/{id}/members/requesttojoin — caller /// asks to join. For Open clubs we promote straight to Member; for @@ -1169,7 +1182,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle }; if (perms == -1) return Forbid(); await clubs.UpsertMembershipAsync(clubId, Me, perms); - return Ok(); + return RecNetResults.Ok(); } /// POST /club/{id}/members/directJoin — same as @@ -1183,7 +1196,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); await clubs.UpsertMembershipAsync(clubId, Me, 0); - return Ok(); + return RecNetResults.Ok(); } /// POST /club/{id}/members/leave — caller leaves @@ -1198,7 +1211,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle if (club.CreatorPlayerId == Me) return BadRequest(new { error = "owner_must_transfer_first" }); await clubs.RemoveMembershipAsync(clubId, Me); - return Ok(); + return RecNetResults.Ok(); } /// POST /club/{id}/members/invite — caller invites @@ -1220,7 +1233,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle var target = req.PlayerId ?? req.AccountId ?? 0; if (target == 0) return BadRequest(new { error = "missing_player_id" }); await clubs.UpsertMembershipAsync(clubId, target, 128); // Pending - return Ok(); + return RecNetResults.Ok(); } /// POST /club/{id}/members/invitemembers — bulk @@ -1241,7 +1254,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle var ids = (req.PlayerIds ?? req.AccountIds ?? new List()).Distinct().ToList(); foreach (var pid in ids) await clubs.UpsertMembershipAsync(clubId, pid, 128); - return Ok(); + return RecNetResults.Ok(); } /// POST /club/{id}/members/acceptinvite — caller @@ -1254,7 +1267,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle if (row is null) return NotFound(); if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_invite" }); await clubs.UpsertMembershipAsync(clubId, Me, 0); // Member - return Ok(); + return RecNetResults.Ok(); } /// POST /club/{id}/members/declineinvite — caller @@ -1264,7 +1277,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle public async Task MemberDeclineInvite(long clubId) { await clubs.RemoveMembershipAsync(clubId, Me); - return Ok(); + return RecNetResults.Ok(); } public sealed class MemberTargetRequest @@ -1288,7 +1301,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle if (row is null) return NotFound(); if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_request" }); await clubs.UpsertMembershipAsync(clubId, target, 0); - return Ok(); + return RecNetResults.Ok(); } public sealed class BulkTargetRequest @@ -1309,7 +1322,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle if (row is null || (row.Permissions & 128) == 0) continue; await clubs.UpsertMembershipAsync(clubId, pid, 0); } - return Ok(); + return RecNetResults.Ok(); } [HttpPost("/club/{clubId:long}/members/denyrequest")] @@ -1320,7 +1333,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle var target = req.PlayerId ?? req.AccountId ?? 0; if (target == 0) return BadRequest(new { error = "missing_player_id" }); await clubs.RemoveMembershipAsync(clubId, target); - return Ok(); + return RecNetResults.Ok(); } [HttpPost("/club/{clubId:long}/members/denyrequests")] @@ -1331,7 +1344,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle var ids = (req.PlayerIds ?? req.AccountIds ?? new List()).Distinct().ToList(); foreach (var pid in ids) await clubs.RemoveMembershipAsync(clubId, pid); - return Ok(); + return RecNetResults.Ok(); } [HttpPost("/club/{clubId:long}/members/remove")] @@ -1346,7 +1359,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle if (target == club.CreatorPlayerId) return BadRequest(new { error = "cannot_remove_owner" }); await clubs.RemoveMembershipAsync(clubId, target); - return Ok(); + return RecNetResults.Ok(); } /// POST /club/{id}/members/ban — kick + prevent @@ -1364,7 +1377,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle if (target == club.CreatorPlayerId) return BadRequest(new { error = "cannot_ban_owner" }); await clubs.UpsertMembershipAsync(clubId, target, 256); // Ban marker - return Ok(); + return RecNetResults.Ok(); } [HttpPost("/club/{clubId:long}/members/unban")] @@ -1375,7 +1388,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle var target = req.PlayerId ?? req.AccountId ?? 0; if (target == 0) return BadRequest(new { error = "missing_player_id" }); await clubs.RemoveMembershipAsync(clubId, target); - return Ok(); + return RecNetResults.Ok(); } /// POST /club/{id}/members/changetype — promote / @@ -1396,7 +1409,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle if (target == club.CreatorPlayerId) return BadRequest(new { error = "cannot_change_owner_role" }); await clubs.UpsertMembershipAsync(clubId, target, ClubService.PermsFromMembershipType(newType)); - return Ok(); + return RecNetResults.Ok(); } // ── members/bulk + clubreporting ───────────────────────────────── @@ -1513,7 +1526,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle public async Task AnnouncementDelete(long clubId, long announcementId) { if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid(); - return Ok(); + return RecNetResults.Ok(); } /// PUT /announcements/club/{clubId}/{announcementId} — EDIT @@ -1529,7 +1542,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle var (title, body, imageName) = await ReadAnnouncementFieldsAsync(); if (!await clubs.UpdateAnnouncementAsync(clubId, announcementId, Me, title, body, imageName)) return Forbid(); - return Ok(); + return RecNetResults.Ok(); } /// POST /announcements/club/{clubId} — CREATE an diff --git a/DorkNet.Server/Controllers/Link/LinkController.cs b/DorkNet.Server/Controllers/Link/LinkController.cs index 47ba3c5..fe73b9b 100644 --- a/DorkNet.Server/Controllers/Link/LinkController.cs +++ b/DorkNet.Server/Controllers/Link/LinkController.cs @@ -223,7 +223,11 @@ public class LinkController(DorkNetDbContext db, ILogger logger) payload.Uses++; // AutoRenewHours present ⇒ the code renews rather than ages out. - if (req.ValidHours is > 0) payload.ExpiresAt = DateTime.UtcNow.AddHours(req.ValidHours.Value); + // Clamped: this route is anonymous and the value is client-supplied, + // so an unbounded renewal would let anyone holding the code extend + // its life indefinitely past what the creator chose. + if (req.ValidHours is > 0) + payload.ExpiresAt = DateTime.UtcNow.AddHours(Math.Min(req.ValidHours.Value, 720)); var encoded = JsonSerializer.Serialize(payload); if (encoded.Length <= ValueLimit) row.Value = encoded; diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs index b22d560..ea74468 100644 --- a/DorkNet.Server/Controllers/Match/GoToController.cs +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -41,6 +41,7 @@ public class GoToController( NotificationService notifications, JoinTimeoutService joinTimeouts, ServerSettingsService serverSettings, + PresenceBroadcastService presenceFanout, ILogger logger) : ControllerBase { [HttpPost("/goto/room/{roomName}")] @@ -1294,15 +1295,14 @@ public class GoToController( private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room) { - // Honors the global-friends toggle: with it on, every other player is - // a "friend" and gets the presence ping (so the whole small server - // sees each other's room moves). - var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, playerId); + // Honors the global-friends toggle (every other player is a "friend" + // when it is on). Goes through the shared fan-out so the frame + // carries the player's real status visibility and the same + // online/room state every other presence transition reports. logger.LogInformation( - "[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)", - playerId, room.RoomId, room.Name, friendIds.Count); - if (friendIds.Count == 0) return; - await notifications.PlayerEnteredRoom(friendIds, playerId, room); + "[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName}", + playerId, room.RoomId, room.Name); + await presenceFanout.PublishCurrentAsync(playerId); } private async Task BuildResponseAsync(string roomName) diff --git a/DorkNet.Server/Controllers/Match/MatchController.cs b/DorkNet.Server/Controllers/Match/MatchController.cs index f651bc0..2d23d33 100644 --- a/DorkNet.Server/Controllers/Match/MatchController.cs +++ b/DorkNet.Server/Controllers/Match/MatchController.cs @@ -22,6 +22,7 @@ public class MatchController( PlayerPresenceService presence, PrivateInstanceService privateInstances, JoinTimeoutService joinTimeouts, + PresenceBroadcastService presenceFanout, RoomService rooms, DorkNetDbContext db, ILogger logger) : ControllerBase @@ -67,6 +68,12 @@ public class MatchController( completed.Value.TargetRoom.Name, completed.Value.TargetRoom.RoomInstanceId, completed.Value.DeferPresenceCommit); + // A deferred join (matchmake/dorm, private-instance hop) + // commits HERE, not at matchmake time, so this is the only + // point friends can learn about the move. Immediate commits + // were already announced by /goto. + if (completed.Value.DeferPresenceCommit) + await presenceFanout.PublishCurrentAsync(pid); } else { @@ -88,36 +95,48 @@ public class MatchController( return Ok(); } + /// POST matchmake/none — "leave room". The 2023 client's + /// only caller is TryGoToOfflineRoom + /// (RecNet.Runtime/RecNet/Matchmaking.txt:12677-13279): it has already + /// switched its own cached presence to the local offline instance + /// (RoomInstanceId -1) before posting this, and it discards the reply + /// (<TryGoToOfflineRoom>b__119_0 returns a constant). + /// + /// So the player is now online in no room, and that is what presence must + /// say. This used to re-commit the previous room (or the dorm) instead: + /// the next heartbeat then answered with a room while the client held the + /// offline instance, the client logged "presence heartbeat response + /// indicates local presence is out-of-sync", cancelled its offline load + /// ("OnPlayerPresenceUpdate is cancelling room load for -1") and chased the + /// server's room — and friends kept seeing the room the player had left. + /// + /// A join still pending is abandoned too: the client only goes offline + /// after giving up on it (fallback chain after a Photon timeout), and + /// echoing its target from the heartbeat would pull the player back. [HttpPost("/matchmake/none")] public async Task MatchmakeNone() { var pid = this.CurrentPlayerId() ?? 0; if (pid == 0) return Unauthorized(); + var previous = presence.GetRoom(pid); var pending = joinTimeouts.GetPending(pid); - if (pending is { DeferPresenceCommit: true }) - { - presence.MarkActive(pid); - logger.LogInformation( - "[matchmake-none] player={Player} has deferred pending room={Room} instance={Instance}; not committing presence", - pid, - pending.Value.TargetRoom.RoomId, - pending.Value.TargetRoom.RoomInstanceId); - return Ok(new MatchmakingResponseDto - { - ErrorCode = 0, - RoomInstance = pending.Value.TargetRoom, - }); - } - - var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid); - presence.SetRoom(pid, current); + joinTimeouts.Cancel(pid, "matchmake-none"); + presence.SetNoRoom(pid); presence.MarkActive(pid); - logger.LogInformation("[matchmake-none] player={Player}", pid); + presence.EndBootDormGrace(pid); + logger.LogInformation( + "[matchmake-none] player={Player} left room={Room} instance={Instance} abandonedPending={Pending}; online with no room", + pid, previous?.RoomId, previous?.RoomInstanceId, pending?.TargetRoom.RoomInstanceId); + + await presenceFanout.PublishCurrentAsync(pid); + + // The client ignores this body; keep a well-formed instance in it so + // the shared MatchmakingResponse reader never sees a null object. return Ok(new MatchmakingResponseDto { ErrorCode = 0, - RoomInstance = current, + RoomInstance = previous ?? pending?.TargetRoom ?? await BuildDormRoomInstanceAsync(pid), }); } @@ -396,14 +415,19 @@ public class MatchController( .Select(p => new { p.Id, p.OwnerPlayerId }) .FirstOrDefaultAsync(); - // forceChange only lets a host reclaim a code from one of their OWN - // instances; another player's code is never stolen. On refusal the - // client just re-reads whatever code we echo back, so we fall - // through to the read path instead of erroring. - var blocked = clash is not null - && (clash.OwnerPlayerId != pid || forceChange != true); - var row = await db.PrivateInstances.FirstOrDefaultAsync(p => p.Id == instanceId); + + // Only the instance's owner may set its code (same guard as + // MarkPrivate), otherwise any player could plant or relocate a + // code onto someone else's instance. forceChange only lets a host + // reclaim a code from one of their OWN instances; another + // player's code is never stolen. On refusal the client just + // re-reads whatever code we echo back, so we fall through to the + // read path instead of erroring. + var blocked = row is null || row.OwnerPlayerId != pid + || (clash is not null + && (clash.OwnerPlayerId != pid || forceChange != true)); + if (!blocked && row is not null) { if (clash is not null) diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index 217bcae..5d5a2d3 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -24,6 +24,7 @@ public class MatchPlayerController( RoomService rooms, PrivateInstanceService privateInstances, JoinTimeoutService joinTimeouts, + PresenceBroadcastService presenceFanout, IConfiguration config, DorkNetDbContext db, ILogger log) : ControllerBase @@ -87,13 +88,20 @@ public class MatchPlayerController( /// /// POST /player/logout — fires when the client gives up on a session /// (e.g. Photon connection couldn't be established, client aborts). - /// Clear the cached presence so the next /goto starts fresh. + /// Clear the cached presence so the next /goto starts fresh, and tell + /// friends right away — otherwise they keep seeing the player in their + /// last room until a bulk poll happens to notice. /// [HttpPost("/player/logout")] - public IActionResult PlayerLogout() + public async Task PlayerLogout() { var playerId = TryGetCurrentPlayerId(); - if (playerId != 0) presence.Clear(playerId); + if (playerId != 0) + { + joinTimeouts.Cancel(playerId, "logout"); + presence.Clear(playerId); + await presenceFanout.PublishOfflineAsync(playerId); + } return Ok(new { success = true, error = "" }); } @@ -178,7 +186,15 @@ public class MatchPlayerController( public async Task> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value) { var playerId = TryGetCurrentPlayerId(); - if (value is int v) await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString()); + if (value is int v) + { + await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString()); + // Friends only. The client does not apply this response to its own + // cached presence, and a PresenceUpdate about the local player runs + // the same apply-and-rejoin path as an out-of-sync heartbeat + // (Matchmaking.EGPABEDPEJO -> FLAOOBNJAAD, Matchmaking.txt:6917), so no self push. + await presenceFanout.PublishCurrentAsync(playerId); + } var room = await GetPresenceRoomForResponseAsync(playerId); return Ok(new PresenceDto { @@ -212,7 +228,7 @@ public class MatchPlayerController( // back in the response and was then dropped, so the setting reverted on the // next heartbeat and never survived a restart. They live in PlayerSettings // (no schema change needed) and are read back wherever presence is built. - private const string PrefStatusVisibility = "pref:statusVisibility"; + private const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey; private const string PrefVrMovementMode = "pref:vrMovementMode"; private const string PrefAvoidJuniors = "pref:avoidJuniors"; @@ -395,6 +411,21 @@ public class MatchPlayerController( if (room is null) { + // The player went to the offline room (matchmake/none). Their + // client now holds RoomInstanceId -1, which the heartbeat compare + // treats as "no room" (HCHDEHIGEBE.GIICPDHJOOH, sign bit of the id; + // RecNet.Runtime/RecNet/Matchmaking.txt:18415-18437) and is only in + // sync with a null roomInstance. The dorm fallback below would + // read as out-of-sync and drag the player out of the offline room. + if (presence.IsInNoRoom(playerId)) + { + presence.TouchNoRoom(playerId); + log.LogInformation( + "[player-heartbeat] player={PlayerId} online in no room (left via matchmake/none); returning null roomInstance", + playerId); + return null; + } + if (presence.IsBootDormGraceActive(playerId)) { log.LogInformation( @@ -426,6 +457,7 @@ public class MatchPlayerController( playerId, fallback.RoomId, fallback.DataBlob); presence.SetRoom(playerId, fallback); presence.MarkActive(playerId); + await presenceFanout.PublishCurrentAsync(playerId); return fallback; } log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId); diff --git a/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs b/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs index 1942b42..0bd2d7c 100644 --- a/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs +++ b/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Mvc; using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; using DorkNet.Server.Auth; +using DorkNet.Server.Data; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.Match; @@ -36,10 +38,19 @@ namespace DorkNet.Server.Controllers.Match; /// [ApiController] public class PlayerPresenceController( - PlayerPresenceService presence) : ControllerBase + PlayerPresenceService presence, + PresenceBroadcastService presenceFanout, + JoinTimeoutService joinTimeouts, + DorkNetDbContext db, + ServerSettingsService settings) : ControllerBase { + /// PlayerSettings key written by PUT /player/statusvisibility + /// (MatchPlayerController.PrefStatusVisibility — same literal; the + /// round-trip is pinned by a regression test). + public const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey; + [HttpGet("/player")] - public ActionResult> GetPresences() + public async Task>> GetPresences() { // The watch sends two URL flavours for this endpoint: // 1. Bulk: /player?{id1}&{id2}&{id3} — bare keys, @@ -81,31 +92,39 @@ public class PlayerPresenceController( // mid-coroutine. Only redact for OTHER players where the // privacy concern actually applies. var callerId = this.CurrentPlayerId(); - var callerRoom = callerId is long cid ? presence.GetRoom(cid) : null; + var callerRoom = callerId is long cid ? SelfRoom(cid) : null; + var visibility = await LoadVisibilityAsync(ids); + var hiddenFromCaller = await ResolveHiddenAsync(callerId, ids, visibility); var result = ids.Select(id => { - var room = presence.GetRoom(id); - // Other players need both a room presence and an active - // live signal. Use the recent-heartbeat marker as the - // truth source: SignalR can drop while the player is still - // heartbeating, or linger after the game is gone. Presence - // keys intentionally live for minutes to survive long room - // loads, so using them alone leaves friends stuck online - // after a graceful disconnect. var isSelf = callerId == id; - var active = isSelf || presence.IsRecentlyActive(id); - var isOnline = room is not null && active; + // The caller's own row must say exactly what their heartbeat + // says (pending-join target, or null in the offline room): the + // client compares its cached self presence field by field + // (HCHDEHIGEBE.DCIJHIPJHOB) and treats any difference as + // out-of-sync. + var room = isSelf ? callerRoom : presence.GetRoom(id); + // Online = a live signal (recent heartbeat, or a SignalR + // connection), NOT "has a room". Presence keys live for minutes + // to survive long room loads, so a room alone would leave friends + // stuck online after the game is gone; and a player in the offline + // room after matchmake/none has no room but is very much online + // (the 2023 DTO carries isOnline on its own; see + // NotificationService.BuildPresenceUpdatePayload). + // A player who chose "appear offline" (or friends/favorites + // only, when the caller doesn't qualify) is reported exactly + // like a genuinely offline player: no room, isOnline=false. + var isOnline = (isSelf || presenceFanout.IsOnline(id)) && !hiddenFromCaller.Contains(id); + if (!isOnline) room = null; return new PlayerPresenceDto { PlayerId = (int)id, - // StatusVisibility is the player's *preference* for who may - // see them online. The enum is Everyone=0 / FriendsOnly=1 / - // Nobody=2 — so the previous value 1 (mislabeled "Visible") - // actually meant FriendsOnly, and anyone who wasn't your - // friend saw you as OFFLINE. On a private server everyone - // should see everyone, so report Everyone (0). IsOnline=false - // still gates the "Online" badge for offline players. - StatusVisibility = 0, // Everyone + // The player's stored online-status visibility. Self MUST + // see its own stored value: the profile dialog's current + // selection is read from the cached self presence, and a + // hard-coded 0 here snapped it back to Public after every + // presence refresh. + StatusVisibility = visibility.GetValueOrDefault(id, PresenceVisibility.Public), DeviceClass = 0, // Desktop / unknown — not VR VrMovementMode = 0, // Full instance details are safe, and necessary, when the @@ -124,6 +143,58 @@ public class PlayerPresenceController( return Ok(result); } + /// The caller's own room as their heartbeat reports it. + private RoomInstanceDto? SelfRoom(long playerId) + { + if (joinTimeouts.GetPending(playerId) is { DeferPresenceCommit: true } pending) + return pending.TargetRoom; + if (presence.IsInNoRoom(playerId)) return null; + return presence.GetRoom(playerId); + } + + private Task> LoadVisibilityAsync(HashSet ids) => + PresenceBroadcastService.GetStatusVisibilitiesAsync(db, ids); + + /// Ids whose online status the caller may not see. The caller + /// always sees itself; anonymous callers only see Public players. + private async Task> ResolveHiddenAsync( + long? callerId, HashSet ids, Dictionary visibility) + { + var hidden = new HashSet(); + var restricted = visibility + .Where(kv => kv.Value != PresenceVisibility.Public && kv.Key != callerId) + .ToList(); + if (restricted.Count == 0) return hidden; + + if (callerId is not long me) + { + foreach (var kv in restricted) hidden.Add(kv.Key); + return hidden; + } + + HashSet? friends = null; + HashSet? favoritedMe = null; + foreach (var (target, vis) in restricted) + { + if (vis == PresenceVisibility.Offline) { hidden.Add(target); continue; } + + friends ??= (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, me)).ToHashSet(); + if (!friends.Contains(target)) { hidden.Add(target); continue; } + + if (vis == PresenceVisibility.FavoriteFriendsOnly) + { + // Favorited lives on the favoriter's own row + // (RequesterId = favoriter), as RelationshipsController.BuildMerged reads it. + favoritedMe ??= (await db.Relationships + .Where(r => r.TargetId == me && r.Favorited && ids.Contains(r.RequesterId)) + .Select(r => r.RequesterId) + .ToListAsync()).ToHashSet(); + if (!favoritedMe.Contains(target)) hidden.Add(target); + } + } + return hidden; + } + private static bool ShouldExposeFullRoom( long? callerId, RoomInstanceDto? callerRoom, @@ -151,8 +222,8 @@ public class PlayerPresenceDto [JsonPropertyName("playerId")] public int PlayerId { get; set; } - /// PlayerStatusVisibility enum (int): - /// 0 = Hidden / Offline-appearing, 1 = Visible, 2 = FriendsOnly. + /// Online-status visibility enum (int, 2023 client): + /// 0 = Public, 1 = FriendsOnly, 2 = FavoriteFriendsOnly, 3 = Offline. [JsonPropertyName("statusVisibility")] public int StatusVisibility { get; set; } diff --git a/DorkNet.Server/Controllers/RecNetResults.cs b/DorkNet.Server/Controllers/RecNetResults.cs new file mode 100644 index 0000000..01e4e37 --- /dev/null +++ b/DorkNet.Server/Controllers/RecNetResults.cs @@ -0,0 +1,36 @@ +using DorkNet.Models.Auth; +using Microsoft.AspNetCore.Mvc; + +namespace DorkNet.Server.Controllers; + +/// +/// Bodies for the 2023 client's RecNetResult writes. +/// +/// A lot of the client's fire-and-forget writes look status-only from their +/// signature (they return LDGADANDBIO, the untyped promise), but they +/// are not: they go through BFDGEKKNAPB.PEFKBAEAOPD +/// (RecNet.Runtime/BFDGEKKNAPB.txt:353, "ExpandRecNetResult"), which +/// deserialises the body as DJMHAFPGLLN = {Success, Error} +/// (formatter RecNet.Runtime/LMCCNLLHLCJ.txt:191-218 registers +/// Success/success and Error/error) and then runs +/// the <ExpandRecNetResult>b__2_0 lambda +/// (RecNet.Runtime/BFDGEKKNAPB_NestedType___c.txt:61): +/// +/// body null (an empty 200 or null — COLNKALCBOK.txt +/// JIKCMNMKNPH returns default for both) → NullReferenceException, the write +/// shows "Failed to …" although the server applied it; +/// Success missing/false → Promise.Rejected(Error); with no +/// Error key that throws ArgumentException: Error message cannot be +/// null (seen in Player.log as an unobserved task exception). +/// +/// So every such endpoint must answer {"success":true,"error":""} on +/// success, and carry a non-null error on a 200 failure. +/// +public static class RecNetResults +{ + public static IActionResult Ok() => + new OkObjectResult(new RecNetResult { Success = true, Error = string.Empty }); + + public static IActionResult Fail(string error) => + new OkObjectResult(new RecNetResult { Success = false, Error = error }); +} diff --git a/DorkNet.Server/Controllers/Storage/StorageController.cs b/DorkNet.Server/Controllers/Storage/StorageController.cs index 37cf06c..ed9b0e9 100644 --- a/DorkNet.Server/Controllers/Storage/StorageController.cs +++ b/DorkNet.Server/Controllers/Storage/StorageController.cs @@ -65,6 +65,12 @@ public class StorageController( [HttpPost("/upload", Order = -2000)] [Consumes("multipart/form-data")] + // Kestrel's default 30,000,000-byte body cap would 413 the room save of + // a big circuits room now that the served CircuitsV2 chip limit is 30000 + // (docs/recroom-2023-circuitsv2-limits.md). 100 MB matches the admin + // import endpoints and Cloudflare's own per-request ceiling. + [RequestSizeLimit(100_000_000)] + [RequestFormLimits(MultipartBodyLengthLimit = 100_000_000)] public async Task Upload() { // Form parsing — ASP.NET Core deserialises the multipart body diff --git a/DorkNet.Server/Controllers/Strings/StringsController.cs b/DorkNet.Server/Controllers/Strings/StringsController.cs index 2a8e678..01c945a 100644 --- a/DorkNet.Server/Controllers/Strings/StringsController.cs +++ b/DorkNet.Server/Controllers/Strings/StringsController.cs @@ -15,4 +15,21 @@ public class StringsController : ControllerBase [HttpGet("/strings/v1/{locale}")] public ActionResult> GetLocale(string locale) => Ok(new Dictionary()); + + /// The 2023 client's server-string download (RecNet.Runtime/NHILNDLLLHH.txt). + /// Two GETs share the path /strings on the strings host: + /// + /// ?locale=&scopePrefix=client.&eTag= (FMLBOAOBKPM, :531) → + /// Dictionary<string, Dictionary<long,string>>, scope → id → text; + /// ?locale=&scope=client.{name}&eTag= (BNEELKAKCEN, :3) → + /// Dictionary<long,string>. + /// + /// The localisation service stores the first answer + /// (RecRoom.Localization.Runtime/IBAJDNIMADI.txt BICKHBAJPIG) and its scope lookup + /// (NHLKEJLGGIE) null-derefs when the download never succeeded but returns an empty + /// table for a scope the server did not send. DorkNet keeps no server-side string + /// overrides, so the truthful answer is an empty JSON object for either query — + /// the lookups then fall back to the bundled text. The path used to 404. + [HttpGet("/strings")] + public IActionResult GetServerStrings() => Content("{}", "application/json"); } diff --git a/DorkNet.Server/Data/Entities/PlayerEntity.cs b/DorkNet.Server/Data/Entities/PlayerEntity.cs index 17f1c4e..1975fef 100644 --- a/DorkNet.Server/Data/Entities/PlayerEntity.cs +++ b/DorkNet.Server/Data/Entities/PlayerEntity.cs @@ -116,6 +116,29 @@ public class PlayerEntity [MaxLength(32)] public string? Phone { get; set; } + // ── 2023 profile fields ────────────────────────────────────────────── + // Written by PUT account/me/{emoji,bannerimage,personalpronouns, + // identityflags} and read back on every account DTO (account/me, + // account/{id}, account/bulk, search, AccountUpdate push) under the + // keys displayEmoji / bannerImage / personalPronouns / identityFlags. + + /// Emoji shown beside the display name ("" = none). + [MaxLength(32)] + public string DisplayEmoji { get; set; } = string.Empty; + + /// Profile banner image name (a storage image the player + /// already uploaded, e.g. one of their photos). Null = no banner. + [MaxLength(256)] + public string? BannerImageName { get; set; } + + /// Pronoun bitmask (client flags enum: SheHer=1, HeHim=2, + /// TheyThem=4, ZeHir=8, ZeZir=16, XeXem=32). + public int PersonalPronouns { get; set; } = 0; + + /// Identity-flag bitmask (client flags enum: LGBTQIA=1, + /// Transgender=2, Bisexual=4, …). + public int IdentityFlags { get; set; } = 0; + // Navigation public AvatarEntity? Avatar { get; set; } public List Settings { get; set; } = []; diff --git a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs index 1f5a176..6caed30 100644 --- a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs +++ b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs @@ -108,5 +108,14 @@ public class ServerSettingsEntity /// admins curate the base-room list from the SPA without a redeploy. public string BaseRoomNamesJson { get; set; } = string.Empty; + /// Admin-managed CircuitsV2 creation limits, served to the + /// client as the UGC.CircuitsV2.Limit.* gameconfig keys. The + /// network entry carries the per-frame RPC and byte budgets a circuit + /// may spend; DorkNet ships them effectively unlimited, where retail + /// shipped 5 RPCs/frame and 1024 bytes/60 frames. Stored as JSON so + /// new limit keys don't need a table migration. + /// See . + public string CircuitsV2LimitsJson { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/DorkNet.Server/Data/url-coverage.md b/DorkNet.Server/Data/url-coverage.md index 37a3d1e..a3595ba 100644 --- a/DorkNet.Server/Data/url-coverage.md +++ b/DorkNet.Server/Data/url-coverage.md @@ -111,7 +111,7 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`, | Verb | URL | Status | Handler | |---|---|---|---| -| GET | `api/gameconfigs/v1/all` | STUB | `GameConfigurationsController.GameConfigsAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Returns `[]`. | +| GET | `api/gameconfigs/v1/all` | REAL | `GameConfigurationsController.GetAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Serves the admin-managed key/value set from `ServerSettingsService.GetGameConfigurationsAsync`: Rec Center doors, the typed keys confirmed in the 2020.12 decomp, the `UGC.CircuitsV2.Limit.*` family (see `docs/recroom-2023-circuitsv2-limits.md`), and `PlayMenuCarouselOrdering_1` (the 2023 play-menu carousel priority map; without it the client logs "Could not find GameConfig for PlayMenuCarouselOrdering_1" and keeps prefab order). | ### `api/groups/` @@ -391,6 +391,18 @@ These all live on `match.rec.net` (locally: `match.localhost`). | 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 | +2023 additions on the auth / accounts / strings hosts: + +| Verb | URL | Status | Handler | +|---|---|---|---| +| POST | `connect/token` `grant_type=urn:ietf:params:oauth:grant-type:device_code` | REAL | `Auth.AuthController.Token` via `DeviceAuthorizations.RedeemAsync` — 400 `authorization_pending` until approved, then the approver's token pair; single use | +| POST | `connect/token` `grant_type=login_token` | REAL | `Auth.AuthController.Token` — redeems an `account/me/createlogintoken` token for its own `account_id`; single use | +| POST | `account/me/createlogintoken` | REAL | `Accounts.AccountsController.CreateLoginToken` — bare JSON string (the client reads `FGLDKEJLAKB`) | +| POST | `account/me/remoteauth` | REAL | `Accounts.AccountsController.RemoteAuth` — approves a device `user_code` for the caller; `{success,error}` RecNetResult | +| POST | `account/bulk/email`, `account/bulk/phone` | REAL | `Accounts.AccountsController.Bulk` — contact lookup, repeated `email` / `phone` form fields, account list | +| GET | `strings?locale&scopePrefix|scope&eTag` | REAL | `Strings.StringsController.GetServerStrings` — empty table (no server-side string overrides) instead of a 404 | +| GET | `api/incentivizedreferrals/referrals?take&continuationToken` | REAL | `IncentivizedReferralsController.Referrals` — `{Referrals:[{InviteeAccountId,CreatedAt,VerifiedAt}],ContinuationToken}` | + ### CDN host (`cdn.rec.net` / `storage` / `data`) Served by `Controllers/Cdn/CdnController` (catch-all that wins routing on every host). diff --git a/DorkNet.Server/Hubs/NotifyHub.cs b/DorkNet.Server/Hubs/NotifyHub.cs index 1258bce..c7db3b7 100644 --- a/DorkNet.Server/Hubs/NotifyHub.cs +++ b/DorkNet.Server/Hubs/NotifyHub.cs @@ -42,8 +42,7 @@ public class NotifyHub( OnlinePresenceService presence, PlayerPresenceService playerRooms, NotificationService notifications, - DorkNetDbContext db, - ServerSettingsService serverSettings, + PresenceBroadcastService presenceFanout, ILogger logger) : Hub { /// SignalR group name for a given player id. Use this @@ -108,18 +107,11 @@ public class NotifyHub( // friend's list for the rest of the session even though they were // still playing. // - // Only push when a room is already known. The presence payload - // derives isOnline from roomInstance being non-null, so a push with - // no room reads as OFFLINE — worse than staying quiet. On a fresh - // boot there is no room yet and the /goto that follows sends the - // real presence anyway; this branch is for reconnects, where the - // cached room is exactly what friends need to see again. - if (cameOnline && playerRooms.GetRoom(id) is { } room) - { - var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, id); - if (friendIds.Count > 0) - await notifications.PlayerCameOnline(friendIds, id, room); - } + // isOnline is sent explicitly now, so this no longer needs a + // known room: a player who connects before matchmaking, or who is + // sitting in the offline room, is online with roomInstance null. + if (cameOnline) + await presenceFanout.PublishCurrentAsync(id); } else { @@ -139,9 +131,17 @@ public class NotifyHub( await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupForPlayer(id)); if (wentOffline) { - var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, id); - if (friendIds.Count > 0) - await notifications.PlayerWentOffline(friendIds, id); + // The socket can drop under a live session (the server or a + // proxy closes it; the client logs "RecNet HubConnection error: + // ... TCP Stream closed unexpectedly by the remote server" and + // reconnects by itself). While the player is still + // heartbeating, wait one activity window before calling them + // offline; a real exit either logs out (immediate offline push) + // or stops heartbeating and is caught by the deferred check. + if (playerRooms.IsRecentlyActive(id)) + presenceFanout.ScheduleOfflineCheck(id); + else + await presenceFanout.PublishOfflineAsync(id); } logger.LogInformation( "[hub] disconnected player={PlayerId} conn={ConnectionId} wentOffline={WentOffline} error={Error}", diff --git a/DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.cs b/DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.cs index c8f0987..59577ee 100644 --- a/DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.cs +++ b/DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.cs @@ -11,19 +11,25 @@ namespace DorkNet.Server.Migrations /// protected override void Up(MigrationBuilder migrationBuilder) { + // Defaults must match the entity contract (TestCaseEntity inits + // CommentsJson to "[]", ClubEntity inits ClubChatEnabled to true): + // existing rows get the column default, and "" is not valid JSON + // while false silently muted every pre-existing club's chat. The + // ClubChatDefaultRepair migration fixes databases that applied + // this migration before the defaults were corrected. migrationBuilder.AddColumn( name: "CommentsJson", table: "TestCases", type: "TEXT", nullable: false, - defaultValue: ""); + defaultValue: "[]"); migrationBuilder.AddColumn( name: "ClubChatEnabled", table: "Clubs", type: "INTEGER", nullable: false, - defaultValue: false); + defaultValue: true); migrationBuilder.AddColumn( name: "MinLevel", diff --git a/DorkNet.Server/Migrations/20260801221530_ClubChatDefaultRepair.Designer.cs b/DorkNet.Server/Migrations/20260801221530_ClubChatDefaultRepair.Designer.cs new file mode 100644 index 0000000..8b38d32 --- /dev/null +++ b/DorkNet.Server/Migrations/20260801221530_ClubChatDefaultRepair.Designer.cs @@ -0,0 +1,3654 @@ +// +using System; +using DorkNet.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + [DbContext(typeof(DorkNetDbContext))] + [Migration("20260801221530_ClubChatDefaultRepair")] + partial class ClubChatDefaultRepair + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomAvatarItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OutfitSelectionsV2") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBuiltIn") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WordsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CharadesWordLists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ModerationState") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey") + .IsUnique(); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SnoozeUntil") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ThreadKey", "PlayerId") + .IsUnique(); + + b.ToTable("ChatThreadMembers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Slot") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "Slot") + .IsUnique(); + + b.ToTable("ClubAdditionalImages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "CreatedAt"); + + b.ToTable("ClubAnnouncements"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AnnouncementId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("AnnouncementId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubAnnouncementReads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoryTagId") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CategoryTagId"); + + b.HasIndex("ClubId", "CategoryTagId") + .IsUnique(); + + b.ToTable("ClubCategoryAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("ClubCategoryTags"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowJuniors") + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ClubChatEnabled") + .HasColumnType("INTEGER"); + + b.Property("ClubType") + .HasColumnType("INTEGER"); + + b.Property("ClubhouseRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsRRO") + .HasColumnType("INTEGER"); + + b.Property("Joinability") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApproveMember") + .HasColumnType("INTEGER"); + + b.Property("BanUnban") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreateEvent") + .HasColumnType("INTEGER"); + + b.Property("EditDetails") + .HasColumnType("INTEGER"); + + b.Property("EditPermissionSettings") + .HasColumnType("INTEGER"); + + b.Property("MembershipType") + .HasColumnType("INTEGER"); + + b.Property("PostAnnouncement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "MembershipType") + .IsUnique(); + + b.ToTable("ClubRolePermissions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SubscribedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("PlayerId", "ClubId") + .IsUnique(); + + b.ToTable("ClubSubscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BaseAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("PurchaseCount") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsFeatured"); + + b.HasIndex("IsPublic"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("CustomAvatarItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("CustomAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CustomAvatarItemId"); + + b.HasIndex("PlayerId", "CustomAvatarItemId") + .IsUnique(); + + b.ToTable("CustomAvatarItemOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GrantedItemId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Offer1ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer2ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer3ItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.Property("SelectedAt") + .HasColumnType("TEXT"); + + b.Property("SelectedGiftDropId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SelectedAt"); + + b.ToTable("GameRewardSelections"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SourceStoreItemId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "ItemKey", "ItemType") + .IsUnique(); + + b.ToTable("ItemWishlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("EarnedAt") + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "EventKey"); + + b.ToTable("Keepsakes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b => + { + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LowerIsBetter") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueFormat") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RoomId", "Channel"); + + b.HasIndex("RoomId"); + + b.ToTable("LeaderboardChannelMeta"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("RoomId", "StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PlatformMask") + .HasColumnType("INTEGER"); + + b.Property("RoomNamesCsv") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LoadingScreenTips"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastIp") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PendingDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCommunityTeam") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CustomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsCurated") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("SupportsJuniors") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.Property("WarningMask") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsCurated"); + + b.HasIndex("Name"); + + b.ToTable("Playlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cheered") + .HasColumnType("INTEGER"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlaylistId", "PlayerId") + .IsUnique(); + + b.ToTable("PlaylistInteractions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlaylistId"); + + b.HasIndex("RoomId"); + + b.HasIndex("PlaylistId", "OrderIndex"); + + b.ToTable("PlaylistRooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.Property("LatestInviteMessageId") + .HasColumnType("INTEGER"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyId") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomConsumables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyCode") + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomConsumableId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomConsumableId"); + + b.HasIndex("PlayerId", "RoomConsumableId") + .IsUnique(); + + b.ToTable("RoomConsumableOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomCurrencyId"); + + b.HasIndex("PlayerId", "RoomCurrencyId") + .IsUnique(); + + b.ToTable("RoomCurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("DailyLimit") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomCurrencies"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomCurrencyId"); + + b.ToTable("RoomCurrencyPurchaseOffers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowNewUsers") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HiddenFromBrowse") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("IsRoomLinkedToRecRoomStudio") + .HasColumnType("INTEGER"); + + b.Property("IsStudioRoom") + .HasColumnType("INTEGER"); + + b.Property("LoadScreensJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayerCalculationMode") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("PromoExternalContentJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PromoImagesJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("StudioSessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(174) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "Name"); + + b.ToTable("RoomKeys"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PaidPrice") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PurchasedAt") + .HasColumnType("TEXT"); + + b.Property("RoomKeyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomKeyId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomKeyPurchases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accepted") + .HasColumnType("INTEGER"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "PlayerId", "Role") + .IsUnique(); + + b.ToTable("RoomRoles"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StudioAssetBundleNamesCsv") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("StudioSubRoomDataSaveId") + .HasColumnType("INTEGER"); + + b.Property("StudioUnityAssetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("AllAvatarItemsOwned") + .HasColumnType("INTEGER"); + + b.Property("BaseRoomNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CharadesSlotBindingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscoveredGameConfigsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("GlobalFriendsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PlayMenuTagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ProfanityFilterDisabled") + .HasColumnType("INTEGER"); + + b.Property("RecCenterDoorsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("RoomBlobVersionClampDisabled") + .HasColumnType("INTEGER"); + + b.Property("SignupsDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeeklyChallengeRewardJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WeeklyChallengesCompletedRequired") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("WeeklyChallengesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Descriptor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SignupCodes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("UgcPurchasables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260801221530_ClubChatDefaultRepair.cs b/DorkNet.Server/Migrations/20260801221530_ClubChatDefaultRepair.cs new file mode 100644 index 0000000..31fb59d --- /dev/null +++ b/DorkNet.Server/Migrations/20260801221530_ClubChatDefaultRepair.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// One-shot data repair for databases that applied + /// ClubPermissionsGalleryAndModeration while it carried the wrong column + /// defaults (ClubChatEnabled false, CommentsJson ""). Those defaults + /// contradicted the entity contract — every pre-existing club had its + /// chat silently disabled and every pre-existing test case held invalid + /// JSON. A migration (rather than a bootstrap statement) because the flip + /// must run exactly once: re-running it on every boot would override any + /// club that later disables chat on purpose. Fresh databases run the + /// corrected AddColumn defaults first, so both UPDATEs no-op there. + public partial class ClubChatDefaultRepair : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + @"UPDATE ""Clubs"" SET ""ClubChatEnabled"" = TRUE WHERE ""ClubChatEnabled"" = FALSE;"); + migrationBuilder.Sql( + @"UPDATE ""TestCases"" SET ""CommentsJson"" = '[]' WHERE ""CommentsJson"" = '';"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Data repair — nothing meaningful to undo. + } + } +} diff --git a/DorkNet.Server/Migrations/20260802190504_CircuitsV2Limits.Designer.cs b/DorkNet.Server/Migrations/20260802190504_CircuitsV2Limits.Designer.cs new file mode 100644 index 0000000..3992313 --- /dev/null +++ b/DorkNet.Server/Migrations/20260802190504_CircuitsV2Limits.Designer.cs @@ -0,0 +1,3658 @@ +// +using System; +using DorkNet.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + [DbContext(typeof(DorkNetDbContext))] + [Migration("20260802190504_CircuitsV2Limits")] + partial class CircuitsV2Limits + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomAvatarItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OutfitSelectionsV2") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBuiltIn") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WordsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CharadesWordLists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ModerationState") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey") + .IsUnique(); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SnoozeUntil") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ThreadKey", "PlayerId") + .IsUnique(); + + b.ToTable("ChatThreadMembers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Slot") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "Slot") + .IsUnique(); + + b.ToTable("ClubAdditionalImages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "CreatedAt"); + + b.ToTable("ClubAnnouncements"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AnnouncementId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("AnnouncementId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubAnnouncementReads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoryTagId") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CategoryTagId"); + + b.HasIndex("ClubId", "CategoryTagId") + .IsUnique(); + + b.ToTable("ClubCategoryAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("ClubCategoryTags"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowJuniors") + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ClubChatEnabled") + .HasColumnType("INTEGER"); + + b.Property("ClubType") + .HasColumnType("INTEGER"); + + b.Property("ClubhouseRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsRRO") + .HasColumnType("INTEGER"); + + b.Property("Joinability") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApproveMember") + .HasColumnType("INTEGER"); + + b.Property("BanUnban") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreateEvent") + .HasColumnType("INTEGER"); + + b.Property("EditDetails") + .HasColumnType("INTEGER"); + + b.Property("EditPermissionSettings") + .HasColumnType("INTEGER"); + + b.Property("MembershipType") + .HasColumnType("INTEGER"); + + b.Property("PostAnnouncement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "MembershipType") + .IsUnique(); + + b.ToTable("ClubRolePermissions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SubscribedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("PlayerId", "ClubId") + .IsUnique(); + + b.ToTable("ClubSubscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BaseAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("PurchaseCount") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsFeatured"); + + b.HasIndex("IsPublic"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("CustomAvatarItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("CustomAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CustomAvatarItemId"); + + b.HasIndex("PlayerId", "CustomAvatarItemId") + .IsUnique(); + + b.ToTable("CustomAvatarItemOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GrantedItemId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Offer1ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer2ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer3ItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.Property("SelectedAt") + .HasColumnType("TEXT"); + + b.Property("SelectedGiftDropId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SelectedAt"); + + b.ToTable("GameRewardSelections"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SourceStoreItemId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "ItemKey", "ItemType") + .IsUnique(); + + b.ToTable("ItemWishlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("EarnedAt") + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "EventKey"); + + b.ToTable("Keepsakes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b => + { + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LowerIsBetter") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueFormat") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RoomId", "Channel"); + + b.HasIndex("RoomId"); + + b.ToTable("LeaderboardChannelMeta"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("RoomId", "StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PlatformMask") + .HasColumnType("INTEGER"); + + b.Property("RoomNamesCsv") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LoadingScreenTips"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastIp") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PendingDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCommunityTeam") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CustomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsCurated") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("SupportsJuniors") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.Property("WarningMask") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsCurated"); + + b.HasIndex("Name"); + + b.ToTable("Playlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cheered") + .HasColumnType("INTEGER"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlaylistId", "PlayerId") + .IsUnique(); + + b.ToTable("PlaylistInteractions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlaylistId"); + + b.HasIndex("RoomId"); + + b.HasIndex("PlaylistId", "OrderIndex"); + + b.ToTable("PlaylistRooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.Property("LatestInviteMessageId") + .HasColumnType("INTEGER"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyId") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomConsumables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyCode") + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomConsumableId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomConsumableId"); + + b.HasIndex("PlayerId", "RoomConsumableId") + .IsUnique(); + + b.ToTable("RoomConsumableOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomCurrencyId"); + + b.HasIndex("PlayerId", "RoomCurrencyId") + .IsUnique(); + + b.ToTable("RoomCurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("DailyLimit") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomCurrencies"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomCurrencyId"); + + b.ToTable("RoomCurrencyPurchaseOffers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowNewUsers") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HiddenFromBrowse") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("IsRoomLinkedToRecRoomStudio") + .HasColumnType("INTEGER"); + + b.Property("IsStudioRoom") + .HasColumnType("INTEGER"); + + b.Property("LoadScreensJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayerCalculationMode") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("PromoExternalContentJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PromoImagesJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("StudioSessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(174) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "Name"); + + b.ToTable("RoomKeys"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PaidPrice") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PurchasedAt") + .HasColumnType("TEXT"); + + b.Property("RoomKeyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomKeyId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomKeyPurchases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accepted") + .HasColumnType("INTEGER"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "PlayerId", "Role") + .IsUnique(); + + b.ToTable("RoomRoles"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StudioAssetBundleNamesCsv") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("StudioSubRoomDataSaveId") + .HasColumnType("INTEGER"); + + b.Property("StudioUnityAssetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("AllAvatarItemsOwned") + .HasColumnType("INTEGER"); + + b.Property("BaseRoomNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CharadesSlotBindingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CircuitsV2LimitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscoveredGameConfigsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("GlobalFriendsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PlayMenuTagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ProfanityFilterDisabled") + .HasColumnType("INTEGER"); + + b.Property("RecCenterDoorsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("RoomBlobVersionClampDisabled") + .HasColumnType("INTEGER"); + + b.Property("SignupsDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeeklyChallengeRewardJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WeeklyChallengesCompletedRequired") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("WeeklyChallengesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Descriptor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SignupCodes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("UgcPurchasables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260802190504_CircuitsV2Limits.cs b/DorkNet.Server/Migrations/20260802190504_CircuitsV2Limits.cs new file mode 100644 index 0000000..16ccce0 --- /dev/null +++ b/DorkNet.Server/Migrations/20260802190504_CircuitsV2Limits.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class CircuitsV2Limits : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CircuitsV2LimitsJson", + table: "ServerSettings", + type: "TEXT", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CircuitsV2LimitsJson", + table: "ServerSettings"); + } + } +} diff --git a/DorkNet.Server/Migrations/20261015110121_PlayerProfile2023Fields.Designer.cs b/DorkNet.Server/Migrations/20261015110121_PlayerProfile2023Fields.Designer.cs new file mode 100644 index 0000000..64a149a --- /dev/null +++ b/DorkNet.Server/Migrations/20261015110121_PlayerProfile2023Fields.Designer.cs @@ -0,0 +1,3673 @@ +// +using System; +using DorkNet.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + [DbContext(typeof(DorkNetDbContext))] + [Migration("20261015110121_PlayerProfile2023Fields")] + partial class PlayerProfile2023Fields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomAvatarItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OutfitSelectionsV2") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBuiltIn") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WordsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CharadesWordLists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ModerationState") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey") + .IsUnique(); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SnoozeUntil") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ThreadKey", "PlayerId") + .IsUnique(); + + b.ToTable("ChatThreadMembers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Slot") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "Slot") + .IsUnique(); + + b.ToTable("ClubAdditionalImages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "CreatedAt"); + + b.ToTable("ClubAnnouncements"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AnnouncementId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("AnnouncementId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubAnnouncementReads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoryTagId") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CategoryTagId"); + + b.HasIndex("ClubId", "CategoryTagId") + .IsUnique(); + + b.ToTable("ClubCategoryAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("ClubCategoryTags"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowJuniors") + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ClubChatEnabled") + .HasColumnType("INTEGER"); + + b.Property("ClubType") + .HasColumnType("INTEGER"); + + b.Property("ClubhouseRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsRRO") + .HasColumnType("INTEGER"); + + b.Property("Joinability") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApproveMember") + .HasColumnType("INTEGER"); + + b.Property("BanUnban") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreateEvent") + .HasColumnType("INTEGER"); + + b.Property("EditDetails") + .HasColumnType("INTEGER"); + + b.Property("EditPermissionSettings") + .HasColumnType("INTEGER"); + + b.Property("MembershipType") + .HasColumnType("INTEGER"); + + b.Property("PostAnnouncement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "MembershipType") + .IsUnique(); + + b.ToTable("ClubRolePermissions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SubscribedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("PlayerId", "ClubId") + .IsUnique(); + + b.ToTable("ClubSubscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BaseAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("PurchaseCount") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsFeatured"); + + b.HasIndex("IsPublic"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("CustomAvatarItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("CustomAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CustomAvatarItemId"); + + b.HasIndex("PlayerId", "CustomAvatarItemId") + .IsUnique(); + + b.ToTable("CustomAvatarItemOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GrantedItemId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Offer1ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer2ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer3ItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.Property("SelectedAt") + .HasColumnType("TEXT"); + + b.Property("SelectedGiftDropId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SelectedAt"); + + b.ToTable("GameRewardSelections"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SourceStoreItemId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "ItemKey", "ItemType") + .IsUnique(); + + b.ToTable("ItemWishlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("EarnedAt") + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "EventKey"); + + b.ToTable("Keepsakes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b => + { + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LowerIsBetter") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueFormat") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RoomId", "Channel"); + + b.HasIndex("RoomId"); + + b.ToTable("LeaderboardChannelMeta"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("RoomId", "StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PlatformMask") + .HasColumnType("INTEGER"); + + b.Property("RoomNamesCsv") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LoadingScreenTips"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastIp") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PendingDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("BannerImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayEmoji") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IdentityFlags") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCommunityTeam") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PersonalPronouns") + .HasColumnType("INTEGER"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CustomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsCurated") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("SupportsJuniors") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.Property("WarningMask") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsCurated"); + + b.HasIndex("Name"); + + b.ToTable("Playlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cheered") + .HasColumnType("INTEGER"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlaylistId", "PlayerId") + .IsUnique(); + + b.ToTable("PlaylistInteractions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlaylistId"); + + b.HasIndex("RoomId"); + + b.HasIndex("PlaylistId", "OrderIndex"); + + b.ToTable("PlaylistRooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.Property("LatestInviteMessageId") + .HasColumnType("INTEGER"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyId") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomConsumables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyCode") + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomConsumableId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomConsumableId"); + + b.HasIndex("PlayerId", "RoomConsumableId") + .IsUnique(); + + b.ToTable("RoomConsumableOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomCurrencyId"); + + b.HasIndex("PlayerId", "RoomCurrencyId") + .IsUnique(); + + b.ToTable("RoomCurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("DailyLimit") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomCurrencies"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomCurrencyId"); + + b.ToTable("RoomCurrencyPurchaseOffers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowNewUsers") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HiddenFromBrowse") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("IsRoomLinkedToRecRoomStudio") + .HasColumnType("INTEGER"); + + b.Property("IsStudioRoom") + .HasColumnType("INTEGER"); + + b.Property("LoadScreensJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayerCalculationMode") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("PromoExternalContentJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PromoImagesJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("StudioSessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(174) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "Name"); + + b.ToTable("RoomKeys"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PaidPrice") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PurchasedAt") + .HasColumnType("TEXT"); + + b.Property("RoomKeyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomKeyId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomKeyPurchases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accepted") + .HasColumnType("INTEGER"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "PlayerId", "Role") + .IsUnique(); + + b.ToTable("RoomRoles"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StudioAssetBundleNamesCsv") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("StudioSubRoomDataSaveId") + .HasColumnType("INTEGER"); + + b.Property("StudioUnityAssetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("AllAvatarItemsOwned") + .HasColumnType("INTEGER"); + + b.Property("BaseRoomNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CharadesSlotBindingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CircuitsV2LimitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscoveredGameConfigsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("GlobalFriendsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PlayMenuTagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ProfanityFilterDisabled") + .HasColumnType("INTEGER"); + + b.Property("RecCenterDoorsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("RoomBlobVersionClampDisabled") + .HasColumnType("INTEGER"); + + b.Property("SignupsDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeeklyChallengeRewardJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WeeklyChallengesCompletedRequired") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("WeeklyChallengesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Descriptor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SignupCodes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("UgcPurchasables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20261015110121_PlayerProfile2023Fields.cs b/DorkNet.Server/Migrations/20261015110121_PlayerProfile2023Fields.cs new file mode 100644 index 0000000..4b16f16 --- /dev/null +++ b/DorkNet.Server/Migrations/20261015110121_PlayerProfile2023Fields.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class PlayerProfile2023Fields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BannerImageName", + table: "Players", + type: "TEXT", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "DisplayEmoji", + table: "Players", + type: "TEXT", + maxLength: 32, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "IdentityFlags", + table: "Players", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PersonalPronouns", + table: "Players", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BannerImageName", + table: "Players"); + + migrationBuilder.DropColumn( + name: "DisplayEmoji", + table: "Players"); + + migrationBuilder.DropColumn( + name: "IdentityFlags", + table: "Players"); + + migrationBuilder.DropColumn( + name: "PersonalPronouns", + table: "Players"); + } + } +} diff --git a/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs index 59d7102..8677b2c 100644 --- a/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs +++ b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs @@ -1819,6 +1819,10 @@ namespace DorkNet.Server.Migrations b.Property("BannedUntil") .HasColumnType("TEXT"); + b.Property("BannerImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + b.Property("Bio") .IsRequired() .HasMaxLength(512) @@ -1842,6 +1846,11 @@ namespace DorkNet.Server.Migrations .HasMaxLength(128) .HasColumnType("TEXT"); + b.Property("DisplayEmoji") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + b.Property("DisplayName") .IsRequired() .HasMaxLength(64) @@ -1851,6 +1860,9 @@ namespace DorkNet.Server.Migrations .HasMaxLength(256) .HasColumnType("TEXT"); + b.Property("IdentityFlags") + .HasColumnType("INTEGER"); + b.Property("IsAdmin") .HasColumnType("INTEGER"); @@ -1892,6 +1904,9 @@ namespace DorkNet.Server.Migrations .HasMaxLength(128) .HasColumnType("TEXT"); + b.Property("PersonalPronouns") + .HasColumnType("INTEGER"); + b.Property("Phone") .HasMaxLength(32) .HasColumnType("TEXT"); @@ -3234,6 +3249,10 @@ namespace DorkNet.Server.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("CircuitsV2LimitsJson") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("DiscoveredGameConfigsJson") .IsRequired() .ValueGeneratedOnAdd() diff --git a/DorkNet.Server/Services/AccountDtos.cs b/DorkNet.Server/Services/AccountDtos.cs new file mode 100644 index 0000000..67411b5 --- /dev/null +++ b/DorkNet.Server/Services/AccountDtos.cs @@ -0,0 +1,46 @@ +using DorkNet.Models.Auth; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Services; + +/// +/// Single projection from to the account wire +/// DTOs. Every account read (account/me, account/{id}, account/bulk, +/// account/search, account/create) and the AccountUpdate / +/// SelfAccountUpdate pushes go through here so a profile field written by +/// one endpoint is visible on all of them — previously each call site built +/// its own DTO and the 2023 profile fields were never emitted anywhere. +/// +public static class AccountDtos +{ + public static RecNetAccount Public(PlayerEntity p) => Fill(new RecNetAccount(), p); + + public static RecNetSelfAccount Self(PlayerEntity p) + { + var a = Fill(new RecNetSelfAccount(), p); + a.Email = p.Email ?? string.Empty; + a.Phone = p.Phone ?? string.Empty; + a.Birthday = p.Birthday ?? new DateTime(2000, 1, 1); + a.JuniorState = p.IsJunior ? 1 : 0; + a.ParentAccountId = null; + return a; + } + + private static T Fill(T a, PlayerEntity p) where T : RecNetAccount + { + a.AccountId = (int)p.Id; + a.RawUsername = p.Username; + a.Username = p.Username; + a.DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? p.Username : p.DisplayName; + a.ProfileImage = p.ProfileImageName ?? string.Empty; + a.TreatAsJunior = p.IsJunior; + a.HasBirthday = true; + a.Platforms = 1; + a.CreatedAt = p.CreatedAt; + a.DisplayEmoji = p.DisplayEmoji ?? string.Empty; + a.BannerImage = p.BannerImageName ?? string.Empty; + a.PersonalPronouns = p.PersonalPronouns; + a.IdentityFlags = p.IdentityFlags; + return a; + } +} diff --git a/DorkNet.Server/Services/DeviceAuthorizations.cs b/DorkNet.Server/Services/DeviceAuthorizations.cs new file mode 100644 index 0000000..5292730 --- /dev/null +++ b/DorkNet.Server/Services/DeviceAuthorizations.cs @@ -0,0 +1,109 @@ +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace DorkNet.Server.Services; + +/// +/// OAuth device-authorization grant as the 2023 client drives it. +/// +/// 1. The device that wants to sign in calls connect/deviceauthorization +/// and shows the player the user_code (reader +/// RecNet.Runtime/AMPFGJPKHOD.txt:399-598: device_code, user_code, +/// verification_uri(_complete), expires_in, interval). +/// 2. It polls connect/token with +/// grant_type=urn:ietf:params:oauth:grant-type:device_code and +/// device_code (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451). +/// A 400 whose error is authorization_pending or +/// slow_down means "keep polling" +/// (LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116); anything else +/// ends the attempt. +/// 3. A signed-in client approves the code with +/// account/me/remoteauth (form field code). +/// +/// State lives in one PlayerSettings row per authorization, owned by the system +/// account: key deviceauthorization:{device_code}, value +/// {user_code}|{expiresAtUtc:O}, plus |{approverAccountId} once +/// approved. Redeeming deletes the row, so a device code is single-use. +/// +public static class DeviceAuthorizations +{ + public const string KeyPrefix = "deviceauthorization:"; + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15); + + public enum RedeemStatus { Invalid, Expired, Pending, Approved } + + public sealed record Created(string DeviceCode, string UserCode, int ExpiresInSeconds); + + public static async Task CreateAsync(DorkNetDbContext db, DateTime nowUtc) + { + var deviceCode = Guid.NewGuid().ToString("N"); + var userCode = deviceCode[..8].ToUpperInvariant(); + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = RelationshipQueries.SystemAccountId, + Key = KeyPrefix + deviceCode, + Value = $"{userCode}|{(nowUtc + Lifetime):O}", + }); + await db.SaveChangesAsync(); + return new Created(deviceCode, userCode, (int)Lifetime.TotalSeconds); + } + + /// Bind the pending authorization whose user code is + /// to . False when + /// no live, unapproved authorization carries that code. + public static async Task ApproveAsync( + DorkNetDbContext db, string userCode, long approverId, DateTime nowUtc) + { + var code = NormaliseUserCode(userCode); + if (code.Length == 0) return false; + + var prefix = code + "|"; + var rows = await db.PlayerSettings + .Where(s => s.Key.StartsWith(KeyPrefix) && s.Value.StartsWith(prefix)) + .ToListAsync(); + foreach (var row in rows) + { + var parts = row.Value.Split('|'); + if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc) + continue; + if (parts.Length >= 3 && parts[2].Length > 0) continue; // already approved + row.Value = $"{parts[0]}|{parts[1]}|{approverId}"; + await db.SaveChangesAsync(); + return true; + } + return false; + } + + /// One poll from the device. Approved authorizations are consumed + /// and yield the approving account. + public static async Task<(RedeemStatus Status, long AccountId)> RedeemAsync( + DorkNetDbContext db, string? deviceCode, DateTime nowUtc) + { + if (string.IsNullOrWhiteSpace(deviceCode)) return (RedeemStatus.Invalid, 0); + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.Key == KeyPrefix + deviceCode.Trim()); + if (row is null) return (RedeemStatus.Invalid, 0); + + var parts = row.Value.Split('|'); + if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc) + { + db.PlayerSettings.Remove(row); + await db.SaveChangesAsync(); + return (RedeemStatus.Expired, 0); + } + + if (parts.Length < 3 || !long.TryParse(parts[2], out var approver)) + return (RedeemStatus.Pending, 0); + + db.PlayerSettings.Remove(row); + await db.SaveChangesAsync(); + return (RedeemStatus.Approved, approver); + } + + private static string NormaliseUserCode(string? raw) => + new string((raw ?? string.Empty).Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant(); + + private static bool TryParseExpiry(string raw, out DateTime expiresUtc) => + DateTime.TryParse(raw, null, System.Globalization.DateTimeStyles.RoundtripKind, out expiresUtc); +} diff --git a/DorkNet.Server/Services/JoinTimeoutService.cs b/DorkNet.Server/Services/JoinTimeoutService.cs index 9bf4f09..65507da 100644 --- a/DorkNet.Server/Services/JoinTimeoutService.cs +++ b/DorkNet.Server/Services/JoinTimeoutService.cs @@ -10,9 +10,27 @@ namespace DorkNet.Server.Services; public sealed class JoinTimeoutService( PlayerPresenceService presence, NotificationService notifications, + PresenceBroadcastService fanout, ILogger logger) { - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + /// + /// How long a join may stay unreported before the server gives up on it. + /// + /// This used to be 30 seconds, which is shorter than a real 2023 room + /// load: the client reports its join result only after the room's assets + /// have downloaded and the player has spawned, and a large maker room + /// regularly takes longer than that (in the field: matchmake/room at + /// 09:16:35, Photon joined at 09:16:41, the load still running at 09:17:33). + /// The watchdog then cleared presence mid-load, the next heartbeat found + /// no room and answered with the dorm, and the client logged "presence + /// heartbeat response indicates local presence is out-of-sync" and + /// cancelled the load ("OnPlayerPresenceUpdate is cancelling room load"). + /// + /// The 2023 client reports its own failures (reportjoinresult with + /// FailedTo*) and runs its fallback chain, so this is only a backstop for + /// a client that silently stopped mid-join. + /// + public static TimeSpan Timeout { get; } = TimeSpan.FromMinutes(3); private readonly ConcurrentDictionary _pending = new(); public void MarkPending(long playerId, RoomInstanceDto? room, bool deferPresenceCommit = false) @@ -40,6 +58,10 @@ public sealed class JoinTimeoutService( _ = WatchAsync(playerId, pending); } + /// Drop the player's pending join without touching presence — + /// the client abandoned it (matchmake/none, logout). + public void Cancel(long playerId, string reason) => ClearForPlayer(playerId, reason); + public (RoomInstanceDto TargetRoom, bool DeferPresenceCommit)? GetPending(long playerId) { if (!_pending.TryGetValue(playerId, out var pending)) return null; @@ -75,11 +97,15 @@ public sealed class JoinTimeoutService( return Task.CompletedTask; } - presence.Clear(playerId); + // Room only: the player is still online and about to run the + // client's fallback chain. A full Clear also dropped the activity + // marker, so friends saw them go offline after every failed join. + presence.ClearRoom(playerId); logger.LogWarning( "[join-watchdog] cleared failed join player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId} outcome={Outcome}", playerId, failedRoom.RoomId, failedRoom.Name, instanceId, outcome); - return Task.CompletedTask; + // Whatever room friends were last told about is gone; correct them. + return fanout.PublishCurrentAsync(playerId); } private async Task WatchAsync(long playerId, PendingJoin pending) @@ -110,8 +136,9 @@ public sealed class JoinTimeoutService( return; } - presence.Clear(playerId); + presence.ClearRoom(playerId); await notifications.KickPlayerAsync(playerId, "Room join timed out. Returning to dorm."); + await fanout.PublishCurrentAsync(playerId); logger.LogWarning( "[join-watchdog] timed out player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId}", playerId, pending.TargetRoom.RoomId, pending.TargetRoom.Name, pending.InstanceId); diff --git a/DorkNet.Server/Services/NotificationService.cs b/DorkNet.Server/Services/NotificationService.cs index 02900f8..ad39efa 100644 --- a/DorkNet.Server/Services/NotificationService.cs +++ b/DorkNet.Server/Services/NotificationService.cs @@ -170,45 +170,25 @@ public class NotificationService( } } - /// Push fresh account payloads to the affected player's - /// watch. The 2020 client caches Account/SelfAccount objects in memory; - /// without this, username and display-name edits persist server-side - /// but the current session keeps rendering the stale cached names until - /// a full relog rebuilds the account cache. + /// Push fresh account payloads after a profile mutation. + /// The owner gets SelfAccountUpdate (private self DTO) and every + /// connected client gets AccountUpdate (public DTO). The 2023 + /// client subscribes to both strings (RecNet.Runtime + /// OPMAPIOEIFG.txt:267 "AccountUpdate" → Action<Account>, :279 + /// "SelfAccountUpdate" → Action<SelfAccount>) and refreshes its + /// cached account from the payload — the profile-edit PUTs return no + /// account, so this push is the ONLY way the new emoji / banner / + /// pronouns / identity flags reach the editor's own UI and anyone else + /// already looking at the profile. Sending AccountUpdate to the owner + /// alone (the previous behaviour) left every other player rendering the + /// stale cached account until relog. public Task AccountChanged(PlayerEntity player) { - var account = new RecNetAccount - { - AccountId = (int)player.Id, - RawUsername = player.Username, - Username = player.Username, - DisplayName = string.IsNullOrWhiteSpace(player.DisplayName) ? player.Username : player.DisplayName, - ProfileImage = player.ProfileImageName ?? string.Empty, - TreatAsJunior = player.IsJunior, - HasBirthday = true, - Platforms = 1, - CreatedAt = player.CreatedAt, - }; - var selfAccount = new RecNetSelfAccount - { - AccountId = account.AccountId, - RawUsername = account.RawUsername, - Username = account.Username, - DisplayName = account.DisplayName, - ProfileImage = account.ProfileImage, - TreatAsJunior = account.TreatAsJunior, - HasBirthday = account.HasBirthday, - Platforms = account.Platforms, - CreatedAt = account.CreatedAt, - Email = player.Email ?? string.Empty, - Phone = player.Phone ?? string.Empty, - Birthday = player.Birthday ?? new DateTime(2000, 1, 1), - JuniorState = player.IsJunior ? 1 : 0, - ParentAccountId = null, - }; + var account = AccountDtos.Public(player); + var selfAccount = AccountDtos.Self(player); return Task.WhenAll( - NotifyAsync(player.Id, PushNotificationId.SubscriptionUpdateProfile, account), + BroadcastAsync(PushNotificationId.SubscriptionUpdateProfile, account), NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount)); } @@ -271,17 +251,49 @@ public class NotificationService( /// the runtime-type path can pick the wrong contract under certain /// option combos and emit an empty {} body — which silently /// drops the presence update at the watch. - public async Task PlayerPresenceChanged(IEnumerable friendIds, long playerId, RoomInstanceDto? room) + public async Task PlayerPresenceChanged( + IEnumerable friendIds, + long playerId, + RoomInstanceDto? room, + bool? isOnline = null, + int statusVisibility = 0) { var groups = friendIds.Select(NotifyHub.GroupForPlayer).ToArray(); if (groups.Length == 0) return; - var redacted = RoomInstanceDto.Redact(room); + var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility); + + logger.LogDebug( + "[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}", + playerId, groups.Length, payload); + + await hub.Clients.Groups(groups).SendAsync(HubMethod, payload); + } + + /// The PresenceUpdate envelope for one player, as a JSON string. + /// + /// is independent of the room: the 2023 + /// presence DTO stores it in its own field (LCEHINCCHOB +0x50, + /// RecNet.Runtime/LCEHINCCHOB.txt:635-653) and DNKHCBFPLJG treats + /// "online with no room" as a real state — a player in the offline dorm + /// after matchmake/none. Deriving it from the room, as this used to, sent + /// such players to their friends as OFFLINE. When omitted it still falls + /// back to "has a room" for callers that only know about room moves. + /// An offline frame never carries a room. + public static string BuildPresenceUpdatePayload( + long playerId, + RoomInstanceDto? room, + bool? isOnline = null, + int statusVisibility = 0) + { + var online = isOnline ?? room is not null; + var redacted = online ? RoomInstanceDto.Redact(room) : null; var msg = new System.Text.Json.Nodes.JsonObject { ["playerId"] = (int)playerId, - // Everyone=0 (was 1=FriendsOnly, which hid you from non-friends). - ["statusVisibility"] = 0, + // The player's stored /player/statusvisibility choice (default + // 0 = Everyone), the same value their own heartbeat reports. + ["statusVisibility"] = statusVisibility, ["deviceClass"] = 0, ["vrMovementMode"] = 0, ["roomInstance"] = redacted is null @@ -307,7 +319,7 @@ public class NotificationService( ["EncryptVoiceChat"] = redacted.EncryptVoiceChat, }, // 2020.12 PlayerPresence.Deserialize requires these. - ["isOnline"] = redacted is not null, + ["isOnline"] = online, // 2023.03.21 client build version; must match the joining // client's own version or it reports a version mismatch. MUST be // a STRING ("20230317"): the client's presence DTO reads @@ -325,13 +337,7 @@ public class NotificationService( ["Id"] = WireName(PushNotificationId.SubscriptionUpdatePresence), ["Msg"] = msg, }; - var payload = envelope.ToJsonString(); - - logger.LogDebug( - "[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}", - playerId, groups.Length, payload); - - await hub.Clients.Groups(groups).SendAsync(HubMethod, payload); + return envelope.ToJsonString(); } public Task PlayerCameOnline(IEnumerable friendIds, long playerId, RoomInstanceDto? room) => diff --git a/DorkNet.Server/Services/PlayerAccountPurge.cs b/DorkNet.Server/Services/PlayerAccountPurge.cs index ec9ae2f..add8bc2 100644 --- a/DorkNet.Server/Services/PlayerAccountPurge.cs +++ b/DorkNet.Server/Services/PlayerAccountPurge.cs @@ -144,6 +144,19 @@ public static class PlayerAccountPurge .SetProperty(x => x.DeletedAt, now) .SetProperty(x => x.IsPublic, false)); + // Custom avatar items: the purged player's ownership rows go away; + // items they CREATED are reassigned to the system account like + // Inventions, because other players may own and be wearing them — + // deleting the item would break those avatars. + deleted += await db.CustomAvatarItemOwnership + .Where(x => x.PlayerId == playerId) + .ExecuteDeleteAsync(); + reassigned += await db.CustomAvatarItems + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.CreatorPlayerId, systemPlayerId) + .SetProperty(x => x.UpdatedAt, now)); + reassigned += await db.SignupCodes .Where(x => x.RedeemedByPlayerId == playerId) .ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null)); diff --git a/DorkNet.Server/Services/PlayerPresenceService.cs b/DorkNet.Server/Services/PlayerPresenceService.cs index 12d89bc..1611611 100644 --- a/DorkNet.Server/Services/PlayerPresenceService.cs +++ b/DorkNet.Server/Services/PlayerPresenceService.cs @@ -48,6 +48,7 @@ public class PlayerPresenceService private readonly ConcurrentDictionary _localRoomExpires = new(); private readonly ConcurrentDictionary _localActiveExpires = new(); private readonly ConcurrentDictionary _localBootDormGraceExpires = new(); + private readonly ConcurrentDictionary _localNoRoomExpires = new(); private readonly IConnectionMultiplexer? _redis; private readonly ILogger? _log; @@ -101,7 +102,9 @@ public class PlayerPresenceService try { var json = JsonSerializer.Serialize(room, JsonOpts); - mux.GetDatabase().StringSet(Key(playerId), json, PresenceTtl); + var db = mux.GetDatabase(); + db.StringSet(Key(playerId), json, PresenceTtl); + db.KeyDelete(NoRoomKey(playerId)); _log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}", playerId, room?.Name ?? "", json.Length); } @@ -117,6 +120,7 @@ public class PlayerPresenceService { _local[playerId] = room; _localRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl); + _localNoRoomExpires.TryRemove(playerId, out _); _log?.LogDebug("[presence] set local player={PlayerId} room={Room}", playerId, room?.Name ?? ""); } @@ -333,6 +337,7 @@ public class PlayerPresenceService db.KeyDelete(Key(playerId)); db.KeyDelete(ActiveKey(playerId)); db.KeyDelete(BootDormGraceKey(playerId)); + db.KeyDelete(NoRoomKey(playerId)); db.SetRemove(ActiveSetKey, playerId); } else @@ -341,9 +346,119 @@ public class PlayerPresenceService _localRoomExpires.TryRemove(playerId, out _); _localActiveExpires.TryRemove(playerId, out _); _localBootDormGraceExpires.TryRemove(playerId, out _); + _localNoRoomExpires.TryRemove(playerId, out _); } } + /// Forget the player's room but keep everything that says they + /// are still here (activity marker, login lock, boot grace). Used when a + /// join fails or times out: the player is still online and about to run + /// the client's fallback chain, so friends must not see them go offline, + /// they just must not see a room the player never reached. + public void ClearRoom(long playerId) + { + if (_redis is { } mux) + { + try + { + mux.GetDatabase().KeyDelete(Key(playerId)); + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis room DEL failed for player={PlayerId}", playerId); + } + return; + } + + _local.TryRemove(playerId, out _); + _localRoomExpires.TryRemove(playerId, out _); + } + + /// + /// Record that the player deliberately left online matchmaking: they are + /// online, but in no room instance. + /// + /// The 2023 client does this in TryGoToOfflineRoom + /// (RecNet.Runtime/RecNet/Matchmaking.txt:12677, the only caller of + /// matchmake/none at :13279): it swaps its cached presence to the + /// local offline instance (RoomInstanceId -1) and posts matchmake/none, + /// ignoring the reply (<TryGoToOfflineRoom>b__119_0 returns a + /// constant). From then on the heartbeat handler + /// (Matchmaking.txt:18103, compare at :18415-18467) treats a negative RoomInstanceId like no + /// room (HCHDEHIGEBE.GIICPDHJOOH = RoomInstanceId < 0) and is + /// only in sync while the server answers roomInstance: null. Any + /// room here — the old one, or a synthesised dorm — reads as out-of-sync + /// and the client cancels its offline load to chase the server's room. + /// The marker keeps the heartbeat's dorm fallback from firing for this + /// player until they matchmake somewhere again. + /// + public void SetNoRoom(long playerId) + { + if (_redis is { } mux) + { + try + { + var db = mux.GetDatabase(); + db.KeyDelete(Key(playerId)); + db.StringSet(NoRoomKey(playerId), "1", PresenceTtl); + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis no-room SET failed for player={PlayerId}", playerId); + } + return; + } + + _local.TryRemove(playerId, out _); + _localRoomExpires.TryRemove(playerId, out _); + _localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl); + } + + /// True while the player is online with no room after + /// . Cleared by the next or + /// . + public bool IsInNoRoom(long playerId) + { + if (_redis is { } mux) + { + try + { + return mux.GetDatabase().KeyExists(NoRoomKey(playerId)); + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis no-room GET failed for player={PlayerId}", playerId); + return false; + } + } + + if (!_localNoRoomExpires.TryGetValue(playerId, out var expires)) return false; + if (expires > DateTimeOffset.UtcNow) return true; + _localNoRoomExpires.TryRemove(playerId, out _); + return false; + } + + /// Keep the no-room marker alive while the player keeps + /// heartbeating from the offline room. + public void TouchNoRoom(long playerId) + { + if (_redis is { } mux) + { + try + { + mux.GetDatabase().KeyExpire(NoRoomKey(playerId), PresenceTtl); + } + catch (Exception ex) + { + _log?.LogWarning(ex, "[presence] redis no-room EXPIRE failed for player={PlayerId}", playerId); + } + return; + } + + if (_localNoRoomExpires.ContainsKey(playerId)) + _localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl); + } + /// List the currently-active instances of a given room, /// grouped by PhotonRoomId, with the player ids currently presenced /// in each. Drives the watch's RoomInstanceBrowser @@ -386,6 +501,7 @@ public class PlayerPresenceService private static string Key(long playerId) => $"presence:{playerId}"; private static string ActiveKey(long playerId) => $"presence-active:{playerId}"; private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}"; + private static string NoRoomKey(long playerId) => $"presence-noroom:{playerId}"; private const string ActiveSetKey = "presence-active"; // ── LoginLock single-session enforcement ───────────────────────── diff --git a/DorkNet.Server/Services/PresenceBroadcastService.cs b/DorkNet.Server/Services/PresenceBroadcastService.cs new file mode 100644 index 0000000..e0f367f --- /dev/null +++ b/DorkNet.Server/Services/PresenceBroadcastService.cs @@ -0,0 +1,188 @@ +using DorkNet.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace DorkNet.Server.Services; + +/// +/// Online-status visibility enum (2023 client, dump.cs IPLLHDINPBL). Picked in +/// the profile's "Set Online Status Visibility" dialog (Assembly-CSharp +/// RRUI/Data/BaseAccountModel.txt, ShowChangeOnlineStatusVisibilityDialog) and +/// sent as PUT player/statusvisibility form statusVisibility (RecNet.Runtime +/// RecNet/Matchmaking.txt:17147-17161). +/// +public static class PresenceVisibility +{ + public const int Public = 0; + public const int FriendsOnly = 1; + public const int FavoriteFriendsOnly = 2; + public const int Offline = 3; + + public static bool IsValid(int value) => value is >= Public and <= Offline; +} + +/// +/// Tells a player's friends when that player's presence changes, from one +/// place, so every transition sends the same frame built from the same state. +/// +/// Before this existed only the immediate /goto commit pushed anything. +/// Deferred commits (matchmake/dorm, private-instance hops) committed in +/// reportjoinresult without a push, matchmake/none and logout changed presence +/// silently, and SignalR connect/disconnect pushed with isOnline derived +/// from "has a room" — so a friend's list showed people in rooms they had left, +/// offline while they sat in their dorm, and never noticed a status-visibility +/// change. +/// +/// The client's PresenceUpdate handler (Matchmaking.EGPABEDPEJO, registered at +/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into +/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync +/// correction (:18467). A frame about the LOCAL player therefore moves that player, so +/// these frames go to friends only, never to the subject. +/// +public sealed class PresenceBroadcastService( + IServiceScopeFactory scopes, + PlayerPresenceService presence, + OnlinePresenceService connections, + NotificationService notifications, + ILogger logger) +{ + /// PlayerSettings key for the persisted + /// /player/statusvisibility choice. + public const string StatusVisibilityPrefKey = "pref:statusVisibility"; + + /// A player counts as online while they heartbeat or hold a + /// SignalR connection. Being in a room is NOT required: the client's + /// presence DTO carries isOnline separately (LCEHINCCHOB +0x50) and + /// DNKHCBFPLJG reads "online, in no room / the offline room" as a valid + /// state, which is exactly what a player in the offline dorm is. + public bool IsOnline(long playerId) => + presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0; + + /// Push the player's current presence (online state + committed + /// room) to every friend. + public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false); + + /// Push an offline frame regardless of any lingering markers — + /// for an explicit logout. + public Task PublishOfflineAsync(long playerId) => PublishAsync(playerId, forceOffline: true); + + /// + /// The last SignalR connection dropped but the player may still be + /// playing: the socket can be closed under a live session ("RecNet + /// HubConnection error: ... TCP Stream closed unexpectedly by the remote + /// server") and the client reconnects on its own. Pushing offline right + /// away greys the player out for every friend until that reconnect. Wait + /// one activity window and only then decide: still silent means the game + /// is really gone. + /// + public void ScheduleOfflineCheck(long playerId) + { + var delay = PlayerPresenceService.ActivityTtl + TimeSpan.FromSeconds(5); + _ = Task.Run(async () => + { + try + { + await Task.Delay(delay); + if (IsOnline(playerId)) return; + await PublishAsync(playerId, forceOffline: true); + } + catch (Exception ex) + { + logger.LogWarning(ex, "[presence-fanout] deferred offline check failed player={PlayerId}", playerId); + } + }); + } + + private async Task PublishAsync(long playerId, bool forceOffline) + { + if (playerId <= 0) return; + try + { + await using var scope = scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var settings = scope.ServiceProvider.GetRequiredService(); + + var friendIds = (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, playerId)) + .Where(id => id != playerId) + .Distinct() + .ToList(); + if (friendIds.Count == 0) return; + + var online = !forceOffline && IsOnline(playerId); + var room = online ? presence.GetRoom(playerId) : null; + var visibility = await GetStatusVisibilityAsync(db, playerId); + + // Honour the player's visibility choice the same way GET /player + // does (PlayerPresenceController.ResolveHiddenAsync): recipients + // who may not see them get the frame a genuinely offline player + // produces. Every recipient here is already a friend, so + // FriendsOnly needs no split; FavoriteFriendsOnly reaches only the + // friends this player favorited (Favorited lives on the + // favoriter's own row, RequesterId = favoriter). + var favorited = visibility == PresenceVisibility.FavoriteFriendsOnly + ? (await db.Relationships.AsNoTracking() + .Where(r => r.RequesterId == playerId && r.Favorited && friendIds.Contains(r.TargetId)) + .Select(r => r.TargetId) + .ToListAsync()).ToHashSet() + : new HashSet(); + var (visibleTo, hiddenFrom) = SplitRecipients(friendIds, visibility, favorited); + + logger.LogInformation( + "[presence-fanout] player={PlayerId} online={Online} room={RoomId} instance={InstanceId} visibility={Visibility} -> {FriendCount} friend(s), {HiddenCount} hidden", + playerId, online, room?.RoomId, room?.RoomInstanceId, visibility, visibleTo.Count, hiddenFrom.Count); + + if (visibleTo.Count > 0) + await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility); + if (hiddenFrom.Count > 0) + await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility); + } + catch (Exception ex) + { + // Best-effort: a fan-out failure must never fail the matchmake, + // logout or hub event that triggered it. + logger.LogWarning(ex, "[presence-fanout] push failed player={PlayerId}", playerId); + } + } + + /// Split a player's friends into those who may see their real + /// presence and those who must see them offline, per their status + /// visibility. holds the friends the player + /// favorited; it only matters for FavoriteFriendsOnly. + public static (List VisibleTo, List HiddenFrom) SplitRecipients( + IReadOnlyList friendIds, int visibility, IReadOnlySet favorited) => + visibility switch + { + PresenceVisibility.Offline => (new List(), friendIds.ToList()), + PresenceVisibility.FavoriteFriendsOnly => ( + friendIds.Where(favorited.Contains).ToList(), + friendIds.Where(id => !favorited.Contains(id)).ToList()), + _ => (friendIds.ToList(), new List()), + }; + + /// Stored status visibility for one player (0 = default). + public static async Task GetStatusVisibilityAsync(DorkNetDbContext db, long playerId) + { + var raw = await db.PlayerSettings.AsNoTracking() + .Where(s => s.PlayerId == playerId && s.Key == StatusVisibilityPrefKey) + .Select(s => s.Value) + .FirstOrDefaultAsync(); + return int.TryParse(raw, out var v) && PresenceVisibility.IsValid(v) ? v : PresenceVisibility.Public; + } + + /// Stored status visibility for many players in one query. + /// Players with no stored choice are absent from the result. + public static async Task> GetStatusVisibilitiesAsync( + DorkNetDbContext db, IReadOnlyCollection playerIds) + { + if (playerIds.Count == 0) return new Dictionary(); + var rows = await db.PlayerSettings.AsNoTracking() + .Where(s => s.Key == StatusVisibilityPrefKey && playerIds.Contains(s.PlayerId)) + .Select(s => new { s.PlayerId, s.Value }) + .ToListAsync(); + var result = new Dictionary(rows.Count); + foreach (var row in rows) + { + if (int.TryParse(row.Value, out var v) && PresenceVisibility.IsValid(v)) result[row.PlayerId] = v; + } + return result; + } +} diff --git a/DorkNet.Server/Services/RoomDataBlobService.cs b/DorkNet.Server/Services/RoomDataBlobService.cs index b4e6f6a..94b2f6a 100644 --- a/DorkNet.Server/Services/RoomDataBlobService.cs +++ b/DorkNet.Server/Services/RoomDataBlobService.cs @@ -19,7 +19,6 @@ public class RoomDataBlobService PersistedRoomVersion.LatestVersion; private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData(); - private readonly byte[] _rroEditableBlob = BuildRroEditableBlob(); private readonly RoomRoleCollectionData _rroEditableRoleData = BuildRroEditableRoleData(); /// The blob served when a room_<id>_v1.dat misses S3 @@ -37,7 +36,20 @@ public class RoomDataBlobService } public byte[] GetDefaultBlob() => _defaultBlob; - public byte[] GetRroEditableBlob() => _rroEditableBlob; + public byte[] GetRroEditableBlob() => GetRroEditableBlob(string.Empty); + + /// The blob served for a baked Rec Room Original whose blob name + /// misses S3 (nothing saved yet). is the + /// room's LocationReplicationId — a real save carries it as + /// activity_id (Rec Center's saved blob: activity_id == + /// cbad71af-…, its location id). + public byte[] GetRroEditableBlob(string activityId) => + BuildRroEditableBlob(_rroEditableRoleData, activityId, "Room"); + + /// Name of the room's default game role (Rec + /// Center's is "Rec Center", Stunt Runner's "StuntRunner"). + public byte[] GetRroEditableBlob(string activityId, string roomName) => + BuildRroEditableBlob(_rroEditableRoleData, activityId, roomName); private static byte[] LoadDefaultBlob() { @@ -267,19 +279,367 @@ public class RoomDataBlobService return BuildRroEditableRoleData(); } - private static byte[] BuildRroEditableBlob() + /// Shape of the stub matters: the March-2023 client fetches this + /// through RecNet.Rooms.GetRoomData and REJECTS a blob it deems + /// unusable (seen as the bare "Failed to copy room" on clones, and as a + /// baked room that silently loads without any room roles / Maker Pen). + /// Two things were wrong with the old six-field stub, measured against + /// Rec Center's real saved blob (room_100_v1.dat, which the same client + /// loads with Maker Pen): + /// + /// • version was stamped PersistedRoomVersion.LatestVersion + /// (19) — past the client's ceiling of + /// (16). The S3-hit path clamps that; the miss path never did. + /// • it lacked the skeleton every real save carries: last_save_time, + /// activity_id (= the room's LocationReplicationId), an empty + /// connectable_graph_data, scene_settings_data, + /// object_model_data and the four room_mood_* blocks. + /// + /// Our server proto only types the role/tag fields, so the rest is + /// appended as raw wire-format fields (protobuf field order is free). + /// The byte payloads below are Rec Center's own defaults, verbatim. + private static byte[] BuildRroEditableBlob(RoomRoleCollectionData roleData, string activityId, string roomName) { var msg = new PersistedRoomData { DEPRECATEDVersion = LatestDeprecatedPersistenceVersion, - Version = LatestPersistenceVersion, + Version = (PersistedRoomVersion)Client2023MaxPersistedRoomVersion, CreativeRolesEnabled = true, ToolTagSettingsData = BuildDefaultToolTagSettingsData(), GameRoleData = BuildDefaultGameRoleData(), - RoomRoleData = BuildRroEditableRoleData(), + RoomRoleData = roleData.Clone(), }; - return msg.ToByteArray(); + using var output = new MemoryStream(4096); + var typed = msg.ToByteArray(); + output.Write(typed, 0, typed.Length); + + // 3: last_save_time (google.protobuf.Timestamp{seconds}) + var seconds = (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + using (var ts = new MemoryStream(12)) + { + WriteVarint(ts, 0x08); + WriteVarint(ts, seconds); + AppendLengthDelimited(output, 3, ts.ToArray()); + } + // 4: activity_id + if (!string.IsNullOrWhiteSpace(activityId)) + AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId)); + + // 2: persistence_views — the room's own GAME-ROLE objects. Every real + // room save carries three GameRoleNode chips (default role named after + // the room, "In-Game", "Eliminated"; Rec Center and Stunt Runner both + // do, with the same well-known role GUIDs), and the 2023 client reads + // Maker Pen / save / spawn rights off the player's GameRole + // (ICreatorRole.get_CreatorRoleCanUseMakerPen on + // RecRoom.Systems.PlayerRoles.GameRole). A blob with no role objects + // gives a co-owner nothing to hold, so MakerPen.CheckMakerPen- + // PermissionAndUpdateMode fails even though the room-role collection + // says yes. View ids are derived from the activity id so repeated + // downloads describe the same objects. + var seed = string.IsNullOrWhiteSpace(activityId) ? "rro" : activityId; + AppendLengthDelimited(output, 2, BuildGameRoleView(seed, roomName, DefaultGameRoleGuid, nodeId: 7, + transform: "0a0f0db7968041152c798b3f1da05e8dc02d0000803f32050d0000803f", + roleId: 0, roleRank: 0, isDefaultRole: true)); + AppendLengthDelimited(output, 2, BuildGameRoleView(seed, "In-Game", InGameGameRoleGuid, nodeId: 9, + transform: "0a0f0d51308241152c798b3f1da05e8dc02d0000803f32050d0000803f", + roleId: 2_097_152, roleRank: 1, isDefaultRole: false)); + AppendLengthDelimited(output, 2, BuildGameRoleView(seed, "Eliminated", EliminatedGameRoleGuid, nodeId: 11, + transform: "0a0f0deac98341152c798b3f1da05e8dc02d0000803f32050d0000803f", + roleId: 4_194_304, roleRank: 1015, isDefaultRole: false)); + + // 2: the room's GAME-RULE chip, for baked originals whose rule is not + // in the scene. Rec Royale's Frontier scene has no baked + // *_GameRuleWrapperCircuit (the paintball scenes do); real Rec Room + // delivered it in the blob. A saved RecRoyaleSquads chip (custom + // Rec Royale room export) is just header + prefab id + palette tag + + // node id + an EMPTY GameRuleWrapperData (field 41), so it can be + // generated. Prefab ids come from the client's spawnable-tool table + // in resources.assets (SpawnableTool_Runtime_ → 16-byte id), + // cross-checked against ids seen in real saves. + var chip = GameRuleChipFor(roomName); + if (chip is not null) + AppendLengthDelimited(output, 2, BuildGameRuleChipView(seed, chip.Value.PrefabId, chip.Value.Tag, nodeId: 3)); + // 21: connectable_graph_data { root_node { slot_index=0-ish, field 7 = 1 } } + AppendLengthDelimited(output, 21, new byte[] { 0x0A, 0x04, 0x0A, 0x00, 0x38, 0x01 }); + // 24: scene_settings_data { enforce_host_only_areas=true, use_new_materials=true } + AppendLengthDelimited(output, 24, new byte[] { 0x18, 0x01, 0x30, 0x01 }); + // 29: object_model_data (opaque bytes; empty model) + AppendLengthDelimited(output, 29, new byte[] { 0x08, 0x02, 0x1A, 0x00 }); + // 32-35: room_fog_data / room_mood_sun_data / room_mood_background_objects / room_mood_skydome + AppendLengthDelimited(output, 32, Convert.FromHexString("0a070d00000040100110122500004844")); + AppendLengthDelimited(output, 33, Convert.FromHexString("0a070d000000401001580162140dffffff3e15ffffff3e1dffffffbe25ffffff3e")); + AppendLengthDelimited(output, 34, Convert.FromHexString("0a070d000000401001121208ffffffffffffffffff01100d250000803f1a1208ffffffffffffffffff01100d250000803f221208ffffffffffffffffff01100d250000803f2a1208ffffffffffffffffff01100d250000803f")); + AppendLengthDelimited(output, 35, Convert.FromHexString("0a070d0000004010011032182920252825300d3dae47613e45713d8a3e")); + + return output.ToArray(); + } + + private static void AppendLengthDelimited(Stream output, int fieldNumber, byte[] payload) + { + WriteVarint(output, ((ulong)fieldNumber << 3) | 2); + WriteVarint(output, (ulong)payload.Length); + output.Write(payload, 0, payload.Length); + } + + /// Game-rule wrapper chips for baked originals whose rule is not + /// baked into the scene, keyed by (a substring of) the room name. Ids are + /// the client's SpawnableTool_Runtime_* prefab ids (2023-03-21 + /// resources.assets); the Squads id also matches a real newer-client + /// save. Tags are what the client generates: prefab name lower-cased, + /// cut to 25 characters. + private static (byte[] PrefabId, string Tag)? GameRuleChipFor(string roomName) + { + if (string.IsNullOrWhiteSpace(roomName)) return null; + var n = roomName.Replace("-", "").Replace("_", "").Replace(" ", ""); + if (n.Contains("RecRoyaleSquads", StringComparison.OrdinalIgnoreCase)) + return (Convert.FromHexString("857f750016ca77408ae9f72f9877748a"), "recroyalesquadsgameruleci"); + if (n.Contains("RecRoyaleSolos", StringComparison.OrdinalIgnoreCase)) + return (Convert.FromHexString("983db81a68927f419edb0daf1243e93e"), "recroyalesolosgamerulecir"); + return null; + } + + /// PersistenceViewData for a GameRuleCircuitWrapperTool chip, + /// laid out like the saved RecRoyaleSquads chip: id, empty + /// tool_entity_data, transform, spawnable_tool_data{prefab_id}, + /// creation_object_data{is_frozen,is_grabbable}, tagged_tool_data{tag}, + /// empty tool_cleanup_data, circuit_node_data{id}, empty + /// game_rule_wrapper_data (field 41). + private static byte[] BuildGameRuleChipView(string seed, byte[] prefabId, string tag, int nodeId) + { + using var view = new MemoryStream(256); + AppendLengthDelimited(view, 1, System.Security.Cryptography.MD5.HashData( + System.Text.Encoding.UTF8.GetBytes($"dorknet-rro-gamerule:{seed}:{tag}"))); + AppendLengthDelimited(view, 9, Array.Empty()); + // Same spot the saved Squads chip sat at (position/scale/rotation). + AppendLengthDelimited(view, 10, Convert.FromHexString("0a0f0d804081c21520b58b431d941d50c42d0000803f32140d7cbd5d3f150034353a1d75deffbe250034353a")); + using (var spawnable = new MemoryStream(20)) + { + AppendLengthDelimited(spawnable, 1, prefabId); + AppendLengthDelimited(view, 11, spawnable.ToArray()); + } + AppendLengthDelimited(view, 14, new byte[] { 0x18, 0x01, 0x30, 0x01 }); + using (var tagged = new MemoryStream(40)) + { + using var tagData = new MemoryStream(32); + AppendLengthDelimited(tagData, 1, System.Text.Encoding.UTF8.GetBytes(tag)); + AppendLengthDelimited(tagged, 1, tagData.ToArray()); + AppendLengthDelimited(view, 15, tagged.ToArray()); + } + AppendLengthDelimited(view, 22, Array.Empty()); + using (var node = new MemoryStream(4)) + { + AppendVarint(node, 1, (ulong)nodeId); + AppendLengthDelimited(view, 25, node.ToArray()); + } + AppendLengthDelimited(view, 41, Array.Empty()); + return view.ToArray(); + } + + public sealed record UploadedBlobSummary(int ViewsIn, int ViewsKept, int RoleViews, int GameRuleViews, + int VersionIn, int VersionOut, bool CircuitsStripped, string ActivityId); + + /// Turn a real PersistedRoomData save (any client + /// version) into something the March-2023 client can load as room + /// 's blob: + /// • field 4 activity_id is rewritten to the target room's + /// location id (real saves carry their own room's); + /// • field 5 output_log, field 6 sub_room_id and every + /// field the 2023 schema does not know (> 36) are dropped; + /// • with , only the persistence views that + /// carry a game role (field 50) or a game rule (fields 40/41) are kept + /// and the circuit payloads (18 circuit_data, 21 connectable graph, + /// 28 circuit_v2_data) are dropped — a newer CircuitsV2 graph is + /// rejected by the client's own CV2 version gate no matter what; + /// • the two header versions are clamped to the 2023 maxima. + /// Every other byte is copied verbatim. + public static (byte[] Bytes, UploadedBlobSummary Summary) PrepareUploadedRoomBlob( + byte[] input, string activityId, bool basicsOnly) + { + using var output = new MemoryStream(input.Length); + var pos = 0; + int viewsIn = 0, viewsKept = 0, roleViews = 0, ruleViews = 0, versionIn = 0; + var wroteActivity = false; + while (pos < input.Length) + { + var fieldStart = pos; + var tag = ReadVarint(input, ref pos); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + var payloadStart = pos; + if (wireType == 0) + { + var value = ReadVarint(input, ref pos); + if (fieldNumber == 30) versionIn = (int)value; + if (fieldNumber is 5 or 6 || fieldNumber > 36) continue; + output.Write(input, fieldStart, pos - fieldStart); + continue; + } + SkipField(input, ref pos, wireType); + if (wireType != 2) + { + if (fieldNumber > 36) continue; + output.Write(input, fieldStart, pos - fieldStart); + continue; + } + + var lenPos = payloadStart; + var len = checked((int)ReadVarint(input, ref lenPos)); + var payload = new ReadOnlySpan(input, lenPos, len); + + switch (fieldNumber) + { + case 4: + AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId)); + wroteActivity = true; + continue; + case 5: + case 18 when basicsOnly: + case 21 when basicsOnly: + case 28 when basicsOnly: + continue; + case 2: + { + viewsIn++; + var (isRole, isRule) = ClassifyView(payload); + if (isRole) roleViews++; + if (isRule) ruleViews++; + if (basicsOnly && !isRole && !isRule) continue; + viewsKept++; + output.Write(input, fieldStart, pos - fieldStart); + continue; + } + default: + if (fieldNumber > 36) continue; + output.Write(input, fieldStart, pos - fieldStart); + continue; + } + } + if (!wroteActivity && !string.IsNullOrWhiteSpace(activityId)) + AppendLengthDelimited(output, 4, System.Text.Encoding.UTF8.GetBytes(activityId)); + + var (clamped, _) = ClampVersionsFor2023(output.ToArray()); + return (clamped, new UploadedBlobSummary(viewsIn, viewsKept, roleViews, ruleViews, versionIn, + Math.Min(versionIn == 0 ? Client2023MaxPersistedRoomVersion : versionIn, Client2023MaxPersistedRoomVersion), + basicsOnly, activityId)); + } + + /// Does this PersistenceViewData carry a GameRoleNode (field 50) + /// or a game rule (40 game_configuration_data / 41 game_rule_wrapper_data)? + private static (bool IsRole, bool IsRule) ClassifyView(ReadOnlySpan view) + { + var bytes = view.ToArray(); + var pos = 0; bool role = false, rule = false; + try + { + while (pos < bytes.Length) + { + var tag = ReadVarint(bytes, ref pos); + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x07); + if (fieldNumber == 50) role = true; + if (fieldNumber is 40 or 41) rule = true; + SkipField(bytes, ref pos, wireType); + } + } + catch { /* malformed view — treat as neither */ } + return (role, rule); + } + + private static void AppendVarint(Stream output, int fieldNumber, ulong value) + { + WriteVarint(output, ((ulong)fieldNumber << 3) | 0); + WriteVarint(output, value); + } + + // Well-known game-role GUIDs, identical in every room save inspected + // (Rec Center room_100_v1.dat, Stunt Runner room_125_v1.dat). + private const string DefaultGameRoleGuid = "c48bac02-a743-4797-94c4-e2d6f0749004"; + private const string InGameGameRoleGuid = "2c721342-c7ed-49fe-b8f0-93f8d282a6df"; + private const string EliminatedGameRoleGuid = "0b9ec39e-2cc9-4825-8321-da11561f1c4f"; + + // SpawnableToolData.prefab_id of the GameRoleNode chip prefab (16 raw + // bytes, same in both reference saves). + private static readonly byte[] GameRoleNodePrefabId = Convert.FromHexString("c46e153588a47147835d6cc2f69bfd6c"); + + /// One PersistenceViewData carrying a GameRoleNode chip, + /// laid out exactly as the client's own writer emits it: id, empty + /// tool_entity_data, transform, spawnable_tool_data{prefab_id}, + /// creation_object_data{is_frozen, is_grabbable}, empty tool_cleanup_data, + /// circuit_node_data{id}, game_role_node_data{PlayerGameRoleData}. The + /// PlayerGameRoleData mirrors the reference saves field-for-field, + /// including the empty Overridable* submessages the writer always emits + /// (the reader expects them present). + private static byte[] BuildGameRoleView(string seed, string roleName, string roleGuid, int nodeId, + string transform, int roleId, int roleRank, bool isDefaultRole) + { + using var view = new MemoryStream(512); + // 1: id — stable per (room, role) + AppendLengthDelimited(view, 1, System.Security.Cryptography.MD5.HashData( + System.Text.Encoding.UTF8.GetBytes($"dorknet-rro-role:{seed}:{roleGuid}"))); + AppendLengthDelimited(view, 9, Array.Empty()); // tool_entity_data + AppendLengthDelimited(view, 10, Convert.FromHexString(transform)); // transform + using (var spawnable = new MemoryStream(20)) // spawnable_tool_data + { + AppendLengthDelimited(spawnable, 1, GameRoleNodePrefabId); + AppendLengthDelimited(view, 11, spawnable.ToArray()); + } + AppendLengthDelimited(view, 14, new byte[] { 0x18, 0x01, 0x30, 0x01 }); // creation_object_data + AppendLengthDelimited(view, 22, Array.Empty()); // tool_cleanup_data + using (var node = new MemoryStream(4)) // circuit_node_data + { + AppendVarint(node, 1, (ulong)nodeId); + AppendLengthDelimited(view, 25, node.ToArray()); + } + using (var roleNode = new MemoryStream(512)) // game_role_node_data + { + AppendLengthDelimited(roleNode, 1, BuildPlayerGameRole(roleName, roleGuid, roleId, roleRank, isDefaultRole)); + AppendLengthDelimited(view, 50, roleNode.ToArray()); + } + return view.ToArray(); + } + + private static byte[] BuildPlayerGameRole(string roleName, string roleGuid, int roleId, int roleRank, bool isDefaultRole) + { + // Field order and the set of always-present empty submessages copied + // from Rec Center's save (role_version 33 = the 2023 writer). + int[] order = + { + 3, 5, 8, 11, 12, 13, 14, 15, 17, 18, 19, 21, 22, 23, 24, 26, 27, 30, 31, 32, 33, 34, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 46, 47, 48, 49, 50, 51, 52, 62, 63, 64, 65, 66, 67, 70, 71, 72, 73, 74, 75, 76, + 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, + }; + static byte[] OverrideBool(bool value) => value ? new byte[] { 0x08, 0x01, 0x10, 0x01 } : new byte[] { 0x08, 0x01 }; + + using var role = new MemoryStream(320); + if (!isDefaultRole) + { + AppendVarint(role, 1, (ulong)roleId); // role_id (AG bitmask) + AppendVarint(role, 2, (ulong)roleRank); // role_rank + } + foreach (var f in order) + { + switch (f) + { + case 3: AppendVarint(role, 3, 1); break; // is_role_active + case 5: AppendLengthDelimited(role, 5, System.Text.Encoding.UTF8.GetBytes(roleName)); break; + case 17 when roleName == "Eliminated": + AppendLengthDelimited(role, 17, new byte[] { 0x08, 0x01, 0x10, 0x01 }); break; // item_pickup_restrictions + case 26 when isDefaultRole: + AppendLengthDelimited(role, 26, new byte[] { 0x08, 0x01, 0x10, 0x14 }); break; // voice_rolloff_max_distance=20 + case 27: AppendVarint(role, 27, 33); break; // role_version + case 31 when roleName == "Eliminated": + AppendLengthDelimited(role, 31, OverrideBool(false)); break; // can_move + case 49: AppendLengthDelimited(role, 49, System.Text.Encoding.UTF8.GetBytes(roleGuid)); break; + // DorkNet policy for editable RROs: the default role may use + // the Maker Pen regardless of room role (matches the + // permissive room-role overlay the CDN applies). + case 94 when isDefaultRole: + AppendLengthDelimited(role, 94, OverrideBool(true)); break; // can_use_maker_pen + default: AppendLengthDelimited(role, f, Array.Empty()); break; + } + } + return role.ToArray(); } private static RoomRoleCollectionData BuildRroEditableRoleData() diff --git a/DorkNet.Server/Services/ServerSettingsService.cs b/DorkNet.Server/Services/ServerSettingsService.cs index 6a80cac..c042af8 100644 --- a/DorkNet.Server/Services/ServerSettingsService.cs +++ b/DorkNet.Server/Services/ServerSettingsService.cs @@ -1,6 +1,7 @@ using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using Microsoft.EntityFrameworkCore; +using System.Globalization; using System.Text.Json; namespace DorkNet.Server.Services; @@ -175,7 +176,25 @@ public class ServerSettingsService(DorkNetDbContext db) var row = await GetAsync(); return ToGameConfigurations( ToRecCenterDoors(row), - ToDiscoveredGameConfigs(row)); + ToDiscoveredGameConfigs(row), + ToCircuitsV2Limits(row)); + } + + public async Task GetCircuitsV2LimitsAsync() + { + var row = await GetAsync(); + return ToCircuitsV2Limits(row); + } + + public async Task SetCircuitsV2LimitsAsync( + CircuitsV2LimitSettings settings) + { + var normalized = NormalizeCircuitsV2Limits(settings); + var existing = await GetTrackedRowAsync(); + existing.CircuitsV2LimitsJson = JsonSerializer.Serialize(normalized, JsonOptions); + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return normalized with { UpdatedAt = existing.UpdatedAt }; } public async Task SetPlayMenuTagsAsync( @@ -663,9 +682,86 @@ public class ServerSettingsService(DorkNetDbContext db) SplitTestSegmentProbabilities: "{}", UpdatedAt: DateTime.UtcNow); + /// + /// Retail shipped one network entry — 5 RPCs per frame, 1024 bytes per + /// 60 frames — which is what makes busy CircuitsV2 rooms drop chip + /// sends. DorkNet ships the budgets effectively unlimited instead, and + /// raises the room-size limits to values the client survives with + /// headroom (each is justified in docs/recroom-2023-circuitsv2-limits.md): + /// + /// Compute 50000 → 500000: the per-frame CPU budget shared by + /// every circuit in the room. It is the only thing that stops a runaway + /// loop, so it stays finite. + /// Chips 3000 → 30000: the room's chip cap ("Too many + /// chips."). + /// CloudValues 100 → 1000: the room's cloud-variable cap. + /// UnculledChips / VisibleChips 500 → 3000: chips past these + /// counts get culled, so every chip of a retail-sized room stays + /// visible. + /// InkPerChip 15 → 0: this is an ink COST, charged against the + /// client's hard-coded 30000-ink room cap, so lowering it is the + /// increase. 0 means chips stop eating room ink. + /// + /// CloudValuesAutosaveSeconds stays at retail's 300: it is a save + /// cadence, not a capacity limit. Admins can dial any of it back from + /// the SPA. + /// + public static CircuitsV2LimitSettings DefaultCircuitsV2Limits() => + new( + Network: + [ + new( + NumPlayersForActivation: 0, + Bytes: UnlimitedBudget, + BytesTimeWindowFrame60s: 60, + Rpcs: UnlimitedBudget, + RpcTimeWindowFrame60s: 1), + ], + Compute: 500_000, + Chips: 30_000, + CloudValues: 1_000, + CloudValuesAutosaveSeconds: 300f, + UnculledChips: 3_000, + VisibleChips: 3_000, + InkPerChip: 0, + UpdatedAt: DateTime.UtcNow); + + /// The budget value that stands in for "unlimited". The client + /// only ever compares a running counter against it, so any large int + /// works; this leaves 2x headroom under int.MaxValue. + private const int UnlimitedBudget = 1_000_000_000; + + /// Time windows size a new int[N] ring buffer — one + /// bucket per frame — inside the client's rate limiter, so a huge + /// window is an allocation, not a bigger budget. 3600 frames (a minute + /// at 60fps) is 14KB and far past anything useful. + private const int MaxTimeWindowFrames = 3600; + + /// Play-menu carousel order. PlayMenuCarouselModel.TryGetDisplayPriorityMap + /// looks this exact key up (literal at Assembly-CSharp/RRUI/Data/PlayMenuCarouselModel.txt:547) + /// and, when it is absent, logs "Could not find GameConfig for PlayMenuCarouselOrdering_1" + /// (:758) and leaves the carousels in prefab order. The value is split on ';' then '=' + /// into id → Int32 priority (:603, :632, :655); PlayMenuCarouselController.DisplayOrderPriorityImpl + /// then reorders the carousel children with SetSiblingIndex. + /// + /// The ids are the carousel ids serialized on that component in the 2023-03-21 build's + /// Addressables bundle (3900770abc33861326d1865e454c0901.bundle, MonoBehaviour + /// PlayMenuCarouselController/DisplayOrderPriorityImpl): continueplaying, friendsplaying, + /// myclubhouses, myevents, mysubscriptions, recommended, hotrooms, topcreators, featured, + /// myfavorites, recroomoriginals, new, limitedtimeevent. Priorities follow the live + /// service's 2023 "PlayMenuCarouselOrdering_3" segment (higher first), with the + /// time-limited event carousel put on top. + public const string PlayMenuCarouselOrderingKey = "PlayMenuCarouselOrdering_1"; + + public const string PlayMenuCarouselOrdering = + "limitedtimeevent=15;hotrooms=14;new=13;recroomoriginals=12;continueplaying=11;" + + "myfavorites=10;friendsplaying=9;recommended=8;mysubscriptions=7;featured=5;" + + "myclubhouses=4;myevents=3;topcreators=2"; + public static IReadOnlyList ToGameConfigurations( RecCenterDoorSettings doors, - DiscoveredGameConfigSettings discovered) + DiscoveredGameConfigSettings discovered, + CircuitsV2LimitSettings circuits) { var rows = new List(); foreach (var door in NormalizeRecCenterDoors(doors.Doors).Doors) @@ -690,8 +786,26 @@ public class ServerSettingsService(DorkNetDbContext db) new("splitTestSoftOverrides", gameConfig.SplitTestSoftOverrides), new("splitTestHardOverrides", gameConfig.SplitTestHardOverrides), new("splitTestSegmentProbabilities", gameConfig.SplitTestSegmentProbabilities), + new(PlayMenuCarouselOrderingKey, PlayMenuCarouselOrdering), + ]); + + var limits = NormalizeCircuitsV2Limits(circuits); + rows.AddRange([ + new("UGC.CircuitsV2.Limit.Network", JsonSerializer.Serialize(limits.Network, JsonOptions)), + new("UGC.CircuitsV2.Limit.Compute", Int(limits.Compute)), + new("UGC.CircuitsV2.Limit.Chips", Int(limits.Chips)), + new("UGC.CircuitsV2.Limit.CloudValues", Int(limits.CloudValues)), + new("UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds", + limits.CloudValuesAutosaveSeconds.ToString("0.###", CultureInfo.InvariantCulture)), + new("UGC.CircuitsV2.Limit.UnculledChips", Int(limits.UnculledChips)), + new("UGC.CircuitsV2.Limit.VisibleChips", Int(limits.VisibleChips)), + new("UGC.CircuitsV2.Limit.InkPerChip", Int(limits.InkPerChip)), ]); return rows; + + // The client parses every scalar with int/float.TryParse under + // InvariantCulture, so no thousands separators. + static string Int(int value) => value.ToString(CultureInfo.InvariantCulture); } private static PlayMenuTagSettings NormalizePlayMenuTags( @@ -800,6 +914,104 @@ public class ServerSettingsService(DorkNetDbContext db) SplitTestSegmentProbabilities: JsonObjectOrDefault(settings.SplitTestSegmentProbabilities), UpdatedAt: DateTime.UtcNow); } + + /// + /// Clamps the limits to what the client can actually survive. Four of + /// these are crash guards rather than taste: + /// + /// Rpcs of 0 divides by zero in the CircuitsV2 debug subscreen, + /// which renders RpcRecentMax / Rpcs. + /// A time window sizes a per-frame ring buffer, so an + /// unbounded value is an out-of-memory kill at circuit init. + /// Chips sizes four list preallocations in the chip-culling + /// manager, and InkPerChip × CloudValues is an unchecked int multiply, + /// so both are capped. + /// The client rejects the whole network array — falling back to + /// the retail 5-RPC budget — unless some entry has + /// NumPlayersForActivation 0, so the lowest entry is forced to 0. + /// + /// + private static CircuitsV2LimitSettings NormalizeCircuitsV2Limits( + CircuitsV2LimitSettings settings) + { + var seen = new HashSet(); + var network = (settings.Network ?? []) + .Where(entry => entry is not null) + .Select(entry => new CircuitsV2NetworkLimit( + NumPlayersForActivation: Math.Clamp(entry.NumPlayersForActivation, 0, 1000), + Bytes: Math.Clamp(entry.Bytes, 1, UnlimitedBudget), + BytesTimeWindowFrame60s: Math.Clamp(entry.BytesTimeWindowFrame60s, 1, MaxTimeWindowFrames), + Rpcs: Math.Clamp(entry.Rpcs, 1, UnlimitedBudget), + RpcTimeWindowFrame60s: Math.Clamp(entry.RpcTimeWindowFrame60s, 1, MaxTimeWindowFrames))) + .OrderBy(entry => entry.NumPlayersForActivation) + .Where(entry => seen.Add(entry.NumPlayersForActivation)) + .Take(16) + .ToList(); + + if (network.Count == 0) + network = DefaultCircuitsV2Limits().Network; + else if (network[0].NumPlayersForActivation != 0) + network[0] = network[0] with { NumPlayersForActivation = 0 }; + + return new CircuitsV2LimitSettings( + Network: network, + // Compute is the per-frame CPU budget: CircuitsV2Manager's frame + // tick (CircuitsV2Manager.txt OCGDEIJJBAG ISIL 240, :21241) + // builds a fresh BENFNKBKHEA(Compute) every frame, which sums + // four int counters and compares them <= the budget + // (Circuits.V2/BENFNKBKHEA.txt). Nothing is allocated from it, + // but it is the only runaway-loop guard: a budget near + // int.MaxValue lets the summed counters wrap negative and never + // trip. 10M keeps 200x headroom under int.MaxValue. + Compute: Math.Clamp(settings.Compute, 1, 10_000_000), + // The chip-culling manager's ctor preallocates four + // List(Chips) (PAFDMBHNMBP.txt .ctor ISIL 053-108), so + // 0 or a negative is an ArgumentOutOfRange and 1M is 32MB of + // pointer arrays — the ceiling. + Chips: Math.Clamp(settings.Chips, 1, 1_000_000), + // CalculateActiveResourceCost computes + // inkCost += InkPerChip * Math.Min(numChips, CloudValues) in + // plain int (CreationManager.txt ISIL 389-415, :6136-6162). + // With InkPerChip capped at 30000 below, 10000 keeps that + // product at 3e8, well under int.MaxValue. + CloudValues: Math.Clamp(settings.CloudValues, 0, 10_000), + // A non-finite value would serialize as "NaN"/"Infinity", which + // the client's float.TryParse happily accepts. + CloudValuesAutosaveSeconds: float.IsFinite(settings.CloudValuesAutosaveSeconds) + ? Math.Clamp(settings.CloudValuesAutosaveSeconds, 1f, 3600f) + : 300f, + UnculledChips: Math.Clamp(settings.UnculledChips, 1, 1_000_000), + VisibleChips: Math.Clamp(settings.VisibleChips, 1, 1_000_000), + // InkPerChip is an ink cost against the room's hard-coded + // 30000-ink cap (CostInfo(0x7530, 64, 101, Chips, CloudValues) + // in CreationManager.OnCircuitsV2LimitsRefreshed, ISIL 060, + // :3374). Past 30000 a single chip is unaffordable, and the + // cap also bounds the int multiply noted on CloudValues. No + // client code divides by it, so 0 is safe. + InkPerChip: Math.Clamp(settings.InkPerChip, 0, 30_000), + UpdatedAt: DateTime.UtcNow); + } + + private static CircuitsV2LimitSettings ToCircuitsV2Limits(ServerSettingsEntity row) + { + if (!string.IsNullOrWhiteSpace(row.CircuitsV2LimitsJson)) + { + try + { + var parsed = JsonSerializer.Deserialize( + row.CircuitsV2LimitsJson, + JsonOptions); + if (parsed is not null) + return NormalizeCircuitsV2Limits(parsed) with { UpdatedAt = row.UpdatedAt }; + } + catch + { + // Bad settings should not break gameconfig bootstrap. + } + } + + return DefaultCircuitsV2Limits() with { UpdatedAt = row.UpdatedAt }; + } } public sealed record WeeklyChallengeSettings( @@ -848,6 +1060,38 @@ public sealed record GameConfigurationSetting( DateTime? StartTime = null, DateTime? EndTime = null); +/// +/// One entry of the UGC.CircuitsV2.Limit.Network array. The client +/// sorts entries ascending by and +/// activates the highest one whose threshold is at or below the room's max +/// player capacity, so a single entry with a threshold of 0 applies +/// everywhere. Field names are the literal JSON keys the client's +/// Newtonsoft reader binds to LimitConfig.NetworkConfig. +/// +public sealed record CircuitsV2NetworkLimit( + int NumPlayersForActivation, + int Bytes, + int BytesTimeWindowFrame60s, + int Rpcs, + int RpcTimeWindowFrame60s); + +/// +/// The UGC.CircuitsV2.Limit.* gameconfig family, read by +/// RecRoom.CircuitsV2.Config.LimitConfig. Every field maps to one +/// key; the client falls back to its baked-in defaults for any key the +/// server omits or sends unparseably. +/// +public sealed record CircuitsV2LimitSettings( + List Network, + int Compute, + int Chips, + int CloudValues, + float CloudValuesAutosaveSeconds, + int UnculledChips, + int VisibleChips, + int InkPerChip, + DateTime UpdatedAt); + public sealed record DiscoveredGameConfigSettings( int FriendsPostGamePromptUnderFriendCount, int FriendsSuggestFriendCodeOnFriendsScreenCount, diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index f2c3f41..485b5bc 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -536,6 +536,8 @@ public static class DatabaseBootstrap @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""BaseRoomNamesJson"" text NOT NULL DEFAULT '';"); await db.Database.ExecuteSqlRawAsync( @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RoomBlobVersionClampDisabled"" boolean NOT NULL DEFAULT false;"); + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""CircuitsV2LimitsJson"" text NOT NULL DEFAULT '';"); return; } @@ -646,6 +648,18 @@ public static class DatabaseBootstrap // ignore the duplicate-column error; any other schema problem // will surface when EF reads ServerSettings. } + + try + { + await db.Database.ExecuteSqlRawAsync( + @"ALTER TABLE ""ServerSettings"" ADD COLUMN ""CircuitsV2LimitsJson"" TEXT NOT NULL DEFAULT '';"); + } + catch + { + // SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists, + // ignore the duplicate-column error; any other schema problem + // will surface when EF reads ServerSettings. + } } private static async Task EnsureInventorySchemaAsync(DorkNetDbContext db) @@ -855,6 +869,11 @@ public static class DatabaseBootstrap @"ALTER TABLE ""Clubs"" ADD COLUMN IF NOT EXISTS ""ClubChatEnabled"" boolean NOT NULL DEFAULT true;", @"ALTER TABLE ""ChatMessages"" ADD COLUMN IF NOT EXISTS ""ModerationState"" integer NOT NULL DEFAULT 0;", @"ALTER TABLE ""TestCases"" ADD COLUMN IF NOT EXISTS ""CommentsJson"" text NOT NULL DEFAULT '[]';", + // 2023 profile edits (PUT account/me/{emoji,bannerimage,personalpronouns,identityflags}). + @"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""DisplayEmoji"" character varying(32) NOT NULL DEFAULT '';", + @"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""BannerImageName"" character varying(256) NULL;", + @"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""PersonalPronouns"" integer NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""IdentityFlags"" integer NOT NULL DEFAULT 0;", """ CREATE TABLE IF NOT EXISTS "ClubRolePermissions" ( "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, @@ -906,10 +925,28 @@ public static class DatabaseBootstrap @"ALTER TABLE ""Clubs"" ADD COLUMN ""ClubChatEnabled"" INTEGER NOT NULL DEFAULT 1;", @"ALTER TABLE ""ChatMessages"" ADD COLUMN ""ModerationState"" INTEGER NOT NULL DEFAULT 0;", @"ALTER TABLE ""TestCases"" ADD COLUMN ""CommentsJson"" TEXT NOT NULL DEFAULT '[]';", + // Repair rows created while the ClubPermissionsGalleryAndModeration + // migration carried defaultValue: "" — the empty string is not + // valid JSON and made the comment reader throw. Idempotent: '' + // is never a legitimate value for this column. + @"UPDATE ""TestCases"" SET ""CommentsJson"" = '[]' WHERE ""CommentsJson"" = '';", + @"ALTER TABLE ""Players"" ADD COLUMN ""DisplayEmoji"" TEXT NOT NULL DEFAULT '';", + @"ALTER TABLE ""Players"" ADD COLUMN ""BannerImageName"" TEXT NULL;", + @"ALTER TABLE ""Players"" ADD COLUMN ""PersonalPronouns"" INTEGER NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Players"" ADD COLUMN ""IdentityFlags"" INTEGER NOT NULL DEFAULT 0;", }; foreach (var sql in sqlite) { - try { await db.Database.ExecuteSqlRawAsync(sql); } catch { } + // Only the duplicate-column error is expected (the column already + // exists via Migrate() or a previous boot). Anything else — locked + // database, missing table, malformed schema — must fail startup + // here rather than as a 500 on the first endpoint that needs the + // column. + try { await db.Database.ExecuteSqlRawAsync(sql); } + catch (Exception ex) when ( + ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase)) + { + } } var sqliteTables = new[] @@ -940,9 +977,11 @@ public static class DatabaseBootstrap """, @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");", }; + // CREATE ... IF NOT EXISTS is already idempotent; a failure here is a + // real problem and must fail startup. foreach (var sql in sqliteTables) { - try { await db.Database.ExecuteSqlRawAsync(sql); } catch { } + await db.Database.ExecuteSqlRawAsync(sql); } } diff --git a/DorkNet.Server/Startup/RequestTracingExtensions.cs b/DorkNet.Server/Startup/RequestTracingExtensions.cs index d3653b7..0ceadb9 100644 --- a/DorkNet.Server/Startup/RequestTracingExtensions.cs +++ b/DorkNet.Server/Startup/RequestTracingExtensions.cs @@ -263,8 +263,13 @@ public static class RequestTracingExtensions private static string[] ResolveSuccessBodyPaths() { var configured = Environment.GetEnvironmentVariable("Trace__SuccessBodyPaths"); + // "/thread" is deliberately absent from the defaults: successful + // thread payloads carry private chat messages, which RedactSecrets + // does not touch, and they would land in application logs and + // PlayerLogEntry rows. Opt in explicitly via Trace__SuccessBodyPaths + // for a short-lived diagnosis instead. if (configured is null) - return ["/player", "/thread", "/clone", "/rooms/", "/matchmake/"]; + return ["/player", "/clone", "/rooms/", "/matchmake/"]; return configured .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index 04e66ca..7d6dbb6 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -234,6 +234,8 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // Friend presence fan-out; resolves its scoped DB per push. + services.AddSingleton(); services.AddSingleton(); // S3-compatible object storage (Garage in production, MinIO/disk in dev) // — holds profile images + room-blob bytes. Stateless wrapper around diff --git a/DorkNet.Server/admin-ui/src/pages/Room.tsx b/DorkNet.Server/admin-ui/src/pages/Room.tsx index 936030a..c80fcda 100644 --- a/DorkNet.Server/admin-ui/src/pages/Room.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Room.tsx @@ -165,6 +165,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void } const [description, setDescription] = useState(room.description); const [accessibility, setAccessibility] = useState(room.accessibility); const [cloningAllowed, setCloningAllowed] = useState(room.cloningAllowed); + const [isAGRoom, setIsAGRoom] = useState(room.isAGRoom); const [tagsCsv, setTagsCsv] = useState(room.tagsCsv); const [hotScore, setHotScore] = useState(String(room.hotScore)); const [imageName, setImageName] = useState(room.imageName); @@ -176,6 +177,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void } setDescription(room.description); setAccessibility(room.accessibility); setCloningAllowed(room.cloningAllowed); + setIsAGRoom(room.isAGRoom); setTagsCsv(room.tagsCsv); setHotScore(String(room.hotScore)); setImageName(room.imageName); @@ -193,6 +195,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void } Description: description, Accessibility: accessibility, CloningAllowed: cloningAllowed, + IsAGRoom: isAGRoom, TagsCsv: tagsCsv, HotScore: Number.isFinite(hot) ? hot : undefined, ImageName: imageName, @@ -283,6 +286,14 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void } /> Cloning allowed +
- {sub.dataBlobName && ( -
{sub.dataBlobName}
+
+ {sub.effectiveBlobName + ? sub.effectiveBlobName + : sub.isBakedOriginal + ? 'no data blob — baked scene only (no Maker Pen / chips)' + : '(synthetic default blob)'} +
+ {sub.isBakedOriginal && ( +
+ + + { + const f = e.target.files?.[0]; + if (f) uploadBlob(sub, f); + e.target.value = ''; + }} + className="text-xs" + /> +
+ )} + {sub.isBakedOriginal && ( +
+ {!sub.dataBlobName ? ( + + ) : resetArmed === sub.subRoomId ? ( + <> + Drops the blob pointer; saved blobs stay restorable. + + + + ) : ( + + )} +
)}
@@ -647,8 +783,9 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n /> diff --git a/DorkNet.Server/admin-ui/src/pages/Settings.tsx b/DorkNet.Server/admin-ui/src/pages/Settings.tsx index 9d5d30c..bdf6018 100644 --- a/DorkNet.Server/admin-ui/src/pages/Settings.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Settings.tsx @@ -139,10 +139,31 @@ interface DiscoveredGameConfigSettings { updatedAt: string; } +interface CircuitsV2NetworkLimit { + numPlayersForActivation: number; + bytes: number; + bytesTimeWindowFrame60s: number; + rpcs: number; + rpcTimeWindowFrame60s: number; +} + +interface CircuitsV2LimitSettings { + network: CircuitsV2NetworkLimit[]; + compute: number; + chips: number; + cloudValues: number; + cloudValuesAutosaveSeconds: number; + unculledChips: number; + visibleChips: number; + inkPerChip: number; + updatedAt: string; +} + export function Settings({ embedded }: { embedded?: boolean } = {}) { const [settings, setSettings] = useState(null); const [weekly, setWeekly] = useState(null); const [gameConfigs, setGameConfigs] = useState(null); + const [circuits, setCircuits] = useState(null); const [rewardOptions, setRewardOptions] = useState({ avatarItems: [], consumables: [], @@ -152,20 +173,23 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) { const [busy, setBusy] = useState(false); const [weeklyBusy, setWeeklyBusy] = useState(false); const [gameConfigBusy, setGameConfigBusy] = useState(false); + const [circuitsBusy, setCircuitsBusy] = useState(false); const toast = useToast(); const load = async () => { try { - const [s, w, rewards, configs] = await Promise.all([ + const [s, w, rewards, configs, limits] = await Promise.all([ get('/settings'), get('/settings/weekly-challenges'), get('/settings/weekly-challenges/reward-options'), get('/settings/gameconfigs'), + get('/settings/circuits-limits'), ]); setSettings(s); setWeekly(w); setRewardOptions(rewards); setGameConfigs(configs); + setCircuits(limits); setErr(null); } catch (e) { setErr((e as Error).message); @@ -470,6 +494,39 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) { } }; + const updateCircuits = (patch: Partial) => { + setCircuits((current) => current ? { ...current, ...patch } : current); + }; + + const updateCircuitsNetwork = (index: number, patch: Partial) => { + setCircuits((current) => current + ? { + ...current, + network: current.network.map((entry, i) => i === index ? { ...entry, ...patch } : entry), + } + : current); + }; + + const saveCircuits = async (reset = false) => { + if (!circuits && !reset) return; + setCircuitsBusy(true); + try { + const body = reset ? defaultCircuitsLimits() : circuits; + const updated = await api('/settings/circuits-limits', { + method: 'POST', + body, + }); + setCircuits(updated); + toast.push( + reset ? 'CircuitsV2 limits reset.' : 'CircuitsV2 limits saved. Players must rejoin their room.', + 'success'); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setCircuitsBusy(false); + } + }; + const refreshBtn = ( + +
+ + +
+ {settings.network.map((entry, index) => ( +
+

+ Network budget{settings.network.length > 1 ? ` — from ${entry.numPlayersForActivation} players` : ''} +

+

+ Each budget is spent over a rolling window measured in frames, so a window of 1 means + per frame and 60 means roughly per second at 60fps. Widening a window makes the limit + stricter, not looser — raise the budget instead. +

+
+ netNumber(index, 'rpcs', v)} + /> + netNumber(index, 'rpcTimeWindowFrame60s', v)} + /> + netNumber(index, 'bytes', v)} + /> + netNumber(index, 'bytesTimeWindowFrame60s', v)} + /> +
+
+ ))} + +
+

Circuit budget

+
+ number('compute', v)} + /> + number('chips', v)} + /> + number('unculledChips', v)} + /> + number('visibleChips', v)} + /> + number('inkPerChip', v)} + /> +
+
+ +
+

Cloud variables

+
+ number('cloudValues', v)} + /> + number('cloudValuesAutosaveSeconds', v)} + /> +
+
+
+ + ); +} + function NumberConfig({ label, value, + hint, onChange, }: { label: string; value: number; + hint?: string; onChange: (value: string) => void; }) { return ( ); } @@ -1128,6 +1345,26 @@ function JsonConfig({ ); } +function defaultCircuitsLimits(): CircuitsV2LimitSettings { + return { + network: [{ + numPlayersForActivation: 0, + bytes: 1_000_000_000, + bytesTimeWindowFrame60s: 60, + rpcs: 1_000_000_000, + rpcTimeWindowFrame60s: 1, + }], + compute: 500_000, + chips: 30_000, + cloudValues: 1_000, + cloudValuesAutosaveSeconds: 300, + unculledChips: 3_000, + visibleChips: 3_000, + inkPerChip: 0, + updatedAt: new Date().toISOString(), + }; +} + function defaultGameConfigs(): DiscoveredGameConfigSettings { return { friendsPostGamePromptUnderFriendCount: 3, diff --git a/README.md b/README.md index 9274448..c67f6ad 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # DorkNet — `march-2023-03-21` -This is the **December 2020.12.18** branch of DorkNet — a self-hostable +This is the **March 2023** branch of DorkNet — a self-hostable reimplementation of the Rec Room backend tuned to the wire protocol of -the **Rec Room 2020.12.18** client build. +the **Rec Room 2023.03.21** PC client build (`appVersion` `20230317`). > ⚠️ **Not affiliated with Rec Room Inc. or Against Gravity.** > See [DISCLAIMER](../../blob/main/DISCLAIMER.md). DorkNet ships no Rec > Room game assets or modified binaries — you supply your own -> legally-acquired Rec Room 2020.12.18 install. +> legally-acquired Rec Room 2023.03.21 install. If you're not sure which DorkNet branch you want, start at the [main branch's chart](../../blob/main/BRANCHES.md). The launcher on @@ -19,23 +19,44 @@ main also auto-detects your install. | | | |---|---| -| **Client build** | Rec Room 2020.12.18 | -| **Version key** (sent as `X-DorkNet-Version` header) | `december_2020_12_18` | -| **Version plugin** | `DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs` | -| **Schema** | One EF `Initial` migration; SQLite + Postgres both work | +| **Client build** | Rec Room 2023.03.21 (PC, IL2CPP); reports `appVersion` `20230317` | +| **Accepted build IDs** (`api/versioncheck/v4?v=`, mapped in `appsettings.json` → `BuildIdToVersionKey`) | `20230317`, `20230321` | +| **Version key** (sent as `X-DorkNet-Version` header) | `march_2023_03_21` | +| **Version plugin** | `DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs` (generation `March2023`; the folder name is historical) | +| **Schema** | EF migrations plus an idempotent schema bootstrap; SQLite + Postgres both work | | **Runtime topology** | Gateway-fronted microservices with dedicated service slices and a monolith fallback | -| **Diverges from `march-2020-03-10` by** | ~170 files | +| **Diverges from `december-2020-12-18` by** | ~220 files | -Subsystems present on this branch that the March branch doesn't have: +This branch serves **only** the 2023 client. It does not advertise or +answer the older 2020 build keys; 2020 and 2018 clients have their own +branches. -- Chat threads + group DMs (`ChatThreads`, `ChatThreadMembers`) -- Clubs surface (`clubs.{apex}/*` — announcements, categories, subscriptions) -- Playlists (curated + user-created) -- Weekly challenges -- Room keys (purchasable room access) -- Room roles (per-room co-owner / moderator / host grants) -- Leaderboard channel metadata -- Loading-screen tips +What the 2023 client gets here that the 2020 branches don't have: + +- **Every 2023 client route.** All HTTP routes the 2023.03.21 client can + issue reach a handler under the verb it actually uses, and + `DorkNet.Server.Tests/ClientRouteCoverageTests.cs` fails the build if one + regresses. Routes, verbs and wire shapes are in + [docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md). +- **Realtime over the SignalR hub** (`hub/v1` on the notify host): numeric + push ids, presence updates to friends that honour each player's + online-status visibility, and account updates when someone edits their + profile. +- **Room saves and sub-rooms** through the storage host, plus cloning and + private instances: + [docs/recroom-2023-room-save.md](docs/recroom-2023-room-save.md). +- **CircuitsV2 limits** served as gameconfig and lifted well past retail + (network budgets, chips, compute, cloud values, ink): + [docs/recroom-2023-circuitsv2-limits.md](docs/recroom-2023-circuitsv2-limits.md). +- **Room shops**: room consumables, room currencies and room keys: + [docs/recroom-2023-room-consumables.md](docs/recroom-2023-room-consumables.md). +- **2023 wardrobe**: owned items, color variants keyed by combination GUID, + saved outfits and custom avatar items (custom shirts). +- **2023 profile**: bio, display emoji, pronouns, identity flags, banner + image and online-status visibility. +- Inventions, clubs (roles, permissions, gallery), chat threads, playlists, + quest reward chests, leaderboards, and the influencer and moderation + surfaces. --- @@ -50,7 +71,7 @@ dotnet tool install --global dotnet-ef **2. Clone this branch:** ```pwsh -git clone --branch december-2020-12-18 https://github.com/DorkSquadRR/DorkNet +git clone --branch march-2023-03-21 https://github.com/DorkSquadRR/DorkNet cd DorkNet ``` @@ -92,53 +113,50 @@ notes, see ## Patching your client -```pwsh -.\tools\install-melon.ps1 ` - -RecRoomPath "C:\Path\To\Recroom_Release_Data" ` - -PhotonAppId "" ` - -PhotonVoiceAppId "" +The 2023.03.21 PC build is laid out like this: + +``` +/ + RecRoom.exe <- the game; run this + RecRoom_Data/ <- globalgamemanagers, resources.assets, levelN ``` -This installs the MelonLoader mod (`DorkNet.ClientMod`) into the -client's `Mods/` folder and writes its config to -`MelonLoader/UserData/dorknet-clientmod.json`. The mod rewrites all -`*.rec.net` URIs to your configured DorkNet apex, swaps the Photon -AppIds, and bypasses TLS verification on the client's HTTPS calls so -self-signed certs work. (First run prints "launch the game once"; do -that, then re-run with `-ResumeBuild` to finish — see the script's -`-?` help.) +The client is IL2CPP, so it is patched at runtime with **MelonLoader +0.6.x** and the `DorkNet.ClientMod` mod: -### Standalone Quest build (experimental) +1. Install MelonLoader 0.6.x into the install folder, launch + `RecRoom.exe` once so MelonLoader generates its IL2CPP assemblies, then + quit. +2. Build the mod and copy it into `Mods\` next to `RecRoom.exe`: -The December Quest APK is also IL2CPP and can build the same -`DorkNet.ClientMod` source against LemonLoader's Android runtime. The -current Quest path is still device-tested manually: build the mod as a -net8 LemonLoader DLL, install it into a LemonLoader-patched APK, then -sideload the resigned APK. + ```pwsh + $env:DOTNET_ROOT = "C:\Program Files\dotnet" + dotnet build .\DorkNet.ClientMod -c Release + Copy-Item .\DorkNet.ClientMod\bin\Release\DorkNet.ClientMod.dll "\Mods\" + ``` -From this branch: + Close the game first; the copy fails while `RecRoom.exe` holds the DLL. +3. Copy `DorkNet.ClientMod\dorknet-clientmod.json.template` to + `\UserData\dorknet-clientmod.json` (MelonLoader 0.6 keeps + `UserData\` at the install root), then fill in your DorkNet apex and + Photon AppIds. The config is read once at startup. -```pwsh -dotnet build .\DorkNet.ClientMod\DorkNet.ClientMod.csproj ` - -p:TargetFrameworks=net8.0 ` - -p:MelonLoaderDir="C:\path\to\LemonLoader\melon_data\MelonLoader" -``` +The mod rewrites all `*.rec.net` URIs to your DorkNet apex, swaps the +Photon AppIds (they are hard-coded in the IL2CPP binary, so this has to +happen at runtime), and can bypass TLS verification on the client's HTTPS +calls so self-signed certs work. -Output lands at -`DorkNet.ClientMod\bin\Debug\net8.0\DorkNet.ClientMod.dll`. Desktop -builds still default to net6 and keep the historical flat output path. +> `tools\install-melon.ps1` automates these steps for the **2020** layout +> (`Recroom_Release.exe` / `Recroom_Release_Data`). It does not recognise +> the 2023 `RecRoom.exe` / `RecRoom_Data` layout yet, so use the manual +> steps above for 2023. -Quest notes: +### Quest -- Target package: `com.AgainstGravity.RecRoom`. -- Use a public HTTPS DorkNet host; keep `"EnableTlsTrustBypass": false` - for Quest builds. -- The Dec Photon hooks are discovered from the unique - `PUNNetworkManager` methods returning `AppSettings` and - `AuthenticationValues`, with the known Dec obfuscated names as fallback. -- Standalone APK patching/resigning is not yet wrapped by - `install-melon.ps1`; this is the porting path, not a public one-click - installer yet. +A Quest client for this branch is in progress and not usable yet. The +archive holds a Quest build of the same release (version `20230317`), so +Quest and PC players will be on matching versions once it lands. Until +then, this branch supports the PC client only. ### Debugging (opt-in — `DorkNet.DebugMod`) @@ -204,6 +222,87 @@ filter for extra players, so player indexes above the four baked spawn slots can reuse existing quest spawn points instead of being left out. Default `0` (off). +### Rec Royale night mode (opt-in) + +Rec Royale never shipped a night variant, but the client carries the room +"mood" engine (`RecRoom.Core.Scene.SceneMoodSettings`) and ready-made +night `MoodSetting` assets in `resources.assets`. Set +`"RecRoyaleNightMode"` in `dorknet-clientmod.json` and the mod applies one +of those moods (sky dome, sun, fog, bloom and colour grading) as soon as +the `RecRoyale_Frontier` scene finishes loading, and unlocks the weapon +flashlight prefab while it is on: + +```json +{ + "RecRoyaleNightMode": true, + "RecRoyaleNightMood": "Night_Calm_Outdoor_Mood", + "RecRoyaleNightScenes": ["RecRoyale"], + "RecRoyaleNightFlashlights": true, + "RecRoyaleNightFog": false, + "RecRoyaleNightExposure": -3.5, + "RecRoyaleNightSunScale": 0.15, + "RecRoyaleNightAmbientScale": 0.2, + "RecRoyaleNightForcePostProcessing": true, + "RecRoyaleNightMatchSunLights": true, + "RecRoyaleNightDisableFog": false, + "RecRoyaleNightHideObjects": ["Cloud"], + "RecRoyaleNightToggleKey": "N" +} +``` + +`RecRoyaleNightDisableFog` turns fog off entirely while night is on +(`RenderSettings.fog` forced off and the mood's fog block pushed out of +range); it is ignored when `RecRoyaleNightFog` is `true`. +`RecRoyaleNightHideObjects` hides every renderer whose object or material +name contains one of the entries; the default hides Frontier's daytime +cloud meshes (`CloudPlane`, `Clouds`, the `_Cloud_RecRoyale_Frontier_Mat` +static batch). Both are restored on toggle-off or scene change. + +The mood engine only ever drives `RenderSettings.sun`, which on Frontier +is the sun-disc light, not the map's key light, so daylight would keep +casting. `RecRoyaleNightMatchSunLights` (default `true`) copies the +engine-driven sun's rotation and colour onto every other directional +light and scales their intensity by `RecRoyaleNightSunScale`. + +Two more RRO quirks the mod works around: the mood skybox material the +engine swaps in (`MoodConfig.SkyBoxMaterial`) is not loaded outside +custom-room scenes, so the mod builds one on the `Custom/SkyBox` shader +and hands it to the engine (otherwise the sky is plain black and the +engine refuses to touch sky colours or the sun); and post-exposure only +shows when the scene Volume is on and the camera renders +post-processing, which `RecRoyaleNightForcePostProcessing` (default +`true`) forces while night mode is active. + +Brightness: RRO maps are lightmapped, and baked lighting ignores the sun +and ambient changes a mood makes, so the mod also darkens the mood data +itself before applying it. `RecRoyaleNightExposure` is an EV offset added +to the mood's colour-grading post-exposure (a full-frame post effect, so +it dims lightmapped surfaces too; `0` = mood default), and +`RecRoyaleNightSunScale` / `RecRoyaleNightAmbientScale` multiply the +mood's sun and ambient intensities (`1` = unchanged). Raise the exposure +toward `0` if it is too dark. + +RRO scenes have no room data blob, so the mood engine starts disabled +there; the mod replays the room-data loader's bring-up (disable, capture +the scene's own lighting, enable), points `RenderSettings.sun` at the +scene's directional light (Frontier ships without the reference, which +would otherwise skip every sun change), and re-captures once the mood +skybox material is in place so the sky colours apply. `RecRoyaleNightFog` +is off by default: the mood's fog block is swapped for one captured from +the scene, so fog stays as the map author left it. + +Shipped moods: `Night_Calm_Outdoor_Mood` ("Calm Night"), +`Night_Spooky_Outdoor_Mood` ("Spooky Night"), `Night_Wild_Outdoor_Mood` +("Wild Night"), `Night_StuntRunner_Outdoor_Mood` and `OuterSpace_Mood`. +`RecRoyaleNightToggleKey` is an optional `UnityEngine.KeyCode` name that +flips night on/off in-game (off restores the scene's own mood); setting a +key also arms the feature when `RecRoyaleNightMode` is `false`. This is +purely **client-side**: the mod calls the mood engine's apply step +directly and never touches the networked room-mood index, so other players +keep their own view. Baked lightmaps are not changed, so interiors stay +lit; the mood's colour grading does the darkening. Default `false` (off). +Wire-level notes: `docs/recroom-2023-scene-moods.md`. + --- ## Code map @@ -275,8 +374,8 @@ troubleshooting, see [docs/admin.md](docs/admin.md). ## Database & migrations -One EF migration (`Migrations/_Initial.cs`) captures the -schema. SQLite dev DBs apply it via `Database.Migrate()`; Postgres uses +The consolidated `Initial` migration plus the migrations added since +(`Migrations/`) capture the schema. SQLite dev DBs apply it via `Database.Migrate()`; Postgres uses `EnsureCreated()` behind a transaction-scoped advisory lock. The entity model is the single source of truth — to add a column, edit the entity, boot, done (on SQLite — Postgres prod needs a migration). @@ -344,11 +443,12 @@ public internet: - **First account becomes admin.** The first account created on a fresh DB is granted `IsAdmin` so there's always a root admin. On a public server, create your admin account first and verify no one else beat you to it. -- **Online status = Everyone.** Presence reports `StatusVisibility = 0` - (Everyone) so every player appears online to every other player, matching - the [everyone-is-friends](#everyone-is-friends-toggle) convenience. A public - server may instead want to persist and honor each player's own visibility - preference. +- **Online status visibility.** Each player's in-game choice (Public, + Friends only, Favorite friends only, Appear offline) is stored and + honoured by both `GET /player` and the live presence pushes. Players who + never pick one are Public. "Friends only" follows the + [everyone-is-friends](#everyone-is-friends-toggle) toggle, so with that + toggle on it shows them to everyone. - **Room / matchmaking capacity.** New matchmade sessions and subrooms default to **8** players and are capped at **40** (`GameSessionService`, `RoomSceneEntity.MaxPlayers`, admin `MaxCapacity` clamp). The in-game "Max @@ -371,8 +471,9 @@ LAN/friends infrastructure unless you harden the items above. See [main's CONTRIBUTING guide](../../blob/main/CONTRIBUTING.md) for the project-wide rules. -Branch-specific: schema changes on this branch don't flow to -`march-2020-03-10` automatically. If a fix applies to both, cherry-pick +Branch-specific: this is the repo's default branch, and Dependabot and +server PRs target it. Schema changes here don't flow to the 2020 or 2018 +branches automatically. If a fix applies to more than one, cherry-pick it across. Don't rebase across branches. --- diff --git a/docs/admin.md b/docs/admin.md index 05546e3..c3d8e37 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -139,6 +139,45 @@ in S3 is modified. Toggle endpoint: `{"Disabled": bool}`. Clamped responses are served with a 60s edge TTL so flipping the toggle lands within a minute. +## CircuitsV2 limits (`/settings`) + +`ServerSettingsEntity.CircuitsV2LimitsJson` backs the +`UGC.CircuitsV2.Limit.*` gameconfig keys the client reads into +`RecRoom.CircuitsV2.Config.LimitConfig`. The panel's headline setting is +the network budget: retail allows a circuit **5 RPCs per frame** and +**1024 bytes per 60 frames**, and silently **drops** every send past +that, which is the usual cause of a busy CV2 room appearing to desync. +DorkNet ships both budgets at 1,000,000,000. + +Two things about this are counter-intuitive enough to be worth stating: + +- The budgets are spent over a rolling window measured in **frames**, so + widening a window makes the limit *stricter*, not looser. Raise the + budget, not the window. +- The **byte** budget is checked before the **RPC** budget, so raising + `Rpcs` on its own leaves circuits blocked. Both have to move together. +- `InkPerChip` is a **cost** charged against a 30000-ink room cap that is + hard-coded in the client, so lowering it is what gives circuits more + room. DorkNet ships it at 0. + +The room-size limits ship at about 10x retail: Compute 500000, Chips +30000, CloudValues 1000, and UnculledChips/VisibleChips 3000. +CloudValuesAutosaveSeconds stays at 300. + +Values are clamped on save, and most of the clamps are crash guards +rather than taste. `Rpcs` of 0 divides by zero in the client's CV2 debug +subscreen. A time window allocates one array slot per frame, so an +unbounded window is an out-of-memory kill at circuit init. Chips sets +the size of four list preallocations. InkPerChip × CloudValues is an +unchecked int multiply. Compute is the client's only runaway-loop +guard, so it has to stay finite. Full +reasoning and the disassembly it came from are in +`docs/recroom-2023-circuitsv2-limits.md`. + +Endpoints: `GET`/`POST api/admin/v1/settings/circuits-limits`. The client +builds its rate limiter once when circuits start, so **players must +rejoin their room** before a change takes effect. + ## Per-sub-room max players (`/rooms` → room detail) Player caps are two separate settings, and conflating them is the easy @@ -179,6 +218,124 @@ Enforcement caveat carried over from `RoomEntity.MaxCapacity`: this is the advertised cap. The client never sets Photon `RoomOptions.MaxPlayers`, so hard enforcement still needs a ClientMod Photon patch. +## RRO room data blob — Maker Pen and chips in a Rec Room Original (`/rooms` → room detail → Sub-rooms) + +Baked Rec Room Originals (Rec Royale, Paintball, Laser Tag, …) are seeded +with an empty `RoomSceneEntity.DataBlobName` and empty +`RoomEntity.CurrentDataBlobName`. Every details builder and matchmaking +resolver (`RoomsController.CurrentOrSyntheticDataBlobName`, +`GoToController.ResolveInitialDataBlobAsync`, +`MatchPlayerController`) turns that into an empty `DataBlob`, and the 2023 +client reads an empty blob name as "this room has no room data": it skips +the persistence download entirely, so there are no room roles (no Maker +Pen despite the CDN's RRO role overlay), no CV2 room-settings chips, no +room mood engine and no saves. Real Rec Room served these rooms a blob — +Rec Royale's game-rule chip (`RecRoyaleSolos_GameRuleWrapperCircuit` / +`RecRoyaleSquads_GameRuleWrapperCircuit`, prefabs in `resources.assets`) +is delivered that way; the Frontier scene has no baked game rule. + +The Sub-rooms tab shows, per sub-room, the blob the client will actually +be told to download (`effectiveBlobName`) and, for baked originals, two +actions: + +- **Enable Maker Pen blob** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable`. + Stamps the synthetic default name (`room_{id}_dorknet_v8.dat` for the + entry scene, `room_{id}_dorknet_v8_sub{n}.dat` otherwise) on the scene + and, for sub-room 0, on the room. No resolver changes are involved: they + already return a non-empty name verbatim, and `CdnController` already + answers that name for AG rooms with the RRO-editable blob (permissive + room roles, no objects) while nothing is saved. `IsRRO` is derived from + `IsAGRoom`, so the client keeps loading the baked map and only + additionally fetches the blob. Idempotent; a no-op on a sub-room that + already has a blob. Seeded originals such as Rec Royale have **no + `RoomScenes` row at all** ("Scenes 0"); the list synthesises a virtual + "Home" entry (`isVirtual: true`, max players = room capacity) and enable + creates the real row with the game-side defaults + (`RoomsController.GetOrCreateSceneForMutationAsync`). + What the CDN hands back for that name while nothing is saved is a + **per-room stub** built by `RoomDataBlobService.BuildRroEditableBlob`: + the skeleton of a real save (Rec Center's `room_100_v1.dat` was the + reference) — `version` 16 (the client's ceiling; the old roles-only stub + was stamped 19 and the client fetched it and silently loaded the room + with no roles, i.e. no Maker Pen), `activity_id` = the room's + `LocationReplicationId`, `last_save_time`, empty connectable graph / + object model, scene settings and the four `room_mood_*` blocks, plus the + permissive role collection. The miss path now runs the same 2023 + version clamp as the S3-hit path. It also carries the room's own three + **game-role objects** (a default role named after the room, plus the + built-in "In-Game" and "Eliminated" roles, well-known GUIDs shared by + every room save — Rec Center and Stunt Runner both have exactly these). + The 2023 client grants Maker Pen off the player's *game* role + (`ICreatorRole.CreatorRoleCanUseMakerPen` on `GameRole`), so a blob with + no role objects leaves even a co-owner with nothing to hold — that was + the second reason "enable + co-owner" still showed no Maker Pen. The + default role sets `can_use_maker_pen` to true, matching the permissive + room-role overlay; the built-in roles mirror the reference saves. + For the two Rec Royale rooms the stub also carries the room's + **game-rule chip** (`RecRoyaleSolos_GameRuleWrapperCircuit` prefab id + `983db81a…`, `RecRoyaleSquads_…` `857f7500…`, read from the client's + spawnable-tool table; a saved Squads chip is header + prefab id + tag + + node id + an empty wrapper payload), so the match logic exists on first + join without anyone spawning it by hand. +- **Upload save** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload?mode=full|basics`, + multipart `file` = a real `PersistedRoomData` save from ANY client + version (`.binpb` / `.room` / `.dat`, e.g. a RecNet export of a custom + Rec Royale room). The server rewrites it for this room + (`RoomDataBlobService.PrepareUploadedRoomBlob`): `activity_id` → the + room's `LocationReplicationId`, drops `output_log`, `sub_room_id` and + every field the 2023 schema doesn't know, clamps the header versions to + the 2023 maxima, and in `basics` mode keeps only the game-role (field 50) + and game-rule (fields 40/41) objects while stripping the circuit payloads + (`circuit_data`, connectable graph, `circuit_v2_data` — a newer + CircuitsV2 graph is rejected by the client's own CV2 version gate no + matter what). Stored under a fresh `room_{id}_upload_*.dat`, recorded in + `RoomDataBlobs`, and the sub-room (and room, for sub-room 0) is pointed + at it. On serve the CDN applies the RRO role overlay and clamp exactly + as for Rec Center's saved blob. Response carries a `summary` + (`viewsIn/viewsKept/roleViews/gameRuleViews/versionIn/versionOut`). +- **Reset to baked scene** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset`. + Clears both pointers. This is the escape hatch if a save left the room + unplayable. Saved blobs stay in `RoomDataBlobs`, so the in-game restore + list can bring one back later. + +Both are audited (`enable_subroom_blob`, `reset_subroom_blob`). Who can +then save the room in game is the normal rule in `SaveDataCore`: owner, +any admin, or an accepted co-owner (`RoomRoles.Role == 0`). Grant a +co-owner role from the Roles tab if a non-admin should be able to save. + +Procedure that makes Rec Royale playable AND editable: + +1. Enable the blob on the Solos room's sub-room 0 (and the Squads room's). +2. Join as an admin; the Maker Pen is available. Spawn the room's game-rule + chip (`RecRoyaleSolos_GameRuleWrapperCircuit` in Solos, + `RecRoyaleSquads_GameRuleWrapperCircuit` in Squads) — via the palette's + Game Rules section, or the debug console's `DebugSpawnTool` if it is not + listed — then save. Without that chip there is no `BattleRoyaleManager` + in the scene and the match never starts. +3. From then on the saved blob (with the game-rule chip and whatever + room-settings chips were placed) is what everyone loads. If a save + breaks the room, Reset to baked scene. + +Tests: `RroRoomBlobTests`. + +### Why Rec Royale still has no Maker Pen after all of the above + +The Maker Pen permission check (`AGMBHDNGOEH.IJEIEBGJJLB`) first runs a +room precondition (`LALJEMHEIBH`) that, via `MLKAFHHHEGA.GHPIBJPNOFF` → +`PNPLLCBBMJP.JGPBNEHFLJA`, looks the current room up in +`AGRoomRuntimeConfig` — a table **baked into the client's +`resources.assets`** — and returns a per-original boolean. Rec Center and +Stunt Runner allow creation there; Rec Royale does not. No blob, role or +server response changes that table, and it is consulted only for rooms the +client considers originals (`IsRRO` = our `IsAGRoom`). + +Server-side experiment: clone Rec Royale, flip the clone's **Rec Room +Original (IsRRO)** checkbox off on the General tab +(`rooms/{id}/props` `IsAGRoom: false`), upload a real save to it, join. If +the client still loads the Frontier scene for a non-original, Rec Royale +becomes an editable template; if the scene fails to load, the table gate +has to be bypassed client-side (ClientMod postfix on `GHPIBJPNOFF`). + ## Test cases ↔ GitHub issues QA test cases can file and track GitHub issues. Configure with: diff --git a/docs/architecture.md b/docs/architecture.md index d2837ef..de1e13a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,9 @@ DorkNet.sln watch at a DorkNet server: URI rewrite (`.rec.net` → configured apex), Photon AppId override, and TLS trust bypass. It loads under MelonLoader 0.6.x and reads its settings from a JSON config under -`MelonLoader/UserData/`. +`MelonLoader/UserData/`. Opt-in extras (all off by default) live in the +same config: Desktop Screen-Share FPS, RRO quest party size, and Rec +Royale night mode (see the README and `recroom-2023-scene-moods.md`). --- diff --git a/docs/recroom-2023-circuitsv2-limits.md b/docs/recroom-2023-circuitsv2-limits.md new file mode 100644 index 0000000..e9e46b0 --- /dev/null +++ b/docs/recroom-2023-circuitsv2-limits.md @@ -0,0 +1,255 @@ +# 2023 client CircuitsV2 limits (`UGC.CircuitsV2.Limit.*`) + +How the March-2023 client budgets CircuitsV2, and how DorkNet lifts the +network budget so circuits stop dropping sends. Reverse-engineered from +`RecRoom.CircuitsV2.Config.LimitConfig` in the 2023-03-21 ISIL dump +(`IsilDump/Assembly-CSharp/RecRoom/CircuitsV2/Config/LimitConfig.txt`) +and the limiter chain under `IsilDump/Circuits.V2/`. + +## Why it matters + +Retail budgets a circuit **5 RPCs per frame** and **1024 bytes per 60 +frames**. A circuit that exceeds either budget does not queue or slow +down — the send is **dropped on the floor** and an execution error of +kind 2 (bytes) or 3 (RPCs) is raised, and the room save gets flagged +`cv2_rpc_limited` / `cv2_net_limited`. This is what makes busy CV2 rooms +feel like they're desyncing at random. + +DorkNet serves both budgets at 1,000,000,000, which is more than a +circuit can spend in a frame. It also raises the room-size limits +(compute, chips, cloud variables and culling) to about 10x retail. +[The scalar limits](#the-scalar-limits) covers what each one does and +why its value stops where it does. + +## The keys + +The client reads these through its gameconfig provider, i.e. the array +served by `GET api/gameconfigs/v1/all`. Any key the server omits — or +sends unparseably — silently falls back to a value baked into the +client, with no error logged. + +| Key | Type | Client default | DorkNet default | +| --- | --- | --- | --- | +| `UGC.CircuitsV2.Limit.Network` | JSON array | one entry, below | budgets lifted | +| `UGC.CircuitsV2.Limit.Compute` | int | 50000 | 500000 | +| `UGC.CircuitsV2.Limit.Chips` | int | 3000 | 30000 | +| `UGC.CircuitsV2.Limit.CloudValues` | int | 100 | 1000 | +| `UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds` | float | 300 | 300 | +| `UGC.CircuitsV2.Limit.UnculledChips` | int | 500 | 3000 | +| `UGC.CircuitsV2.Limit.VisibleChips` | int | 500 | 3000 | +| `UGC.CircuitsV2.Limit.InkPerChip` | int | 15 | 0 | + +The client defaults are the values `LimitConfig..ctor` bakes in (the +`mov dword ptr [rbx+28h..40h]` run in `LimitConfig.txt`). For comparison, +the 2023-06-21 build raised its `COMPUTE_DEFAULT` to 100000 and left the +others unchanged. + +Scalars are parsed with `int.TryParse` / `float.TryParse` under +`NumberStyles.Integer` / `NumberStyles.Float` and +`CultureInfo.InvariantCulture` — so no thousands separators, and a dot +decimal separator only. + +## The network array + +`UGC.CircuitsV2.Limit.Network` is a **top-level JSON array**, parsed by +Newtonsoft (`JsonConvert.DeserializeObject`), not Unity's `JsonUtility`. +Newtonsoft binds public fields and falls back to case-insensitive +matching, but DorkNet emits the client's own field names verbatim: + +```json +[{"NumPlayersForActivation":0,"Bytes":1000000000,"BytesTimeWindowFrame60s":60,"Rpcs":1000000000,"RpcTimeWindowFrame60s":1}] +``` + +The retail value is the same shape with `Bytes` 1024, `Rpcs` 5. + +**Entry selection.** The client sorts entries ascending by +`NumPlayersForActivation` and activates the highest one whose threshold +is at or below the room's **max player capacity** (not the live player +count). One entry at threshold 0 therefore applies everywhere. + +**Validation.** The client rejects the whole array — falling back to the +retail budget — if it is not valid JSON, is empty, or has no entry with +`NumPlayersForActivation` of 0. `ServerSettingsService` normalizes +around all three: it sorts, de-duplicates thresholds, and rewrites the +lowest entry's threshold to 0. + +## The scalar limits + +Every getter is consumed in exactly the places listed here. A sweep of +the whole ISIL dump for `LimitConfig.get_` finds only these files. + +### Compute: per-frame CPU budget + +`CircuitsV2Manager`'s per-frame tick (`OCGDEIJJBAG(float)`, ISIL 240 at +`CircuitsV2Manager.txt:21241`) builds a fresh compute context every +frame through `DIILFAPPLOI` (ISIL 018 at `:12637`), which wraps the value +in `Circuits.V2/BENFNKBKHEA`. That object keeps four int counters. Each +charge adds to one of them and checks the sum against the budget with +`<=`. The default costs come from the `MPKGOFPJCOP` interface: 100, 100, +50 or 500 depending on the kind of operation. When the budget runs out, +circuit work stops for the rest of the frame and the save is flagged +`cv2_cpu_limited`. + +Nothing is allocated from this value. **It is the only guard against +runaway loops**, though. Raising it lets a broken loop burn more frame +time before it is stopped. A budget near `int.MaxValue` would let the +summed counters wrap negative so the check never fires. DorkNet serves +500,000, which is 10x retail and 5x the June 2023 build. That allows +1,000 to 10,000 operations per frame, depending on their kind. + +### Chips: room chip cap + +`CreationManager.OnCircuitsV2LimitsRefreshed` (`CreationManager.txt:3179`) +rebuilds the room's max sandbox resources as +`CostInfo(ink 30000, lights 64, ai 101, numChips = Chips, numCloudVariables = CloudValues)` +(ISIL 050-068). Adding a chip checks `CanAffordResourceCost(OneChip)` +against it. A failed check shows the "Too many chips." toast +(`CircuitsV2Manager_NestedType_AMEJFMPHNEO.txt` ISIL 164-187, 592). + +Hazard: the chip-culling manager's constructor preallocates **four** +`List(Chips)` (`PAFDMBHNMBP.txt` `.ctor` ISIL 053-108). That costs +32 bytes per unit of the limit whether or not the room uses it, and a +value of 0 or less throws. The clamp is 1..1,000,000, which is 32MB at +the top. DorkNet serves 30,000, which preallocates under 1MB. + +### CloudValues: room cloud-variable cap + +This is the `numCloudVariables` field of the same max-resources +`CostInfo`. `CreationManager.CalculateActiveResourceCost` also reads it +(ISIL 389-415 at `CreationManager.txt:6136-6162`): + +``` +inkCost += InkPerChip * Math.Min(numChips, CloudValues) +``` + +That is an unchecked int multiply. It also means that raising +CloudValues while InkPerChip stays at retail's 15 makes chips use +**more** room ink. Serving InkPerChip 0 removes the term. The clamp was +lowered from 100,000 to 0..10,000 so the product stays at or below 3e8 +even with InkPerChip at its own ceiling. DorkNet serves 1,000. + +### InkPerChip: ink cost per chip (lower is more) + +This is a **cost**, not an allowance. It feeds the +`CalculateActiveResourceCost` term above. The add-chip path also charges +it directly through `CanAffordResourceCost` ("Not enough ink.", +`AMEJFMPHNEO` ISIL 247-265). That direct charge only runs when +`ShowGadgetsToEveryone` is set and `Chips < VisibleChips` (ISIL 221-234), +so with the defaults below it never runs. + +The room's ink cap is a **hard-coded 30000** (`0x7530`, +`CreationManager.txt:3374`), not a gameconfig key. Lowering this cost is +therefore the only way to leave circuits more room under that cap. No +client code divides by it. DorkNet serves **0**, so chips use no room +ink. The clamp is 0..30,000, because past 30000 no single chip can be +afforded. + +### UnculledChips and VisibleChips: chip culling + +The chip-culling manager reads both together (`PAFDMBHNMBP.DJLEFJCOLCF`, +ISIL 079-087 at `PAFDMBHNMBP.txt:2520-2525`). It subtracts +`UnculledChips` from the live chip count and culls that many chips. +`VisibleChips` is used in the same arithmetic further down. Both values +only appear in counts and subtractions: they allocate nothing and are +never divisors. Raising them costs rendering time, not stability. +DorkNet serves 3,000 for both, so every chip in a retail-sized room stays +drawn. Both stay at or below Chips, which keeps the `Chips < VisibleChips` +ink charge above switched off. + +### CloudValuesAutosaveSeconds: save cadence + +A `CircuitsV2Manager` coroutine reads this value +(`CircuitsV2Manager_NestedType_CDPKCHNIHFG.txt` ISIL 059 at `:193`), +waits that many seconds, then flushes the CircuitsV2 player save-data +manager. It sets how often saves happen, not how much a room can hold. A +shorter interval sends more save traffic to a shared server. A longer +one loses more progress if the client crashes. DorkNet leaves it at +retail's 300 seconds. The clamp stays at 1..3600 and rejects non-finite +values. + +### Limits these keys don't raise + +- **Room ink 30000, lights 64 and AI controllers 101** are literals in + the max-resources `CostInfo` (`CreationManager.txt:3374`, and again in + the static constructor at `:22761`). +- **Event recursion depth is fixed at 16.** + `Circuits.V2/BCNIMLFCMMD.MENMKOOAJPF` (ISIL 041, + `Compare [rbx+240], 16`) raises "Max recursion depth reached. Do you + have events in a loop?" +- **Room save uploads are capped at 100,000,000 bytes on the server.** + `StorageController.Upload` sets `[RequestSizeLimit]` and + `MultipartBodyLengthLimit` to 100 MB, up from Kestrel's 30 MB default + that a room with tens of thousands of chips could reach. 100 MB is + also Cloudflare's per-request ceiling, so raising it further does + nothing behind the tunnel. + +## How the budget is enforced + +Each budget is a sliding window with **one bucket per rendered frame**. +`RpcTimeWindowFrame60s` is that window's length in frames, so 1 means +per frame and 60 means roughly per second at 60fps. On each send the +client adds the cost to the current bucket and compares the window's +running sum against the budget. + +Two consequences worth keeping in mind: + +- **Widening a window makes the limit stricter, not looser** — the same + budget now has to cover more frames. Raise the budget instead. +- The **byte budget is checked before the RPC budget**, so lifting + `Rpcs` alone leaves circuits blocked on `Bytes`. Both have to move. + +Enforcement is entirely client-local: each client reads its own +gameconfig and polices its own outgoing sends, and nothing validates +budgets on receive. Photon's own server-side message-rate and bandwidth +caps still apply underneath this and are not affected by these keys. + +## Clamps, and why they exist + +`NormalizeCircuitsV2Limits` clamps admin input. Most of the clamps are +crash guards read straight off the disassembly: + +- **`Rpcs` is floored at 1.** The CircuitsV2 debug subscreen renders + `RpcRecentMax / Rpcs` with a hardware integer divide, so a budget of 0 + is a divide-by-zero the moment that panel opens. +- **Time windows are capped at 3600 frames.** A window allocates + `new int[N]` — one bucket per frame — when circuits start, so an + unbounded window is an out-of-memory kill rather than a bigger budget. +- **`Chips` is capped at 1,000,000 and floored at 1**, because it sets + the size of four list preallocations in the chip-culling manager. +- **`InkPerChip` is capped at 30,000 and `CloudValues` at 10,000**, + because the ink calculation multiplies them as unchecked ints. +- **`Compute` is capped at 10,000,000.** It allocates nothing, but it is + the runaway-loop guard, and a value near `int.MaxValue` lets its + counters wrap past the check. + +Budgets themselves are capped at 1,000,000,000: the client only ever +compares a counter against them, and this leaves 2x headroom under +`int.MaxValue`. + +## Operational notes + +- The client builds its rate limiter **once, when circuits start**. + Changing these settings does not affect anyone already in a room — + players have to rejoin. +- Raising `Rpcs` also changes the Maker Pen top bar's network readout, + which renders ` / `; the ratio collapses toward 0 + and the bar effectively tracks byte usage only. +- The keys are additive to the gameconfig array, so older clients that + know nothing about CircuitsV2 just ignore them. +- Defaults are applied when the settings are read. While the + `CircuitsV2LimitsJson` column is empty (no admin has saved the panel), + the server always serves the current defaults. Saving the panel + stores the full record, so every value sticks from then on, including + the ones the admin didn't touch. **Reset** in the panel restores the + current defaults. + +## Where this lives + +| Concern | File | +| --- | --- | +| Settings record, defaults, clamps, key emission | `DorkNet.Server/Services/ServerSettingsService.cs` | +| Persisted column | `ServerSettingsEntity.CircuitsV2LimitsJson` | +| Admin API | `api/admin/v1/settings/circuits-limits` (GET/POST) | +| Admin SPA | `admin-ui/src/pages/Settings.tsx` → CircuitsV2 limits panel | +| Served to the client | `api/gameconfigs/v1/all` | +| Tests | `DorkNet.Server.Tests/CircuitsV2LimitsTests.cs` | diff --git a/docs/recroom-2023-client-api-complete.md b/docs/recroom-2023-client-api-complete.md index 52bca8c..3f14cc2 100644 --- a/docs/recroom-2023-client-api-complete.md +++ b/docs/recroom-2023-client-api-complete.md @@ -64,6 +64,19 @@ an object wrapping one. Bare-scalar endpoints are a recurring source of defects returning `{"Limit":1000}` where the client reads `1000` makes the deserialize throw and the feature silently fail. +**The untyped promise is not always status-only.** Many writes return +`LDGADANDBIO` (no type argument), which reads like "body ignored". About 40 of +them are not: they pass the response to `BFDGEKKNAPB.PEFKBAEAOPD` +(`RecNet.Runtime/BFDGEKKNAPB.txt:353`, "ExpandRecNetResult"), which +deserialises it as `DJMHAFPGLLN` = `{Success, Error}` (formatter +`LMCCNLLHLCJ.txt:191-218`). An empty 200 (or `null`) parses to null and the +continuation null-derefs, so the player sees "Failed to …" after the server +applied the change. A body without `Success` parses as a failure with a null +`Error`, and `Promise.Rejected(null)` throws `ArgumentException: Error message +cannot be null`, which shows up in Player.log as an unobserved task exception. +Search for callers of `BFDGEKKNAPB.PEFKBAEAOPD` before you decide a write +needs no body. The server helper is `Controllers/RecNetResults.cs`. + Watch for a projection between the wire type and the method return type. `comments/unreadcounts` returns `Dictionary` from the method, but the wire payload is `List` — a `Func, Dictionary<...>>` @@ -193,8 +206,15 @@ verified against the binary before the change, and the full test suite passes. | `PUT player/photonregionpings` | parsed only the 2020 pair shape | also reads one field per region name | | `POST player/notifydisconnect` | bound 2020 `otherPlayerId` only | also binds `PlayerId`/`RoomInstanceId` | | `PUT player/{statusvisibility,vrmovementmode,avoidjuniors}` | echoed and discarded | persisted per player and read back on heartbeat | +| `GET player?{ids}` statusVisibility | always 0 (Public); "appear offline" never hid anyone | reports the stored value; Offline hides from everyone else, FriendsOnly / FavoriteFriendsOnly hide from non-qualifying callers | +| `PUT account/me/{emoji,personalpronouns,identityflags,bannerimage}` | 404 — routes missing ("Failed to modify …") | handlers added; form fields `displayEmoji` / `pronounFlags` / `identityFlags` / `imageName`; values persisted and emitted on every account DTO + pushed as AccountUpdate to all clients | | `GET econ/customAvatarItems/v1/itemOwnershipLimit` | object body where the client reads an int | bare JSON integer | -| `GET api/customAvatarItems/v1/isCreationAllowedForAccount` | object body where the client reads a bool | bare JSON boolean | +| `GET api/customAvatarItems/v1/isCreationAllowedForAccount` | object body, then (wrongly) a bare boolean | `{Success, Error}` envelope — the client parses DJMHAFPGLLN (converter LMCCNLLHLCJ registers only `Success`/`Error`); the bare boolean is what logged "Failed to query if local account can create Custom Avatar Items" | +| `GET api/customAvatarItems/v1/minPriceForPublicItem` | object with four hedge keys | bare JSON integer — sent through FDKKOPAPDGF, continuation `b__69_0(Int32 minimumPrice)` | +| `GET + POST api/avatar/v3/saved{,/set}` | `OutfitSelectionsV2` and `CustomAvatarItems` dropped on save; loading a slot restored the stale V1 selection (the client wears V2 when non-empty) | all eight IMMOJHBENFI keys persisted and served | +| `POST api/avatar/v{2,3}/gifts/generate` | 415 on the client's form-urlencoded body (seen live) | form-or-JSON binder | +| `GET api/avatar/v1/lockeditems` | 5-key subset: AvatarItemId 0, no TagList/IsBaseAvatarItem | full IFNGBPJGINL owned-item shape | +| `GET api/avatar/v4/items` (colored variants) | a colorway reused its base item's AvatarItemId, so owning a base + colorways collided in the id-keyed wardrobe | id hashed from the full descriptor | | `GET api/customAvatarItems/v2/fromCreator/{id}` | bare array; client reads {Results,TotalResults} | paged container | | `POST econ/customAvatarItems/v1/{id}/purchase` | 404 — route only existed under api/ | econ/ alias added | | `GET api/ugcPurchasables/v1/items/room/{roomId}` | 404 — only the ?roomId= form existed | path form added | @@ -235,10 +255,58 @@ verified against the binary before the change, and the full test suite passes. | `SignalR push `Id` (notify)` | enum NAME sent for most ids; client only registered numbers — pushes vanished | numeric by default, with the six string-keyed ids listed explicitly | | `SignalR SubscriptionUpdateRoom` | sent as `SubscriptionUpdateRoom`; client listens on `RoomUpdate` | sent as `RoomUpdate` | | `Room ban notification` | sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan | -| `Presence on SignalR reconnect` | disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect re-pushes presence when a room is already known | +| `Presence on SignalR reconnect` | disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect pushes presence (online even with no room); a drop while the player still heartbeats waits one activity window before pushing offline | +| `POST matchmake/none` | re-committed the previous room (or the dorm), so the next heartbeat answered with a room while the client held its offline instance (-1): "presence out-of-sync", offline load cancelled, player dragged back | player is online with no room; any pending join is abandoned; heartbeat answers `roomInstance: null` until the next matchmake | +| `Presence fan-out to friends` | only the immediate /goto commit pushed; deferred commits (matchmake/dorm, private hops), matchmake/none, logout, failed joins and statusvisibility changes were silent, and `isOnline` was derived from "has a room" | one fan-out (PresenceBroadcastService) for every transition, explicit `isOnline`, stored `statusVisibility`, never sent to the subject | +| `GET player` (bulk) | `isOnline` required a room; `statusVisibility` hard-coded 0; caller's own row ignored a pending join | online = heartbeat or SignalR; stored visibility; own row matches the heartbeat | +| `Join watchdog` | 30 s timeout cleared presence in the middle of long room loads, so the next heartbeat bounced the player to the dorm | 3 min backstop that clears the room only (player stays online) | +| `15 club/{id}/members/* writes, DELETE club/{id}, PUT+DELETE club/home/me` | empty 200; the RecNetResult reader null-derefed, so every join/leave/invite/ban showed "Failed to …" | `{success:true,error:""}` | +| `announcements/club/{c}/{a}` edit, delete, `/read` | empty 200 / `{Read:true}`; read threw "Error message cannot be null" | RecNetResult | +| `DELETE api/keepsakes/{id}`, `PUT comments/read/{r}/{c}`, `DELETE subscription/{id}` | empty 200 | RecNetResult | +| `api/roomcurrencies/v1/deletePurchaseOffer`, `PUT accountprivacysettings/recenthistoryvisibility` | non-RecNetResult body (no `Success`) — rejected with a null message | RecNetResult (privacy keys kept) | +| `POST api/playerwarnings` (refusals) | failure carried `Message`, not `Error` | `Error` added | +| `POST account/me/createlogintoken` | object where the client reads a bare string | bare JSON string | +| `POST account/me/remoteauth` | matched codes against createlogintoken tokens — always 401 — and answered a token pair | approves a device-authorization `user_code` for the caller; RecNetResult | +| `connect/token` `device_code` / `login_token` grants | both fell into the device-id fallback (approval bypassed / wrong account) | real grants: `authorization_pending` until approved; login token redeemed once for its own account | +| `PUT api/playerevents/v2/{id}/multiinstance` | broadcast-permission fields are boxed enums (sent as NAMES), parsed as ints → always 0 | names (None/RoomOwners/All) and numbers accepted | +| `GET api/incentivizedreferrals/referrals` | 404 — built by the LPPEFJMCJFN URL builder, missed by the literal sweep | implemented (paged) | +| `POST account/bulk/email`, `account/bulk/phone` | 404 | routed to the bulk account lookup | +| `GET strings` (strings host) | 404 | empty table — no server-side overrides | +| `gameconfig PlayMenuCarouselOrdering_1` | missing — "Could not find GameConfig" warning, prefab order | served; ids recovered from the build's DisplayOrderPriorityImpl | Everything else in the per-subsystem Defects sections below is still -outstanding. +outstanding — but the sections are stale. A 2026-10 re-check of the +breaks-gameplay rooms, playerevents, room-currency, clubs, keepsakes and +storefront entries found all of them fixed except purchase-offer `Order` +(sent by the client, MIHKAJFMIPE.txt:2664, never persisted; display order only). + +### Requests outside the route-literal sweep + +The sweep that produced `client-routes-2023.tsv` matched plain literals in +RecNet.Runtime. These were found later and are now in the inventory: +leading-slash literals (`/api/playerReputation/v2/bulk`, +`/api/players/v2/progression/bulk`, `/strings`, `/config/{0}`, `/data/{0}`), +base-plus-format routes (`api/itemWishlists` + `{0}/v1/wishlist/me/{1}`, +`api/roomEarningsDistributions` + `{0}/v1/earningsDistribution/{1}`), the +`LPPEFJMCJFN` URL builder (`api/incentivizedreferrals/referrals`), the +runtime-composed `account/me/{field}` and `account/bulk/{email,phone}`, and +the Assembly-CSharp CDN fetch `/sigs/{id}` (JFDPIJFDHAD.txt:1432). + +Known and deliberately not served: + +- GameLogs host (`HKBNPBMNDGG` 22): `PUT /reports/{guid}`, `/reports/{guid}/userinfo`, + `/reports/{guid}/attachments/{name}`, `/Reports/{guid}/Complete` + (AICEFGDBCGI.txt:671-1944). This is the "Send Game Log" tool, which is hidden + unless gameconfig `Experience.GameLogTool.HideTool` is false — its client + default is true (BugReportingVisibilityModel.txt:241-245), and DorkNet does not + send the key, so the client never makes these calls. +- BugReporting host (18): `POST /ado/bug`, `POST /ado/file` — Rec Room's internal + Azure DevOps bug filer for staff builds. +- Third-party hosts baked into the client (Amplitude, RudderStack, Unity user + reporting, backtrace.io) are not RecNet services. +- `/sigs/{id}` answers an empty payload on purpose: it is an encrypted + file-signature set for the client's integrity check. The client logs + "/sigs/ is empty" and boot continues (Player.log, 2026-10-10 session). ## Subsystems @@ -320,6 +388,8 @@ Handler: `DorkNet.Server\Controllers\Accounts\AccountsController.cs:54` **Fix.** In AccountsController.GetMe, build the payload from the actual PlayerEntity (Email, Phone, Birthday, IsJunior) and add availableUsernameChanges (e.g. a large int), displayEmoji, bannerImage, personalPronouns (0), identityFlags (0) keys — extend RecNetSelfAccount or return an augmented DTO for the 2023 path. +**Status: FIXED.** Every account read (account/me, account/{id}, account/bulk, account/search, account/create, api/account/v1/me) and the AccountUpdate / SelfAccountUpdate pushes now go through `DorkNet.Server/Services/AccountDtos.cs`, built from the real PlayerEntity: displayEmoji, bannerImage, personalPronouns, identityFlags on the public DTO; email/phone/birthday/juniorState plus availableUsernameChanges (99) on the self DTO. + ##### `DELETE account/me` — VERB_MISMATCH (degraded) Client's in-game account-deletion action sends DELETE /account/me (OPMAPIOEIFG.txt:8771-8773, verb 4). Server registers only [HttpGet] on /account/me (plus v1/v2 GET aliases) — DELETE returns 405 and the client logs 'Failed to delete local account'. @@ -342,6 +412,8 @@ None of these five concrete routes exist. server-routes.txt has no account/me/em **Fix.** Add handlers in AccountsController: [HttpPut] /account/me/emoji (form 'emoji'), /account/me/personalpronouns (form 'personalpronouns', int), /account/me/identityflags (form 'identityflags', int), /account/me/bannerimage (form 'bannerimage'); [HttpPost] /account/me/confirmphone (route the existing ConfirmPhone action). Persist emoji/bannerimage/pronouns/flags on PlayerEntity so GET /account/me can echo them back (required for the account/me shape fix above). +**Status: FIXED for the four profile edits** (confirmphone still open). The form field names suggested above were wrong — the field is NOT the suffix. Verified per call site lambda: emoji → `displayEmoji` (OPMAPIOEIFG_NestedType_KPGNAEPGLAC.txt:57), personalpronouns → `pronounFlags` (…_LCCEHLBIOPK.txt:67, boxed Int32), identityflags → `identityFlags` (…_HHDFMJPLFHG.txt:67, boxed Int32), bannerimage → `imageName` (…_NJKHNGEPODH.txt:57). All four are PUT (rcx=3 before OPMAPIOEIFG.OBAGJBBIFGB). The response is never parsed, so the new value reaches the editor and other players only through the `AccountUpdate` push (now broadcast to every connected client) and `SelfAccountUpdate` (owner). Emoji is validated against `emojiConfig/whitelistedEmojis` (U+FE0F-insensitive, stored in the list's spelling); empty clears. New Players columns DisplayEmoji / BannerImageName / PersonalPronouns / IdentityFlags (migration PlayerProfile2023Fields + DatabaseBootstrap patch). Tests: `DorkNet.Server.Tests/ProfileEdit2023Tests.cs`. + ##### `POST account/me/createlogintoken` — SHAPE_MISMATCH (degraded) Client deserializes the response as a BARE JSON string (FGLDKEJLAKB, LBNJFPOLCDL.txt:8382) — server returns an OBJECT { Success, Error, Token, LoginToken, ExpiresAt } (AccountsController.cs:575-582). The strict reader hits '{' where it expects '"' and throws → 'Failed to CreateLoginToken', so every 'open on the web' link (BHKIHEEBBNG builds auth.rec.net URL + token) fails. @@ -433,7 +505,7 @@ All 20 client routes have real, verb-matching handlers on DorkNet (GoToControlle **Client-side notes.** TRANSPORT/HOST: Every route in this subsystem is issued against the matchmaking host: BNDIAONDFFF..ctor(BestHTTP.HTTPMethods verb, GJDLNNLKDIJ host, string route) with host enum value 3 at every call site (BNDIAONDFFF.txt:74 for the signature; host base URLs come from the nameserver response — PAPLNIPKAMG has a NestedType_NameServerResponse). Verb enum is BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT — decoded from the rdx immediate at each ctor call. Params added via BNDIAONDFFF.AFGEDDANEKP are sent as an application/x-www-form-urlencoded (or multipart when binary present) BODY on POST/PUT via HTTPFormBase.AddField (BNDIAONDFFF.txt:3032,3099), and as a ?k=v&k=v QUERY string only on body-less verbs (the \"?\"/\"&\" concat path, BNDIAONDFFF.txt:3444-3489). DorkNet's [FromForm] bindings therefore match. CENTRAL SENDER: All matchmake/* routes except matchmake/none funnel through Matchmaking.HHJFFJEHLOB (Matchmaking.txt:11330, original name GoToHelper): POST, always adds BypassMovementModeRestriction=bool, LoginLock=string, MaxPersistenceVersion=Int32 (from GJGGDBDBCMJ — clamp-relevant, see room-version-clamp work), repeated AdditionalPlayerIds=Int32; a per-route Action closure adds JoinMode / ClientJoinData / AdditionalPlayersAutoFollow / SubRoomId. Response: JSON -> Matchmaking/PCFNLCMMGKB {errorCode:HMIBNIEBEKK enum, roomInstance:HCHDEHIGEBE} -> mapped by <>c.b__114_2 to the HMIBNIEBEKK error value the UI consumes ('Matchmaking request failed ({0}): {1}' on failure). The exact JSON key casing of PCFNLCMMGKB/HCHDEHIGEBE is reflection/attribute-driven and NOT recoverable from ISIL; the camelCase errorCode/roomInstance shape served by DorkNet (GoToController.cs:1529-1620) is verified working live against this exact build (matchmake/dorm boot, room joins, sub-room hops all functioning on the march-2023 branch), so treat it as the wire truth. 2023-vs-2020 MISMATCHES FOUND (both benign, responses ignored, but fix for fidelity): (1) player/notifydisconnect — 2023 sends PlayerId + RoomInstanceId form keys; server binds 2020's otherPlayerId so the logged id is always null (MatchPlayerController.cs:117). (2) player/photonregionpings — 2023 sends one form field per region keyed by REGION NAME (us=42&eu=90...), not the region=/ping= pair arrays the server parses (MatchPlayerController.cs:239+); 2023 pings are silently dropped. Also note verb: 2023 uses PUT here (server already accepts GET/POST/PUT). -HEARTBEAT: response IS parsed (BDDJOGCKIJO); serve the current roomInstance (never null once in a room) and appVersion as the STRING \"20230317\" or the client reports presence out-of-sync / version mismatch. avoidjuniors GET must return a bare JSON boolean primitive. +HEARTBEAT: response IS parsed (BDDJOGCKIJO); serve the current roomInstance (never null once in a room) and appVersion as the STRING \"20230317\" or the client reports presence out-of-sync / version mismatch. The compare (RecNet.Runtime/RecNet/Matchmaking.txt:18415-18467): no cached presence = always resync; cached roomInstance null OR RoomInstanceId < 0 (the offline room, HCHDEHIGEBE.GIICPDHJOOH) = in sync only with roomInstance null; otherwise in sync only when HCHDEHIGEBE.DCIJHIPJHOB finds every field equal. The compare is skipped while TryGoToOfflineRoom's matchmake/none is in flight (static flag +0xF8). A PresenceUpdate push about the LOCAL player runs the same apply path (EGPABEDPEJO -> FLAOOBNJAAD), so presence pushes must never go to their subject. avoidjuniors GET must return a bare JSON boolean primitive. COVERAGE: all 20 routes in groups.json are real HTTP routes, all are already registered on the DorkNet server (server-routes.txt:812-847); no missing endpoints, only the two request-binding mismatches above. #### Endpoints @@ -479,6 +551,8 @@ Handler: `DorkNet.Server/Controllers/Match/MatchPlayerController.cs:168` **Fix.** Persist the value per player (e.g. a field on PlayerPresenceService state or a Players column), read it back in Heartbeat/SetVrMovementMode responses, and honor it in the friend-presence read path. +**Status: FIXED.** Persisted as PlayerSettings `pref:statusVisibility` and echoed on heartbeat (earlier change). The enum is Public=0 / FriendsOnly=1 / FavoriteFriendsOnly=2 / Offline=3 (dump.cs IPLLHDINPBL), set from the profile's "Set Online Status Visibility" dialog. `GET player?{ids}` (PlayerPresenceController) now reports the stored value — to self as well, which the dialog's current selection depends on — and reports a hidden player exactly like an offline one (isOnline=false, roomInstance=null): Offline to everyone but self, FriendsOnly to non-friends (global-friends toggle honored), FavoriteFriendsOnly to callers the player has not favorited. Still open: the SignalR `PresenceUpdate` push (NotificationService.PlayerPresenceChanged) hard-codes statusVisibility 0 and does not filter recipients, so an appear-offline player's room changes still reach friends live. + ##### `PUT player/vrmovementmode` — STUB (degraded) Same pattern as statusvisibility: correct verb and camelCase form key 'vrMovementMode', client ignores the body, but the value is echoed and dropped — Heartbeat always reports vrMovementMode: 0 (line 163). If any server-side matchmaking logic is ever meant to gate on movement mode (the client sends BypassMovementModeRestriction on every matchmake/* call, implying the retail server matched comfort modes), it has no data to work from. @@ -1275,7 +1349,7 @@ Verified all 37 real client HTTP calls against the DorkNet server source (every |---|---|---|---| | GET | `api/avatar/v1/defaultbaseavataritems` | none | JSON array of the same UnlockedAvatarItem shape as v4/items | | GET | `api/avatar/v1/defaultunlocked` | none | Same array shape as v4/items (server aliases it to the same handler) | -| GET | `api/avatar/v1/lockeditems` | Query: repeated key "desc" carrying avatar-item desc strings (list added via helper 0x181F2E250 with key literal "desc") | JSON array; server ships subset {AvatarItemType, AvatarItemDesc, FriendlyName, Tooltip, Rarity} for each requested desc the caller does NOT own | +| GET | `api/avatar/v1/lockeditems` | Query: repeated key "desc" carrying avatar-item desc strings (list added via helper 0x181F2E250 with key literal "desc") | JSON array of List — the same element as v4/items (CLIANCNHNIE reader: AvatarItemType, AvatarItemDesc, FriendlyName, Tooltip, Rarity, TagList, AvatarItemId, IsBaseAvatarItem) for each requested desc the caller does NOT own | | GET | `api/avatar/v2` | none | Object with keys (exact literals in reader EKHJKDNHOPB, case-tolerant Pascal/camel/lower): OutfitSelections(string), OutfitSelectionsV2(string), FaceFeatures(string), SkinColor(string), HairColor(string), CustomAvatarIte | | GET | `api/avatar/v2/gifts` | none | JSON array of gift objects. Server wire (verified live, DorkNet AvatarGiftsController.ToWire): Id(Int64), FromPlayerId(Int64?), ConsumableItemDesc(string), AvatarItemType(Int32), AvatarItemDesc(string, 4-comma-part 'guid | | POST | `api/avatar/v2/gifts/consume/` | POST to the literal URL WITH trailing slash (no id in path). Params: "Id" (boxed Int64 gift id), "UnlockedLevel" (boxed Int32) — sent form-urlencoded (server had to accept form; [F | Gift object echo (server returns ToWire(gift)); client treats non-2xx as 'Gift is missing from the list.' error | @@ -1283,8 +1357,8 @@ Verified all 37 real client HTTP calls against the DorkNet server source (every | POST | `api/avatar/v2/set` | JSON body written by EKHJKDNHOPB writer, PascalCase keys: OutfitSelections, OutfitSelectionsV2, FaceFeatures, SkinColor, HairColor, CustomAvatarItems:[{CustomAvatarItemId, BodyPart | Client fires-and-forgets (void); server echoes AvatarV2Dto | | GET | `api/avatar/v2/{playerId}` | none (player id is bare path segment under v2) | Same HHDLNAPEMGP object as api/avatar/v2 | | POST | `api/avatar/v3/gifts/generate` | Body params: "GiftContext" (boxed Int32), "Message" (string, optional) | Single gift object (same as v2) | -| GET | `api/avatar/v3/saved` | none | JSON array of saved outfits. Server DTO: Slot(Int32), PreviewImageName(string), OutfitSelections(string), FaceFeatures(string), SkinColor(string), HairColor(string). NOTE: client BEPMJICDNDD has Int32 + SIX strings + Lis | -| POST | `api/avatar/v3/saved/set` | JSON body = one BEPMJICDNDD serialised via JsonConvert (ALHIJCJOLCB) then BNDIAONDFFF.FJLLPHFOOJJ; same field set as the GET element | Client ignores body (fire-and-forget task with error toast); server echoes the DTO | +| GET | `api/avatar/v3/saved` | none | JSON array of BEPMJICDNDD. Converter IMMOJHBENFI keys (IMMOJHBENFI.txt:587-910): Slot(Int32), PreviewImageName, OutfitSelections, OutfitSelectionsV2, FaceFeatures, SkinColor, HairColor (strings), CustomAvatarItems([{CustomAvatarItemId, BodyPart}]). Client stores them in a Dictionary keyed by Slot. The client wears OutfitSelectionsV2 when non-empty, else OutfitSelections (BEPMJICDNDD.NLGCNDMLACA) | +| POST | `api/avatar/v3/saved/set` | Raw JSON body (application/json) = one BEPMJICDNDD written by IMMOJHBENFI; same eight keys as the GET element. Verb ordinal 2 (FFFIMAGLKEG.txt:4071-4073). No delete route exists in this client | Client ignores body (status only, LDGADANDBIO); server echoes the outfit | | GET | `api/avatar/v4/items` | none | JSON array. Server DTO (verified live against this client): AvatarItemType(Int32), AvatarItemDesc(string, MUST have >=4 comma-parts 'guid,,,'), AvatarItemId(Int32 — must be unique+non-zero or wardrobe collapses), Platfor | | GET | `api/catalog/v1/all?onlyAvailableSkus=true` | query baked into the literal: onlyAvailableSkus=true | JSON array of SKU objects. Exact client key set not in ISIL (reflection DTO); server ships dual-cased dictionary verified live — required keys include SkuId(Int32 — string here throws FormatException), Slug/Sku, Name, De | | POST | `api/consumables/v1/consume` | JSON body = JsonUtility.ToJson(ConsumableItemRequest); server binds {Id(Int64), DeltaCount(Int32)} (client passes Mathf.Abs of the count) | Server returns {Id, Count}; client parse target is a string callback (Action) — tolerant | @@ -1299,11 +1373,11 @@ Verified all 37 real client HTTP calls against the DorkNet server source (every | DELETE | `api/customAvatarItems/v1/design` | none. SERVER GAP: no DELETE on design — client logs 'Unable to delete custom avatar item design' | status only | | GET | `api/customAvatarItems/v1/featured` | none | JSON array of custom-item objects (server ToWire shape as listed for the create endpoint) | | GET | `api/customAvatarItems/v1/hot` | none | JSON array of custom-item objects | -| GET | `api/customAvatarItems/v1/isCreationAllowedForAccount` | none | Client expects a BARE JSON boolean. LIKELY MISMATCH: server returns object {IsCreationAllowed, Allowed} — should be bare true/false like the sibling endpoints | +| GET | `api/customAvatarItems/v1/isCreationAllowedForAccount` | none | `{"Success": bool, "Error": string|null}` — response type DJMHAFPGLLN (MPBLNLMCEDL.txt:6216-6223), keys from its converter LMCCNLLHLCJ.txt:191-254; the client stores `Success` as can-create (HONEEPBHNLB, :9155-9216). NOT a bare boolean, unlike the two siblings | | GET | `api/customAvatarItems/v1/isCreationEnabled` | none | Bare JSON boolean; server correctly returns Content("true","application/json") | | GET | `api/customAvatarItems/v1/isRenderingEnabled` | none | Bare JSON boolean; server returns bare true | | GET | `api/customAvatarItems/v1/me` | Query params "skip" (boxed Int32), "take" (boxed Int32) | Paged object {Results:[NOAAFNFJPFB...], TotalResults:Int32} (server also emits extra Created/Owned arrays which the client ignores) | -| GET | `api/customAvatarItems/v1/minPriceForPublicItem` | none | Object with one int field; server hedges with {MinPrice:100, Price:100} — one of the two keys presumably matches the client's property name | +| GET | `api/customAvatarItems/v1/minPriceForPublicItem` | none | BARE JSON integer — FDKKOPAPDGF (MPBLNLMCEDL.txt:6922, same generic token as itemOwnershipLimit) continued by `b__69_0(Int32 minimumPrice)`; the IHLMDLKOLNB config is built client-side | | PUT | `api/customAvatarItems/v1/{id}` | JSON body via FJLLPHFOOJJ carrying the changed fields (name/description/price/publish-state). SERVER GAP: only GET is registered on v1/{id:guid}; PUT 405s | Updated NOAAFNFJPFB object | | DELETE | `api/customAvatarItems/v1/{id}` | none (guid in path). SERVER GAP: no DELETE registered — 405 | status only (client fire-and-forget) | | POST | `api/customAvatarItems/v1/{id}/report` | JSON body via FJLLPHFOOJJ with report reason enum + free-text (exact keys UNKNOWN — body serialised from method args). SERVER GAP: no report route registered — 404 | status only | @@ -1360,37 +1434,27 @@ Handler: `none (api-side sibling: DorkNet.Server/Controllers/API/CustomAvatarIte **Fix.** Add [HttpPost("/econ/customAvatarItems/v1/{id:guid}/purchase")] (rooted route, trailing-slash-tolerant) on CustomAvatarItemsController delegating to the existing Purchase(Guid) action; also read the requestedPrice query param and reject/adjust when it differs from item.Price to keep price-display honesty. -##### `POST api/avatar/v2/gifts/generate` — SHAPE_MISMATCH (degraded) +##### `POST api/avatar/v2/gifts/generate` — FIXED -Route and verb exist (v2+v3), response ToWire is correct, and GenerateGiftRequest properties match the client's exact body keys (GiftContext/IsGameGift/AlternateGiftContext/Message). BUT the handler binds [FromBody] GenerateGiftRequest? (line 68), which only the JSON input formatter can read. The client builds this request with the same BNDIAONDFFF.AFGEDDANEKP named-param helper as gifts/consume, and the server's own comment on the consume handler (lines 170-184) documents that this helper produced a form-urlencoded body live and that [FromBody] returned HTTP 415 there. The [Consumes(...form-urlencoded...)] attribute on line 67 does NOT add a form formatter — a form-encoded generate body still 415s and the reward chest never spawns a gift; a JSON body works. PLAUSIBLE (not live-confirmed which encoding generate uses; consume is proven form-encoded, so generate likely is too). +The client's AFGEDDANEKP params go out form-urlencoded; a live trace showed `GiftContext=12000&IsGameGift=True&Message=…` answered 415 while the handler was `[FromBody]`. It now binds through `FormOrJsonModelBinder`. -Handler: `DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs:65` +Handler: `DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs` -**Fix.** In AvatarGiftsController.Generate, drop [FromBody] and read the body the way ConsumeViaBody does: if Request.HasFormContentType read GiftContext/IsGameGift/AlternateGiftContext/Message from the form, else JSON-parse the raw body (case-insensitive), else fall back to query. Keep the response unchanged. +##### `POST api/avatar/v3/gifts/generate` — FIXED -##### `POST api/avatar/v3/gifts/generate` — SHAPE_MISMATCH (degraded) +Same handler and fix as v2 generate. -Same handler as v2 generate ([HttpPost("api/avatar/v3/gifts/generate")] on line 66), so the route/verb the 2023 client uses for reward chests exists — but it shares the exact [FromBody]-vs-form-body risk described for v2 generate (415 on a form-urlencoded body despite the Consumes attribute). +##### `GET api/avatar/v3/saved` — FIXED -Handler: `DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs:66` +SavedOutfitDto now carries `OutfitSelectionsV2` and `CustomAvatarItems`, the two IMMOJHBENFI keys it used to drop. Rows saved before the fix read back with V2 empty, which the client treats as "use OutfitSelections". -**Fix.** Same fix as v2 generate (manual form/JSON/query body reading); one change covers both routes. +Handler: `DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs` -##### `GET api/avatar/v3/saved` — SHAPE_MISMATCH (degraded) +##### `POST api/avatar/v3/saved/set` — FIXED -Route/verb OK (v2/v3/v4) and the array element carries Slot/PreviewImageName/OutfitSelections/FaceFeatures/SkinColor/HairColor with exact PascalCase. But SavedOutfitDto (lines 95-103) has NO OutfitSelectionsV2 and NO CustomAvatarItems, while the client outfit DTO BEPMJICDNDD holds Int32 + SIX strings + List. The 2023 avatar model round-trips OutfitSelectionsV2 + CustomAvatarItems everywhere else (AvatarV2Dto has both), so a saved outfit restores without its V2 selections and without custom shirts — silent data loss on every save slot, no crash (Newtonsoft defaults the missing fields). +The body is parsed by hand, case-insensitively, so all eight keys persist. Before this, loading a saved slot restored the stale V1 selection (or nothing), because the client wears `OutfitSelectionsV2` whenever it is non-empty (BEPMJICDNDD.NLGCNDMLACA), and every custom shirt in the slot was lost. -Handler: `DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs:29` - -**Fix.** Add [JsonPropertyName("OutfitSelectionsV2")] string and [JsonPropertyName("CustomAvatarItems")] List to SavedOutfitDto (reuse CustomAvatarItemRefDto from AvatarV2Controller). Existing persisted JSON deserializes fine (missing props default). - -##### `POST api/avatar/v3/saved/set` — SHAPE_MISMATCH (degraded) - -Route/verb OK, [FromBody] binds the client's JSON body, upsert-by-Slot works and echoes the DTO. Same defect as the GET: [FromBody] SavedOutfitDto silently DROPS the client's OutfitSelectionsV2 string and CustomAvatarItems list at bind time (System.Text.Json ignores unknown members), so those parts of the outfit are never persisted. Also note SavedOutfitDto's non-nullable value binding is fine here (all strings + int). - -Handler: `DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs:42` - -**Fix.** Same DTO extension as the GET; no handler change needed. +Handler: `DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs` ##### `GET api/consumables/v1/getTransferable/{playerId}` — MISSING (degraded) @@ -1468,13 +1532,11 @@ Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsCont **Fix.** Add [HttpDelete("design")] that removes the caller's design row and returns Ok(). -##### `GET api/customAvatarItems/v1/isCreationAllowedForAccount` — SHAPE_MISMATCH (degraded) +##### `GET api/customAvatarItems/v1/isCreationAllowedForAccount` — FIXED -Handler returns the OBJECT {IsCreationAllowed, Allowed}, but the client method returns FGLDKEJLAKB — it parses the body as a BARE JSON boolean. Object-into-Boolean deserialization fails, so the can-create gate errors (likely read as not-allowed) and shirt creation is blocked at the UI gate. CONFIRMED from both sides. The two sibling flags on lines 209-215 already do it correctly with Content("true","application/json"). +The first audit read the method's `FGLDKEJLAKB` return type as the wire type and switched the body to a bare boolean. That was wrong: the request is sent as FDKKOPAPDGF (MPBLNLMCEDL.txt:6216-6223) and the boolean is projected client-side from field +0x10 (HONEEPBHNLB, :9155-9216). DJMHAFPGLLN's converter LMCCNLLHLCJ registers only `Success` and `Error` (LMCCNLLHLCJ.txt:191-254). The bare `true` failed that parse, and the client logged `Failed to query if local account can create Custom Avatar Items: An error occurred`. The server now returns `{"Success": allowed, "Error": null|reason}`. -Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:194` - -**Fix.** Return Content(allowed ? "true" : "false", "application/json") from IsCreationAllowedForAccount. +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs` ##### `GET api/ugcPurchasables/v1/items/room/{roomId}` — MISSING (degraded) @@ -1484,13 +1546,11 @@ Handler: `DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesControll **Fix.** Add [HttpGet("api/ugcPurchasables/v1/items/room/{roomId:long}")] mapped to the existing List logic (roomId from route instead of query). -##### `GET api/customAvatarItems/v1/minPriceForPublicItem` — UNKNOWN (none) +##### `GET api/customAvatarItems/v1/minPriceForPublicItem` — FIXED -Route/verb OK; server hedges with {MinPrice:100, Price:100}. Client DTO IHLMDLKOLNB is an object with a single Int32 property whose JSON key is not recoverable from ISIL (reflection DTO) — whether either hedge key matches is UNKNOWN. If neither matches, the client silently falls back to the DTO's static default rather than crashing (int defaults to 0 under Newtonsoft missing-key semantics), so worst case is a wrong minimum-price validation, not a crash. +The wire is a bare Int32, not an IHLMDLKOLNB object. HCBOHHEACPK sends FDKKOPAPDGF (MPBLNLMCEDL.txt:6922, the same generic token as the bare-Int32 itemOwnershipLimit call at :2981), and the continuation is `b__69_0(Int32 minimumPrice)` (MPBLNLMCEDL_NestedType___c.txt:286). The server now returns a bare `100`. -Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:217` - -**Fix.** None provable. If live testing shows the min-price gate misbehaving, dump the real key from a live Newtonsoft trace or the metadata name in the decompile and align. +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs` ### Clubs, threads, chat and comments @@ -3371,7 +3431,7 @@ Trigger-verification caveat: gameplay-flow attributions are grounded in the clie | GET | `api/config/v1/cohortnux/{cohortId}` | none (cohort id in path, Int32) | JSON array of {ButtonNumber:Int32, Version:Int32 (enum MDPAPEEJFED), Override:Int32 (enum IAEGEKLOMHF), CustomTitle:String, CustomDescription:String, CustomRoomName:String, DefaultTitle:String, DefaultDescription:String, | | GET | `api/config/v1/freegiftbutton` | none | bare JSON boolean: true\|false | | GET | `api/config/v2` | none | Object with exactly 8 recognized keys (each accepted in PascalCase/camelCase/lowercase): LevelProgressionMaps: [{Level:Int32, RequiredXp:Int32, GiftDropId:Int32?}], DailyObjectives: OIMDBINAEGE[][] i.e. array-of-arrays o | -| GET | `api/gameconfigs/v1/all` | none | JSON array of {Key:String, Value:String, StartTime:DateTime? (ISO8601), EndTime:DateTime?} — tri-casing accepted | +| GET | `api/gameconfigs/v1/all` | none | JSON array of {Key:String, Value:String, StartTime:DateTime? (ISO8601), EndTime:DateTime?} — tri-casing accepted. Carries the `UGC.CircuitsV2.Limit.*` family that fills `RecRoom.CircuitsV2.Config.LimitConfig`, including the per-frame RPC/byte budgets — see `docs/recroom-2023-circuitsv2-limits.md` | | GET | `api/testcasemanagement/v1/testcase/{id}` | none (string id concatenated into path) | {Id:String, Key:String, Title:String, Description:String, RoomName:String, Status:Int32 (enum LIHDCCJNKGC), AssignedPlayerNames:[String], Tags:[String], JiraUrl:String, Comments:[{Comment:String, CreatedAt:DateTime}]} — | | POST | `api/testcasemanagement/v1/testcase/{id}/claim` | none (empty body) | none consumed (BNDIAONDFFF.KDOPJCNKOOK = fire, ignore body); 2xx status only | | POST | `api/testcasemanagement/v1/testcase/{id}/comment` | raw JSON body = JsonConvert.SerializeObject(comment) → a JSON string literal, e.g. "\"my comment\"" | none consumed; 2xx status only | diff --git a/docs/recroom-2023-room-save.md b/docs/recroom-2023-room-save.md index 2ec4075..3b65234 100644 --- a/docs/recroom-2023-room-save.md +++ b/docs/recroom-2023-room-save.md @@ -132,6 +132,74 @@ role-holder (co-owner/mod/host), nor an instance invitee return `errorCode=4` (RoomDoesNotExist) so private rooms don't leak their existence. Tests: `PrivateRoomInstanceTests.cs`. +## Baked Rec Room Originals and data blobs (Rec Royale) + +Baked RRO rooms are seeded with empty `DataBlobName` / `CurrentDataBlobName`, +which the details builders and matchmaking resolvers pass through as an +empty `DataBlob`. The 2023 client treats that as "no room data": it never +downloads a blob, so the room has no room roles (no Maker Pen even though +the CDN overlays permissive roles for AG rooms), no CV2 room-settings +chips, no mood engine and nothing to save into. `IsRRO` (= `IsAGRoom`) is +independent of the blob, so a baked room CAN carry one: the client loads +the baked map from its own assets and layers the blob's persistence views +on top — exactly how Rec Center behaves. + +Rec Royale needs this to be a game at all: the Frontier scene has no baked +game rule (contrast `PaintballTeamBattle_GameRuleWrapperCircuit`, which is +baked into the paintball scene). Its rule is the Maker Pen chip +`RecRoyaleSolos_GameRuleWrapperCircuit` / `RecRoyaleSquads_GameRuleWrapperCircuit` +(`BattleRoyaleGameRuleWrapper : GameRuleCircuitWrapperTool`), which real +Rec Room delivered inside the room blob. Without it there is no +`BattleRoyaleManager` in the scene. + +Stub shape matters. Until something is saved the CDN synthesises the blob, +and the client fetches it through `RecNet.Rooms.GetRoomData` and rejects +one it deems unusable — a clone surfaces that as the bare "Failed to copy +room", a baked room as a silent load with no room roles (no Maker Pen). +Compared against Rec Center's working `room_100_v1.dat`, the old stub was +stamped `PersistedRoomVersion` 19 (client ceiling 16; only the S3-hit path +clamped) and lacked the save skeleton (`last_save_time`, `activity_id` = +LocationReplicationId, connectable graph, scene settings, object model, +`room_mood_*`). `RoomDataBlobService.BuildRroEditableBlob` now emits that +skeleton per room at version 16. + +Second requirement — game-role objects. Both reference saves carry three +`PersistenceViewData` entries whose `game_role_node_data` (field 50) holds a +`PlayerGameRoleData`: the room's default role (Rec Center: "Rec Center", +Stunt Runner: "StuntRunner"; GUID `c48bac02-…` in both), "In-Game" +(`role_id` 2097152, GUID `2c721342-…`) and "Eliminated" (`role_id` +4194304, rank 1015, `can_move` false, GUID `0b9ec39e-…`). Their +`spawnable_tool_data.prefab_id` is the GameRoleNode chip prefab +(`c46e1535…` raw). Since 2023 permissions hang off the player's GameRole, +a blob without these leaves a co-owner with no Maker Pen. The stub now +generates Rec Royale's own set (view ids derived from the activity id). + +Game-rule chip. A newer-client export of a custom Rec Royale room showed +the saved `RecRoyaleSquads_GameRuleWrapperCircuit` chip is minimal: +`spawnable_tool_data.prefab_id` `857f7500…`, `tagged_tool_data` tag +`recroyalesquadsgameruleci` (prefab name lower-cased, 25 chars), +`circuit_node_data.id`, and an EMPTY `game_rule_wrapper_data` (field 41). +Prefab ids live in the client's spawnable-tool table in +`resources.assets`: after `SpawnableTool_Runtime_` comes a 20-byte +hash (len-prefixed), two u32s, then the 16-byte prefab id (len-prefixed), +then the enum again, display name and tag. Extracted: +Solos `983db81a68927f419edb0daf1243e93e`, Squads +`857f750016ca77408ae9f72f9877748a` (matches the save), StuntRunner +`28276d92470ff34ab05f72b2707a04d1`. Stunt Runner's other rule-ish object +(`84eb8434…`, `game_configuration_data` field 40) is the generic Game +Configuration chip, optional — `GameConfigurationTool` falls back to its +baked asset. The stub emits the wrapper chip for the two Rec Royale rooms. + +Attaching a real save: `POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/upload` +(multipart `file`, `?mode=basics|full`) rewrites any-version +`PersistedRoomData` for the room — see `docs/admin.md` → "Upload save". +Used to A/B a generated stub against a genuine custom-Rec-Royale save. + +Admin switch: `POST api/admin/v1/rooms/{id}/subrooms/{sub}/blob/enable` +stamps `RoomService.SyntheticDefaultRoomDataBlobName(id)` on the scene and +room; `.../blob/reset` clears it. See `docs/admin.md` → "RRO room data +blob" for the full procedure (enable → spawn the game-rule chip → save). + ## Related 2023 quirks fixed alongside - **Play-menu search** (`IBEOONPEELF.SearchRooms`): calls diff --git a/docs/recroom-2023-scene-moods.md b/docs/recroom-2023-scene-moods.md new file mode 100644 index 0000000..bf176e3 --- /dev/null +++ b/docs/recroom-2023-scene-moods.md @@ -0,0 +1,223 @@ +# Rec Room 2023-03-21 — scene moods (sky dome / sun / fog) and Rec Royale night mode + +Client-side reference for the room "mood" engine the March-2023 client uses +for Maker Pen sky/sun/fog settings, and how `DorkNet.ClientMod` +(`RecRoyaleNight.cs`) reuses it to give Rec Royale a night variant the game +never shipped. Everything below was read from the Cpp2IL ISIL dump +(`C:\tmp\recroom-2023-03-21-isil`), the interop stubs +(`C:\tmp\recroom-2023-03-21-decompiled`) and the built game data +(`RecRoom_Data\resources.assets`, `globalgamemanagers`). + +## Ownership chain + +| Object | Where | Notes | +|---|---|---| +| `RecRoomSceneManager` | `MonoBehaviourPun`, one per room scene | static `get_Instance` (interop property name obfuscated: `PDCOCJBICGC`); name-preserved field `sceneMoodSettings` | +| `RecRoom.Core.Scene.SceneMoodSettings` | plain class held by the scene manager | initialised by `PCJGOACJEDL(RecRoomSceneManager)` at scene start | +| `RecRoom.Core.Creation.SceneMoods.MoodSetting` | `ScriptableObject` asset | `FriendlyName`, `SkyBoxSettings`, `SunSettings`, `FogSettings`, `BloomSettings`, `ColorGradingSettings`, `BackgroundObjectsSettings`, `VolumeProfile`, `BakedReflection`, `guid` | + +`SceneMoodSettings` name-preserved members: `availableMoods` +(`MoodSetting[]`), `defaultMood`, `CurrentMood`, `PrevMood`, +`CurrentRoomMoodIndex`, `SupportsMoodCircuits`, `HasRecRoomSkyboxShader`, +`HasSunSource`, `Volume`, `DefaultVolumeProfile`, `ReflectionIntensity`, +`AmbientLightIntensity`, plus one `PFFMDAJPHJO` blender per settings +block (`SkyBoxMoodSettingsBlender`, `SunMoodSettingsBlender`, …). + +## Apply path (what actually changes the picture) + +`SceneMoodSettings.MLLBALMKEGJ(MoodSetting)` — ISIL +`RecRoom/Core/Scene/SceneMoodSettings.txt:3025-3581`: + +1. `ALCMNGKOEBK()` — pushes the mood's six settings blocks into the blenders + as the "top" data (6× generic blender set call). +2. `OEKNGEMDMDB(VolumeProfile)` — swaps the URP post-processing profile. +3. `RenderSettings.set_skybox` — the shared Rec Room skybox material; the + `SkyBoxMoodSettings` blender then writes `_SkyTopColor`-style shader + props into it (`SkyBoxMoodSettings.FPKCBLPBOFN(Material)`). +4. `RenderSettings.get_sun` → re-aims/recolours the sun (`SunMoodSettings`, + only when `HasSunSource`). +5. `ReflectionProbe.set_customBakedTexture` ← `MoodSetting.BakedReflection`. +6. 6× blender force-update. + +It does **not** write `CurrentRoomMoodIndex`, so calling it is local only. + +### RRO scenes: the engine starts disabled + +`SceneMoodSettings.isEnabled` (+0x38) gates everything: `get_CurrentMood` +returns null while it is false, `get_RoomMoodsEnabled` is +`isEnabled && defaultMood != null`, and nothing ticks. The flag is only +switched on by the room data-blob loader (`EIMFCPIHEPF.JPKEMACDNCF`, ISIL +`EIMFCPIHEPF.txt:2136-2310`), which custom rooms run and RRO scenes (no +data blob) never do. Its bring-up sequence: + +| Call | Meaning | +|---|---| +| `LKIKMLNPCFK(false)` | SetEnabled(false): applies a null mood (blenders reset, default VolumeProfile back) and toggles `BackgroundObjectsRoot` | +| `LIEDFOLBGHL()` | Capture: builds the blenders' "previous" data from `RenderSettings.skybox` / `sun` / fog; sets `hasRecRoomSkyboxShader` = skybox shader name `== "Custom/SkyBox"` | +| `LKIKMLNPCFK(true)` | SetEnabled(true) | + +The apply step then swaps `RenderSettings.skybox` to the global +`MoodConfig` skybox material (`LNMDIIFOFPD.NHAEHKJINFH`, +0x18). +The ClientMod replays this exact sequence when it finds `isEnabled` false. + +**The apply step only sets blend targets.** The per-frame update +`CEKLEBHDAHA` (ISIL :3895-4031) is what actually writes sky colours, +rotates/recolours the sun, blends fog and background objects and (via +`CPJBHLMCBBC`) pushes bloom/colour grading into the Volume — and it +returns immediately while `get_CurrentMood()` is null. `get_CurrentMood` +(ISIL :297-457) returns null when `isEnabled` is false, else +`_studioRoomUnityMood` if set, else `availableMoods[index]` when the +synced index is set, else `defaultMood`. RRO scenes have `defaultMood` +null and `availableMoods` empty, so even a fully enabled engine with a +successful apply shows only the ungated background objects (moon, stars). +The ClientMod therefore writes the night mood into `defaultMood` and a +one-element `availableMoods` before enabling the engine. + +Two flags captured by `LIEDFOLBGHL` gate what the apply step and the +per-frame update are allowed to touch: + +| Flag | Set by capture from | Gates | +|---|---|---| +| `hasRecRoomSkyboxShader` (+0xA8) | `RenderSettings.skybox.shader.name == "Custom/SkyBox"` | sky colour blender apply | +| `hasSunSource` (+0xA9) | `RenderSettings.sun != null` | sun direction / colour / intensity | + +Note the sun flag is nested inside the sky check: `hasSunSource` can only +become true when the skybox shader test already passed (ISIL :2629-2705). + +`RecRoyale_Frontier` fails both at capture time: its skybox is not the +mood shader. The observable result of a naive apply is exactly "moon and +stars appear, sky and sun unchanged" — the mood's +`BackgroundObjectsSettings` are not gated. + +Worse, the apply step's own skybox swap does not help in RRO scenes: it +assigns `MoodConfig.SkyBoxMaterial`, and that material +(`Skybox_Moods_Sunny_Default_Mat`, sharedassets77.assets — a custom-room +scene's shared assets) is not loaded there, so `RenderSettings.skybox` +becomes null (plain black sky) and both flags stay false forever. The +`Custom/SkyBox` shader itself is in resources.assets, so +`new Material(Shader.Find("Custom/SkyBox"))` works anywhere. + +Workarounds used by the ClientMod: assign the scene's directional `Light` +to `RenderSettings.sun` if unset; apply once; then put a material on the +`Custom/SkyBox` shader into `RenderSettings.skybox` and +`MoodConfig.SkyBoxMaterial`; capture again so both flags flip true; then +apply for real. Post-exposure additionally needs the scene `Volume` +enabled with weight 1 and `UniversalAdditionalCameraData.renderPostProcessing` +on (`RecRoyaleNightForcePostProcessing`). `LILOMACBFPI()` (:2789-2817) force-sets +both flags to true (writes 0x0101 at +0xA8) and clears +`_studioRoomUnityMood`; it is the Studio path and not used here because +a true `hasSunSource` with a null sun would NRE in the update loop. + +Fog: `FogMoodSettings.NLDDIDEIEFH()` reads `RenderSettings.fog`, +`fogStartDistance`, `fogEndDistance`, `fogColor` into the block; +`KGBEBONMIII()` writes them back. Swapping `MoodSetting.FogSettings` for +a freshly captured block before the first apply keeps the scene's fog +(`RecRoyaleNightFog: false`). The block exposes only `color`, +`startDistance`, `fadeDistance` (no enabled flag), so "fog off" +(`RecRoyaleNightDisableFog`) pushes both distances to 1e6 and forces +`RenderSettings.fog = false` after each apply. + +Frontier's daytime clouds are scene meshes, not mood background objects: +`CloudPlane` ×19 under `Clouds`, static-batched as +`StaticObjectsStaticBatch_Cloud_RecRoyale_Frontier_Mat` (level106). +`RecRoyaleNightHideObjects` disables renderers whose object or +`sharedMaterial` name contains a pattern (default `Cloud`). + +Brightness: the mood blocks are plain data the ClientMod edits in place +before the apply (originals restored on scene exit / toggle-off): + +| Block · field | Applied as | ClientMod knob | +|---|---|---| +| `ColorGradingMoodSettings.exposure` | `ColorAdjustments.postExposure` via `VolumeParameter.Override` in `KGBEBONMIII` | `RecRoyaleNightExposure` (EV offset) — the only knob that dims baked lightmaps | +| `SunMoodSettings.sunIntensity` | `RenderSettings.sun.intensity` (needs `hasSunSource`) | `RecRoyaleNightSunScale` (×) | +| — | other directional `Light`s (never touched by the engine; Frontier's `RenderSettings.sun` is `SkyboxLight`, the sun-disc light, not the key light) | `RecRoyaleNightMatchSunLights`: copy the driven sun's rotation/colour, intensity × `RecRoyaleNightSunScale` | +| `SkyBoxMoodSettings.ambientLightIntensity` | `RenderSettings.ambientIntensity` | `RecRoyaleNightAmbientScale` (×) | + +Other `ColorGradingMoodSettings` fields: `color`, `temperature`, `tint`, +`hueShift`, `saturation`, `contrast`, `colorFilterIntensity`. +`BloomMoodSettings`: `color`, `diffusion`, `intensity`, `threshold`. + +### Networked path (do not use for a client-side effect) + +`SceneMoodSettings.IPAMPMBDPNB(Guid)` scans `availableMoods` for the GUID +and calls `set_CurrentRoomMoodIndex`. `_currentRoomMoodIndex` is a +`DFAOKLDJPEL` from `RecRoom.Networking.SynchronizedFields.Runtime`, +i.e. a synchronized field that replicates to everyone in the instance. +CircuitsV2 room-settings chips (`RecRoom.CircuitsV2.RoomSettings.RoomSkydome`, +`RoomSun`, `RoomFog`, all `RoomMoodBase`) drive the same engine +through `ModifyMood(...)` and are also networked. + +## Shipped mood assets + +All live in `resources.assets` and are registered in the `globalgamemanagers` +ResourceManager table under their lower-cased asset name (no folder), so +`Resources.Load("", typeof(MoodSetting))` works. + +| Asset | `FriendlyName` | +|---|---| +| `Night_Calm_Outdoor_Mood` | Calm Night | +| `Night_Spooky_Outdoor_Mood` | Spooky Night | +| `Night_Wild_Outdoor_Mood` | Wild Night | +| `Night_StuntRunner_Outdoor_Mood` | (Stunt Runner night) | +| `OuterSpace_Mood` | Outer Space | +| `Sunset_Cozy_Outdoor_Mood` | Cozy Sunset | +| `Sunset_Burning_Mood` | Burning Sunset | +| `Sunset_Tropical_Outdoor_Mood` | Tropical Sunset | +| `Sunrise_Misty_Outdoor_Mood` | (misty sunrise) | + +Reflection cubemaps ship alongside (`Moods_WildNight_ReflectionProbe`, +`Moods_SunnyDay_Skybox_Reflection_Baked`, …) and are referenced from each +`MoodSetting.BakedReflection`. + +## Rec Royale specifics + +* Only one Rec Royale map in this build: + `Assets/Activities/RecRoyale/Maps/RecRoyale_Frontier/RecRoyale_Frontier.unity` + (Unity scene name `RecRoyale_Frontier`). The `"recroyale"` literal in + `BattleRoyaleManager` is only `get_RuleTypeAutoTag`, not a scene name. +* `BattleRoyaleManager` has no static instance accessor; scene-name + matching via MelonLoader `OnSceneWasLoaded` is the detection choke point. +* Weapon flashlights already exist: `WeaponFlashlightController` (fields + `weapon`, `flashlightPrefab`, `flashlightRoot`) spawns + `WeaponFlashlight` when `get_FlashlightAllowed` is true. That getter + (ISIL `WeaponFlashlightController.txt:3-75`) is + `Matchmaking.FBELDNLEKAC() != null && [obj+0x28] == 15` — a game-mode + gate for a different activity. Interop property name: `KIMMMCCKMJL`. + +## Real CV2 mood chips in an RRO scene + +The Maker Pen chips ("Skydome Constant", "Room Skydome Modify/Reset", and +the Sun / Fog / Background Objects trios) drive `RoomSkydome`, `RoomSun`, +`RoomFog`, `RoomBackgroundObjects` (`RecRoom.CircuitsV2.RoomSettings`, +all `RoomMoodBase`), which hang off `RoomElementSettingsManager` (a +`NetworkedSingletonMonoBehaviour` that `CircuitsV2Manager` brings up in +`MPDIMHOMIDP`). Their state persists in the room blob +(`PersistedRoomData` fields 32-35: `room_fog_data`, `room_mood_sun_data`, +`room_mood_background_objects`, `room_mood_skydome`). None of that runs in +a room the client believes has no data blob, so the prerequisite for chips +in Rec Royale is server-side: give the room a blob (`docs/admin.md` → "RRO +room data blob"). The ClientMod probe line +`[royale-night] CV2 probe in 'RecRoyale_Frontier': …` logs instance counts +for `CircuitsV2Manager`, `RoomElementSettingsManager`, `RoomSkydome`, +`RoomSun`, `RoomFog`, `RoomBackgroundObjects` and `MakerPen` so the effect +of the blob can be verified in one join. + +## How the ClientMod uses this + +`DorkNet.ClientMod/RecRoyaleNight.cs`: + +1. `OnSceneWasLoaded` → scene name contains any `RecRoyaleNightScenes` + entry (default `RecRoyale`) ⇒ arm. +2. `OnUpdate` every 30 frames: `RecRoomSceneManager.Instance.sceneMoodSettings` + present and `CurrentMood` non-null (engine initialised) ⇒ + `Resources.Load(RecRoyaleNightMood, MoodSetting)` ⇒ + `MLLBALMKEGJ(mood)`. Re-asserted every ~10 s (the engine re-applies its + own mood on quality-level changes). +3. Hotkey (`RecRoyaleNightToggleKey`, a `UnityEngine.KeyCode` name) toggles; + off re-applies `CurrentMood`, i.e. the scene's own default. +4. Harmony postfix on `get_FlashlightAllowed` returns `true` while active + and in a Rec Royale scene (`RecRoyaleNightFlashlights`). + +Limits: baked lightmaps are untouched (interiors keep their daytime bake); +darkening comes from the mood's colour grading, sun and ambient settings. +Purely per-client — other players see whatever their own client applies. diff --git a/tools/install-melon.ps1 b/tools/install-melon.ps1 index 0ca2307..b439872 100644 --- a/tools/install-melon.ps1 +++ b/tools/install-melon.ps1 @@ -447,6 +447,35 @@ $cfg = [ordered]@{ DesktopScreenShareRaisePhotonRate = $true DesktopScreenShareResolution = 0 DesktopScreenShareQuality = 0 + # Rec Royale night mode (client-side visual). Applies one of the game's + # shipped night MoodSetting assets (sky dome, sun, fog, colour grading) + # to the Rec Royale map and unlocks weapon flashlights. Moods: + # Night_Calm_Outdoor_Mood, Night_Spooky_Outdoor_Mood, + # Night_Wild_Outdoor_Mood, Night_StuntRunner_Outdoor_Mood, OuterSpace_Mood. + # ToggleKey is an optional UnityEngine.KeyCode name (e.g. 'N') to flip it + # in-game; it also arms the feature when NightMode is $false. + RecRoyaleNightMode = $false + RecRoyaleNightMood = 'Night_Calm_Outdoor_Mood' + RecRoyaleNightScenes = @('RecRoyale') + RecRoyaleNightFlashlights = $true + RecRoyaleNightFog = $false # $true = also apply the mood's fog + # Darkening: EV offset added to the mood's post-exposure (dims baked + # lightmaps too), and multipliers on the mood's sun / ambient intensity. + RecRoyaleNightExposure = -3.5 + RecRoyaleNightSunScale = 0.15 + RecRoyaleNightAmbientScale = 0.2 + # Post-exposure needs the scene Volume on and camera post-processing + # enabled; force both while night mode is active. + RecRoyaleNightForcePostProcessing = $true + # The engine only drives RenderSettings.sun; match every other + # directional light's rotation/colour to it and scale its intensity. + RecRoyaleNightMatchSunLights = $true + # Fog off entirely while night is on, and renderers whose object or + # material name contains any of these are hidden (Frontier's daytime + # cloud meshes). Empty array = hide nothing. + RecRoyaleNightDisableFog = $false + RecRoyaleNightHideObjects = @('Cloud') + RecRoyaleNightToggleKey = '' } Write-Step "Writing mod config -> $cfgPath" [System.IO.File]::WriteAllText($cfgPath, ($cfg | ConvertTo-Json -Depth 4))