Merge pull request #55 from DorkSquadRR/feat/cv2-network-limits

2023 client: presence, profile, outfits, RecNetResult writes, CircuitsV2 limits
This commit is contained in:
Alexa
2026-10-15 14:10:25 +03:00
committed by GitHub
78 changed files with 18502 additions and 517 deletions
+92 -3
View File
@@ -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<string>();
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<string>();
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;
+1401
View File
@@ -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<int>, 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) ? "<none>" : 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) ? "<null>" : 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) ? "<null>" : 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<LightState> _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<string>();
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<MoodSetting>; 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<object> _hidden = new();
private static bool _hideScanned;
private static void HideObjects()
{
if (_hideScanned) return;
_hideScanned = true;
try
{
var patterns = new List<string>();
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<string>();
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<string>();
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)}=<type missing>"); 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 "<null>";
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 ? "<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<string>();
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<string>();
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 });
}
}
@@ -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": ""
}
+37
View File
@@ -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;
}
/// <summary>Pronoun flags enum, serialized as its Int32 value.</summary>
[JsonPropertyName("personalPronouns")]
public int PersonalPronouns { get; set; }
/// <summary>Identity flags enum, serialized as its Int32 value.</summary>
[JsonPropertyName("identityFlags")]
public int IdentityFlags { get; set; }
}
/// <summary>
@@ -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;
}
/// <summary>
@@ -0,0 +1,254 @@
using DorkNet.Server.Services;
using System.Net.Http.Json;
using System.Text.Json;
namespace DorkNet.Server.Tests;
/// <summary>
/// The March-2023 client budgets every CircuitsV2 network send against
/// <c>RecRoom.CircuitsV2.Config.LimitConfig</c>, which it fills from the
/// <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. Retail ships 5 RPCs per
/// frame; past that the send is dropped and the room save is flagged
/// <c>cv2_rpc_limited</c>. 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.
/// </summary>
public sealed class CircuitsV2LimitsTests : IClassFixture<DorkNetServerFactory>
{
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<List<GameConfigurationSetting>>(
"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<GameConfigurationSetting> Emit(CircuitsV2LimitSettings limits) =>
ServerSettingsService.ToGameConfigurations(
ServerSettingsService.DefaultRecCenterDoors(),
ServerSettingsService.DefaultDiscoveredGameConfigs(),
limits);
private static string ValueOf(IReadOnlyList<GameConfigurationSetting> 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<Chip>(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);
}
}
@@ -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<EndpointProbeResult> ProbeAsync(
@@ -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;
/// <summary>
/// <c>GET api/incentivizedreferrals/referrals?take=&amp;continuationToken=</c> 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: <c>{Referrals:[{InviteeAccountId, CreatedAt, VerifiedAt}],
/// ContinuationToken}</c> (KOEEACLMAFE.txt:203-238, ADAFNGNADFH.txt:279-346).
/// </summary>
public sealed class IncentivizedReferralsTests : IClassFixture<DorkNetServerFactory>
{
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<DorkNetDbContext>();
// 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;
}
}
+222
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class OutfitMenu2023Tests : IClassFixture<DorkNetServerFactory>
{
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);
// <GetPricingConfig>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<string, object?>
{
["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<object>();
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<string, string>
{
["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<string> owned;
await using (var scope = _factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var inv = await db.Avatars.Where(a => a.PlayerId == player.PlayerId)
.Select(a => a.InventoryJson).FirstAsync();
owned = (JsonSerializer.Deserialize<List<string>>(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<int>();
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<DorkNetDbContext>();
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
var inv = JsonSerializer.Deserialize<List<string>>(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<JsonElement> 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();
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class PresenceTransitionTests : IClassFixture<DorkNetServerFactory>
{
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<long> { 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<HttpClient> 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<long> PlayerIdOfAsync(HttpClient client)
{
var beat = await HeartbeatAsync(client);
return beat.GetProperty("playerId").GetInt64();
}
private static async Task<string> 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<JsonElement> 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<JsonElement> 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<string, string>(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;
}
}
@@ -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;
/// <summary>
/// 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 &lt;suffix&gt;", 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.
/// </summary>
public sealed class ProfileEdit2023Tests : IClassFixture<DorkNetServerFactory>
{
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<DorkNetDbContext>();
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<PlayerPresenceService>();
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<JsonElement> 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<HttpResponseMessage> PutFormAsync(HttpClient client, string path, string field, string value)
=> await client.PutAsync(path, new FormUrlEncodedContent(new Dictionary<string, string> { [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<JsonElement> 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();
}
}
@@ -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;
/// <summary>
/// Writes whose client method returns the untyped promise (<c>LDGADANDBIO</c>)
/// but whose reply is still parsed — as <c>DJMHAFPGLLN</c>, the
/// <c>{Success, Error}</c> RecNetResult — by <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
/// (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 <c>Success</c> parses as a failure whose <c>Error</c> is null,
/// and <c>Promise.Rejected(null)</c> 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).
/// </summary>
public sealed class RecNetResultEnvelopeTests : IClassFixture<DorkNetServerFactory>
{
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<String> LGKLFFPACGL(): a JSON string, not an object.
var token = JsonSerializer.Deserialize<string>(raw);
Assert.False(string.IsNullOrEmpty(token));
using var anon = Client("auth");
var form = new Dictionary<string, string>
{
["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<string, string>
{
["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);
}
/// <summary>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.</summary>
[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<long> SeedOpenClubAsync(long ownerId)
{
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
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<long> 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;
}
}
+448
View File
@@ -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;
/// <summary>
/// Baked Rec Room Originals (Rec Royale, Paintball, …) ship with an empty
/// <see cref="RoomSceneEntity.DataBlobName"/>, and every details builder and
/// matchmaking resolver turns that into an empty <c>DataBlob</c>. 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 <c>IsRRO</c> 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.
/// </summary>
public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
{
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<DorkNetDbContext>();
(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());
}
/// <summary>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.</summary>
[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<DorkNetDbContext>();
(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());
}
/// <summary>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).</summary>
[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<DorkNetDbContext>();
(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));
}
/// <summary>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
/// <c>basics</c> mode only the role + rule objects kept with circuits
/// stripped. The room then loads that blob (with the CDN's role overlay).</summary>
[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<DorkNetDbContext>();
(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<DorkNetDbContext>();
(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());
}
/// <summary>Count top-level <c>persistence_views</c> (field 2) entries by
/// walking the wire format — our server proto doesn't type field 2.</summary>
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<JsonElement> 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<JsonElement> 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;
}
}
@@ -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/<id>, 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
Can't render this file because it contains an unexpected character in line 532 and column 73.
@@ -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));
}
/// <summary>
@@ -50,7 +50,18 @@ public class AvatarGiftsController(
return Ok(rows.Select(ToWire));
}
public sealed record GenerateGiftRequest(int? GiftContext, bool? IsGameGift, int? AlternateGiftContext, string? Message);
/// <summary>Body keys are the client's AFGEDDANEKP param literals
/// (GiftContext / IsGameGift / AlternateGiftContext / Message). A class
/// with settable properties, not a positional record, because
/// <see cref="Binding.FormOrJsonModelBinder"/> constructs it empty and
/// fills it.</summary>
public sealed class GenerateGiftRequest
{
public int? GiftContext { get; set; }
public bool? IsGameGift { get; set; }
public int? AlternateGiftContext { get; set; }
public string? Message { get; set; }
}
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — 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<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
public async Task<IActionResult> Generate(
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] GenerateGiftRequest? req)
{
var pid = this.RequireCurrentPlayerId();
var isGameGift = req?.IsGameGift ?? false;
@@ -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;
/// <summary>
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Client wire
/// type BEPMJICDNDD, whose converter IMMOJHBENFI registers exactly
/// <c>Slot, PreviewImageName, OutfitSelections, OutfitSelectionsV2,
/// FaceFeatures, SkinColor, HairColor, CustomAvatarItems</c>
/// (RecNet.Runtime/IMMOJHBENFI.txt:587-766 reader, :781-910 writer).
/// CustomAvatarItems is a list of BGNNOMBFMLH
/// <c>{CustomAvatarItemId, BodyPart}</c>, the same element the avatar
/// settings (<c>api/avatar/v2</c>) use.
///
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
@@ -38,12 +43,27 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
}
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
/// Body is the SavedOutfit shape directly.</summary>
/// 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 <c>[FromBody] SavedOutfitDto</c>: 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.</summary>
[HttpPost("api/avatar/v3/saved/set")]
[HttpPost("api/avatar/v2/saved/set")]
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
public async Task<IActionResult> 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();
}
/// <summary>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
/// <c>avatar/v2/set</c> treats the same fields. Returns null for a body
/// that is not a JSON object.</summary>
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(),
};
/// <summary>BGNNOMBFMLH list. Elements without a parseable guid are
/// dropped instead of failing the whole save.</summary>
private static List<CustomAvatarItemRefDto> AsCustomItems(JsonElement v)
{
var result = new List<CustomAvatarItemRefDto>();
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<SavedOutfitDto> ParseList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
var list = JsonSerializer.Deserialize<List<SavedOutfitDto>>(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;
/// <summary>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.</summary>
[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;
/// <summary>Custom shirts worn in this slot. Always an array on the wire
/// (the client's ctor allocates an empty list, BEPMJICDNDD.txt:321-360).</summary>
[JsonPropertyName("CustomAvatarItems")] public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
}
@@ -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<IFNGBPJGINL> — 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(
/// <summary>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 <c>AvatarItemId</c>, 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.</summary>
/// item and stable across restarts. Colored variants must NOT use this — a player can own the base and
/// several colorways together; see <see cref="StableAvatarItemIdFull"/>.</summary>
public static int StableAvatarItemId(string? avatarItemDescOrGuid)
{
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
@@ -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<long, uint>();
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
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
///
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
/// (OPELMBNJHNO.txt:1375) returns the status-only <c>LDGADANDBIO</c>, not a
/// DTO — the body is never parsed, only the status code. Verb is <b>PUT</b>
/// (OPELMBNJHNO.txt:1375) returns the untyped <c>LDGADANDBIO</c> promise, but
/// it is built from the body parsed as a RecNetResult: the continuation
/// <c>&lt;UpdateLatestReadComment&gt;b__9_1(DJMHAFPGLLN)</c>
/// (OPELMBNJHNO_NestedType___c.txt:333) null-derefs an empty body and
/// rejects on <c>Success</c>=false — see <see cref="RecNetResults"/>. Verb is <b>PUT</b>
/// (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();
}
/// <summary>Deletes a comment.
@@ -129,14 +129,19 @@ public class ConfigController(ConfigService configService) : ControllerBase
/// Returns a broad default set so the picker is populated.</summary>
[HttpGet("/emojiConfig/whitelistedEmojis")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult WhitelistedEmojis() => Ok(new[]
public IActionResult WhitelistedEmojis() => Ok(WhitelistedEmojiList);
/// <summary>The emoji allow-list served above. Also the validation set
/// for <c>PUT account/me/emoji</c>, so a player can only pick an emoji
/// the client's own picker offered.</summary>
public static readonly IReadOnlyList<string> WhitelistedEmojiList = new[]
{
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
});
};
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
/// 2020.12 client deserializes this endpoint as a raw Boolean.
@@ -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<System.Boolean> 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<DJMHAFPGLLN,…> 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<string, object?>
{
["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");
/// <summary>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.</summary>
/// <summary>Bare JSON integer. The client's IHLMDLKOLNB pricing config is
/// built CLIENT-side: HCBOHHEACPK sends through FDKKOPAPDGF&lt;Int32&gt;
/// (RecNet.Runtime/MPBLNLMCEDL.txt:6922 — the same generic token
/// 0x18847B0B8 as the bare-Int32 itemOwnershipLimit call at :2981) and
/// continues with <c>&lt;GetPricingConfig&gt;b__69_0(Int32 minimumPrice)</c>
/// (MPBLNLMCEDL_NestedType___c.txt:286). An object body failed the Int32
/// parse, so the designer's public-price floor never loaded.</summary>
[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");
/// <summary>GET api/customAvatarItems/v1/design — hand back the caller's
/// saved in-progress shirt design. Verb ordinal 0 = GET
@@ -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
{
@@ -34,6 +34,58 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
});
}
/// <summary>The caller's referred players, paged.
///
/// <c>FGLDKEJLAKB&lt;GPJIMAHMAIO&gt; EGEEFJHAGAM(Int32? take, String continuationToken)</c>
/// (RecNet.Runtime/LINKIECPDML.txt:337) builds this through the URL builder
/// LPPEFJMCJFN ("api/incentivizedreferrals/" + "referrals", :677-701, with
/// <c>take</c> and <c>continuationToken</c> 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:
/// <c>{Referrals: [...], ContinuationToken: string}</c>; each entry
/// (ADAFNGNADFH.txt:279-346) is <c>{InviteeAccountId: Int32, CreatedAt:
/// DateTime, VerifiedAt: DateTime?}</c>.
///
/// 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.</summary>
[HttpGet("referrals")]
public async Task<IActionResult> 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<RewardSelection> where each entry is
// { rewardSelectionId: long, rewardType: string } (RecNet.Runtime
@@ -208,8 +208,10 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
/// <c>DELETE api/keepsakes/{guid}</c> - <c>NCCLEJPIABA.JBKENCNIEPA(System.Guid)</c> builds
/// <c>String.Format("{0}/{1}", "api/keepsakes", guid)</c> and passes verb 4 (DELETE) to the
/// BNDIAONDFFF ctor (NCCLEJPIABA.txt:1544, 1687-1698 - <c>Move rdx, 4</c>). Its return type is
/// the non-generic <c>LDGADANDBIO</c> 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 <c>LDGADANDBIO</c> promise, but that promise comes from
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult) — the body IS parsed, as a
/// <c>{success,error}</c> RecNetResult, and an empty 200 null-derefs into a failed delete.
/// See <see cref="RecNetResults"/>. 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.
/// </summary>
@@ -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();
}
/// <summary>
@@ -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;
@@ -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;
}
/// <summary>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.</summary>
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<int?> ReadIntFieldAsync(params string[] names)
{
var value = await ReadStringFieldAsync(names);
@@ -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 });
}
/// <summary>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)
@@ -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));
}
@@ -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();
}
/// <summary>Both verbs are mapped on purpose: the client picks GET or POST at
@@ -1642,7 +1642,21 @@ public class RoomsModerationController(
var newBlob = CloneBlobName(cloneId, orderIndex);
var (dstBucket, dstKey) = BlobRouter.Route(newBlob);
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
{
@@ -654,13 +654,28 @@ public class StorefrontsBuyController(
$"buyPurchaseReminder:{item.Slug}");
var giftId = await GrantWithGiftBoxAsync(pid, item, currencyType);
// 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)
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = $"purchaseReminder:{req.PurchaseReminderId}",
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));
@@ -177,7 +177,10 @@ public class SubscriptionsController(
}
await db.SaveChangesAsync();
return Ok();
// The client reads this body as DJMHAFPGLLN, the {success,error}
// RecNetResult (FGLDKEJLAKB<DJMHAFPGLLN> at IKMMOCKDKAF.txt LPFOPPHGDHG,
// instr 098) — answer in that shape rather than an empty 200.
return RecNetResults.Ok();
}
/// <summary>
@@ -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));
}
/// <summary>
@@ -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<BLOJAFNJPJI>) as account/bulk. Both suffixes 404'd.
[HttpPost("/account/bulk/email")]
[HttpPost("/account/bulk/phone")]
public async Task<IActionResult> 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 <suffix>" 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.
/// <summary>PUT <c>/account/me/emoji</c> — form <c>displayEmoji</c>.
/// Empty clears the emoji; anything else must be on the
/// <c>emojiConfig/whitelistedEmojis</c> list the picker was filled
/// from, and is stored in that list's canonical spelling.</summary>
[HttpPut("/account/me/emoji")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> 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 });
}
/// <summary>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).</summary>
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;
}
/// <summary>PUT <c>/account/me/personalpronouns</c> — form
/// <c>pronounFlags</c> (pronoun flags enum as Int32).</summary>
[HttpPut("/account/me/personalpronouns")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> 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 });
}
/// <summary>PUT <c>/account/me/identityflags</c> — form
/// <c>identityFlags</c> (identity flags enum as Int32).</summary>
[HttpPut("/account/me/identityflags")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> 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 });
}
/// <summary>Flags arrive as the boxed enum's Int32 ToString(). Negative
/// values are not valid bitmasks for either enum.</summary>
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;
/// <summary>PUT <c>/account/me/bannerimage</c> — form <c>imageName</c>,
/// the storage name of an image the player already uploaded (the
/// portfolio-banner picker offers the player's own photos). Empty clears
/// the banner.</summary>
[HttpPut("/account/me/bannerimage")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> 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<System.String> 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");
}
/// <summary>PlayerSettings key of the one-shot token minted above.
/// Value is <c>{token}|{expiresAtUtc:O}</c>.</summary>
public const string RemoteLoginTokenKey = "remote_login_token";
/// <summary>In-game approval of another device's login code.
///
/// <c>LDGADANDBIO BJMMDKNPCLD(String)</c> (RecNet.Runtime/LBNJFPOLCDL.txt:7896)
/// POSTs the single form field <c>code</c> and reads the reply through
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> as a <c>{success,error}</c> RecNetResult
/// (failure toast "Failed to authorize login"). The code is the
/// <c>user_code</c> that <c>connect/deviceauthorization</c> handed the other
/// device, which meanwhile polls <c>connect/token</c> with
/// <c>grant_type=urn:ietf:params:oauth:grant-type:device_code</c>
/// (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 (<see cref="DeviceAuthorizations"/>).
///
/// 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.</summary>
[HttpPost("/account/me/remoteauth")]
[HttpGet("/account/me/remoteauth")]
public async Task<IActionResult> 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.
/// </summary>
// 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<IActionResult> 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,
};
}
@@ -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);
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — 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);
}
/// <summary>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 <c>_sub{n}</c> form for the others.</summary>
private static string SyntheticSubRoomBlobName(long roomId, int subRoomId) =>
subRoomId == 0
? RoomService.SyntheticDefaultRoomDataBlobName(roomId)
: $"room_{roomId}_dorknet_v8_sub{subRoomId}.dat";
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable</c>
/// — 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 <see cref="RoomSceneEntity.DataBlobName"/>, and every details
/// builder + matchmaking resolver turns that into an empty
/// <c>DataBlob</c>, 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 <c>RecRoyaleSolos_GameRuleWrapperCircuit</c> lives in
/// it, not in the scene). Stamping the synthetic default name is enough:
/// every resolver returns it verbatim, and <c>CdnController</c> 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.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/enable")]
public async Task<ActionResult> 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));
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset</c>
/// — 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 <c>RoomDataBlobs</c>; only the pointers move, so
/// the in-game restore list can bring an old save back.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/reset")]
public async Task<ActionResult> 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));
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload</c>
/// — multipart <c>file</c> = a real <c>PersistedRoomData</c> save (any
/// client version; <c>.binpb</c> / <c>.room</c> / <c>.dat</c>), optional
/// form/query <c>mode</c> = <c>full</c> (default) or <c>basics</c>.
/// Rewrites it for this room (activity id, 2023 version clamp, drops
/// output log / sub-room id / unknown fields; <c>basics</c> also keeps
/// only the game-role and game-rule objects and drops the circuit
/// payloads — see <see cref="RoomDataBlobService.PrepareUploadedRoomBlob"/>),
/// stores it under a fresh <c>room_{id}_upload_*.dat</c> 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.</summary>
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/upload")]
[RequestSizeLimit(64 * 1024 * 1024)]
public async Task<ActionResult> 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);
/// <summary>PUT <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers</c>
@@ -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);
}
/// <summary>GET <c>api/admin/v1/settings/circuits-limits</c> — the
/// CircuitsV2 creation limits served as the <c>UGC.CircuitsV2.Limit.*</c>
/// gameconfig keys, including the per-frame RPC and byte budgets.</summary>
[HttpGet("settings/circuits-limits")]
public async Task<ActionResult> GetCircuitsV2Limits()
=> Ok(await serverSettings.GetCircuitsV2LimitsAsync());
[HttpPost("settings/circuits-limits")]
public async Task<ActionResult> 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 ─────────────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
@@ -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<IActionResult> 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,
+45 -16
View File
@@ -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<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName)
/// <summary>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.</summary>
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<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName) =>
await ResolveRroRoomAsync(fileName) is not null;
/// <summary>The AG/RRO room a blob name belongs to, or null when the name
/// is not an RRO room's (current blob, synthetic <c>room_{id}_*</c> form,
/// or a sub-room scene blob). Drives both the role overlay on S3 hits and
/// the per-room stub on misses.</summary>
private async Task<RoomEntity?> 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();
}
/// <summary>
@@ -91,11 +91,13 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
/// 2023 client sends the target club in a form-urlencoded
/// <c>clubId</c> field (boxed <c>Nullable&lt;Int64&gt;</c> pushed through
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> at <c>IKMMOCKDKAF.txt:16923-16927</c>)
/// and parses NO response DTO — the issuing method is
/// <c>LDGADANDBIO JOPMBFIFFBB(Nullable&lt;Int64&gt;)</c>, 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).</summary>
/// — the issuing method is <c>LDGADANDBIO JOPMBFIFFBB(Nullable&lt;Int64&gt;)</c>.
/// Despite the untyped promise the body IS parsed: both branches hand
/// the response to <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (:16858, :16943), the
/// <c>{success,error}</c> RecNetResult reader, so an empty 200 surfaced as
/// a failure (<see cref="RecNetResults"/>). Verb ordinal 3 (PUT) is the
/// HasValue branch of the same method at :16911; the null branch is the
/// DELETE below (:16838).</summary>
[HttpPut("/club/home/me")]
[Authorize]
public async Task<IActionResult> 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();
}
/// <summary>DELETE <c>/club/home/me</c> — "Remove Home Clubhouse Room".
/// No body, no response DTO (<c>IKMMOCKDKAF.txt:16830-16841</c>: route
/// No request body; RecNetResult reply (<c>IKMMOCKDKAF.txt:16830-16858</c>: route
/// literal, host 13, verb ordinal 4, error string "Failed to Remove Home
/// Clubhouse Room"). Idempotent so a double-tap is harmless.</summary>
[HttpDelete("/club/home/me")]
@@ -128,7 +130,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
public async Task<IActionResult> 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
/// (<c>IKMMOCKDKAF.txt:17107-17120</c>, verb ordinal 4, host 13, error
/// string "Failed to Delete Club"). <c>LDGADANDBIO</c> is the status-only
/// promise so an empty 200 is the whole contract.
/// string "Failed to Delete Club"). The promise is built by
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult, :17137), so the
/// body is a <c>{success,error}</c> RecNetResult — an empty 200 made the
/// client report "Failed to Delete Club" after the club was gone. See
/// <see cref="RecNetResults"/>.
///
/// Owner-only, and a soft delete — see
/// <see cref="ClubService.DisbandAsync"/>.</summary>
@@ -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 <see cref="ToWireMemberRequest"/>.</summary>
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().
/// <summary>POST <c>/club/{id}/members/requesttojoin</c> — 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();
}
/// <summary>POST <c>/club/{id}/members/directJoin</c> — 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();
}
/// <summary>POST <c>/club/{id}/members/leave</c> — 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();
}
/// <summary>POST <c>/club/{id}/members/invite</c> — 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();
}
/// <summary>POST <c>/club/{id}/members/invitemembers</c> — bulk
@@ -1241,7 +1254,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
foreach (var pid in ids)
await clubs.UpsertMembershipAsync(clubId, pid, 128);
return Ok();
return RecNetResults.Ok();
}
/// <summary>POST <c>/club/{id}/members/acceptinvite</c> — 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();
}
/// <summary>POST <c>/club/{id}/members/declineinvite</c> — caller
@@ -1264,7 +1277,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
public async Task<IActionResult> 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<int>()).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();
}
/// <summary>POST <c>/club/{id}/members/ban</c> — 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();
}
/// <summary>POST <c>/club/{id}/members/changetype</c> — 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<IActionResult> AnnouncementDelete(long clubId, long announcementId)
{
if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid();
return Ok();
return RecNetResults.Ok();
}
/// <summary>PUT <c>/announcements/club/{clubId}/{announcementId}</c> — 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();
}
/// <summary>POST <c>/announcements/club/{clubId}</c> — CREATE an
@@ -223,7 +223,11 @@ public class LinkController(DorkNetDbContext db, ILogger<LinkController> 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;
@@ -41,6 +41,7 @@ public class GoToController(
NotificationService notifications,
JoinTimeoutService joinTimeouts,
ServerSettingsService serverSettings,
PresenceBroadcastService presenceFanout,
ILogger<GoToController> 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<MatchmakingResponseDto> BuildResponseAsync(string roomName)
@@ -22,6 +22,7 @@ public class MatchController(
PlayerPresenceService presence,
PrivateInstanceService privateInstances,
JoinTimeoutService joinTimeouts,
PresenceBroadcastService presenceFanout,
RoomService rooms,
DorkNetDbContext db,
ILogger<MatchController> 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();
}
/// <summary>POST <c>matchmake/none</c> — "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
/// (<c>&lt;TryGoToOfflineRoom&gt;b__119_0</c> 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.</summary>
[HttpPost("/matchmake/none")]
public async Task<IActionResult> 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 })
{
joinTimeouts.Cancel(pid, "matchmake-none");
presence.SetNoRoom(pid);
presence.MarkActive(pid);
presence.EndBootDormGrace(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,
});
}
"[matchmake-none] player={Player} left room={Room} instance={Instance} abandonedPending={Pending}; online with no room",
pid, previous?.RoomId, previous?.RoomInstanceId, pending?.TargetRoom.RoomInstanceId);
var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid);
presence.SetRoom(pid, current);
presence.MarkActive(pid);
logger.LogInformation("[matchmake-none] player={Player}", pid);
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)
@@ -24,6 +24,7 @@ public class MatchPlayerController(
RoomService rooms,
PrivateInstanceService privateInstances,
JoinTimeoutService joinTimeouts,
PresenceBroadcastService presenceFanout,
IConfiguration config,
DorkNetDbContext db,
ILogger<MatchPlayerController> log) : ControllerBase
@@ -87,13 +88,20 @@ public class MatchPlayerController(
/// <summary>
/// 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.
/// </summary>
[HttpPost("/player/logout")]
public IActionResult PlayerLogout()
public async Task<IActionResult> 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<ActionResult<PresenceDto>> 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);
@@ -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;
/// </summary>
[ApiController]
public class PlayerPresenceController(
PlayerPresenceService presence) : ControllerBase
PlayerPresenceService presence,
PresenceBroadcastService presenceFanout,
JoinTimeoutService joinTimeouts,
DorkNetDbContext db,
ServerSettingsService settings) : ControllerBase
{
/// <summary>PlayerSettings key written by <c>PUT /player/statusvisibility</c>
/// (MatchPlayerController.PrefStatusVisibility — same literal; the
/// round-trip is pinned by a regression test).</summary>
public const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
[HttpGet("/player")]
public ActionResult<List<PlayerPresenceDto>> GetPresences()
public async Task<ActionResult<List<PlayerPresenceDto>>> GetPresences()
{
// The watch sends two URL flavours for this endpoint:
// 1. Bulk: <c>/player?{id1}&amp;{id2}&amp;{id3}</c> — 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);
}
/// <summary>The caller's own room as their heartbeat reports it.</summary>
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<Dictionary<long, int>> LoadVisibilityAsync(HashSet<long> ids) =>
PresenceBroadcastService.GetStatusVisibilitiesAsync(db, ids);
/// <summary>Ids whose online status the caller may not see. The caller
/// always sees itself; anonymous callers only see Public players.</summary>
private async Task<HashSet<long>> ResolveHiddenAsync(
long? callerId, HashSet<long> ids, Dictionary<long, int> visibility)
{
var hidden = new HashSet<long>();
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<long>? friends = null;
HashSet<long>? 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; }
/// <summary>PlayerStatusVisibility enum (int):
/// 0 = Hidden / Offline-appearing, 1 = Visible, 2 = FriendsOnly.</summary>
/// <summary>Online-status visibility enum (int, 2023 client):
/// 0 = Public, 1 = FriendsOnly, 2 = FavoriteFriendsOnly, 3 = Offline.</summary>
[JsonPropertyName("statusVisibility")]
public int StatusVisibility { get; set; }
@@ -0,0 +1,36 @@
using DorkNet.Models.Auth;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers;
/// <summary>
/// Bodies for the 2023 client's <c>RecNetResult</c> writes.
///
/// A lot of the client's fire-and-forget writes look status-only from their
/// signature (they return <c>LDGADANDBIO</c>, the untyped promise), but they
/// are not: they go through <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
/// (<c>RecNet.Runtime/BFDGEKKNAPB.txt:353</c>, "ExpandRecNetResult"), which
/// deserialises the body as <c>DJMHAFPGLLN</c> = <c>{Success, Error}</c>
/// (formatter <c>RecNet.Runtime/LMCCNLLHLCJ.txt:191-218</c> registers
/// <c>Success</c>/<c>success</c> and <c>Error</c>/<c>error</c>) and then runs
/// the <c>&lt;ExpandRecNetResult&gt;b__2_0</c> lambda
/// (<c>RecNet.Runtime/BFDGEKKNAPB_NestedType___c.txt:61</c>):
/// <list type="bullet">
/// <item>body null (an empty 200 or <c>null</c> — <c>COLNKALCBOK.txt</c>
/// JIKCMNMKNPH returns default for both) → NullReferenceException, the write
/// shows "Failed to …" although the server applied it;</item>
/// <item><c>Success</c> missing/false → <c>Promise.Rejected(Error)</c>; with no
/// <c>Error</c> key that throws <c>ArgumentException: Error message cannot be
/// null</c> (seen in Player.log as an unobserved task exception).</item>
/// </list>
/// So every such endpoint must answer <c>{"success":true,"error":""}</c> on
/// success, and carry a non-null <c>error</c> on a 200 failure.
/// </summary>
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 });
}
@@ -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<IActionResult> Upload()
{
// Form parsing — ASP.NET Core deserialises the multipart body
@@ -15,4 +15,21 @@ public class StringsController : ControllerBase
[HttpGet("/strings/v1/{locale}")]
public ActionResult<Dictionary<string, string>> GetLocale(string locale)
=> Ok(new Dictionary<string, string>());
/// <summary>The 2023 client's server-string download (RecNet.Runtime/NHILNDLLLHH.txt).
/// Two GETs share the path <c>/strings</c> on the strings host:
/// <list type="bullet">
/// <item><c>?locale=&amp;scopePrefix=client.&amp;eTag=</c> (FMLBOAOBKPM, :531) →
/// <c>Dictionary&lt;string, Dictionary&lt;long,string&gt;&gt;</c>, scope → id → text;</item>
/// <item><c>?locale=&amp;scope=client.{name}&amp;eTag=</c> (BNEELKAKCEN, :3) →
/// <c>Dictionary&lt;long,string&gt;</c>.</item>
/// </list>
/// 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.</summary>
[HttpGet("/strings")]
public IActionResult GetServerStrings() => Content("{}", "application/json");
}
@@ -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.
/// <summary>Emoji shown beside the display name ("" = none).</summary>
[MaxLength(32)]
public string DisplayEmoji { get; set; } = string.Empty;
/// <summary>Profile banner image name (a storage image the player
/// already uploaded, e.g. one of their photos). Null = no banner.</summary>
[MaxLength(256)]
public string? BannerImageName { get; set; }
/// <summary>Pronoun bitmask (client flags enum: SheHer=1, HeHim=2,
/// TheyThem=4, ZeHir=8, ZeZir=16, XeXem=32).</summary>
public int PersonalPronouns { get; set; } = 0;
/// <summary>Identity-flag bitmask (client flags enum: LGBTQIA=1,
/// Transgender=2, Bisexual=4, …).</summary>
public int IdentityFlags { get; set; } = 0;
// Navigation
public AvatarEntity? Avatar { get; set; }
public List<PlayerSettingEntity> Settings { get; set; } = [];
@@ -108,5 +108,14 @@ public class ServerSettingsEntity
/// admins curate the base-room list from the SPA without a redeploy.</summary>
public string BaseRoomNamesJson { get; set; } = string.Empty;
/// <summary>Admin-managed CircuitsV2 creation limits, served to the
/// client as the <c>UGC.CircuitsV2.Limit.*</c> 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 <see cref="Services.CircuitsV2LimitSettings"/>.</summary>
public string CircuitsV2LimitsJson { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+13 -1
View File
@@ -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<String>`) |
| 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).
+17 -17
View File
@@ -42,8 +42,7 @@ public class NotifyHub(
OnlinePresenceService presence,
PlayerPresenceService playerRooms,
NotificationService notifications,
DorkNetDbContext db,
ServerSettingsService serverSettings,
PresenceBroadcastService presenceFanout,
ILogger<NotifyHub> logger) : Hub
{
/// <summary>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}",
@@ -11,19 +11,25 @@ namespace DorkNet.Server.Migrations
/// <inheritdoc />
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<string>(
name: "CommentsJson",
table: "TestCases",
type: "TEXT",
nullable: false,
defaultValue: "");
defaultValue: "[]");
migrationBuilder.AddColumn<bool>(
name: "ClubChatEnabled",
table: "Clubs",
type: "INTEGER",
nullable: false,
defaultValue: false);
defaultValue: true);
migrationBuilder.AddColumn<int>(
name: "MinLevel",
@@ -0,0 +1,3654 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("AdminPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CustomAvatarItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EquippedItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FaceFeatures")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<string>("HairColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("InventoryJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OutfitSelections")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("OutfitSelectionsV2")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("SavedOutfitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SkinColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("Avatars");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ClientVersion")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long?>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBuiltIn")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WordsJson")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CharadesWordLists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("ModerationState")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey")
.IsUnique();
b.ToTable("ChatThreads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<long?>("LastReadMessageId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SnoozeUntil")
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CheeredAt")
.HasColumnType("TEXT");
b.Property<long>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPhotoId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Slot")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "Slot")
.IsUnique();
b.ToTable("ClubAdditionalImages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AuthorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "CreatedAt");
b.ToTable("ClubAnnouncements");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AnnouncementId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CategoryTagId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("ClubCategoryTags");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowJuniors")
.HasColumnType("INTEGER");
b.Property<int>("BanStatus")
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("ClubChatEnabled")
.HasColumnType("INTEGER");
b.Property<int>("ClubType")
.HasColumnType("INTEGER");
b.Property<long?>("ClubhouseRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsRRO")
.HasColumnType("INTEGER");
b.Property<int>("Joinability")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<int>("Permissions")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ApproveMember")
.HasColumnType("INTEGER");
b.Property<bool>("BanUnban")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<bool>("CreateEvent")
.HasColumnType("INTEGER");
b.Property<bool>("EditDetails")
.HasColumnType("INTEGER");
b.Property<bool>("EditPermissionSettings")
.HasColumnType("INTEGER");
b.Property<int>("MembershipType")
.HasColumnType("INTEGER");
b.Property<bool>("PostAnnouncement")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "MembershipType")
.IsUnique();
b.ToTable("ClubRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AssignedAt")
.HasColumnType("TEXT");
b.Property<string>("CohortKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CommunityBoardRows");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int>("MaxRedemptions")
.HasColumnType("INTEGER");
b.Property<int>("RedemptionCount")
.HasColumnType("INTEGER");
b.Property<long>("RewardAmount")
.HasColumnType("INTEGER");
b.Property<int>("RewardCurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("RewardItemSlug")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Coupons");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CouponId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CouponId", "PlayerId")
.IsUnique();
b.ToTable("CouponRedemptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CurrencyType")
.IsUnique();
b.ToTable("CurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssetName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("BaseAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("CheerCount")
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("PurchaseCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<long>("CustomAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("PlayerId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("PlayerId");
b.ToTable("DormStates");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<long>("GrantedItemId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("Offer1ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer2ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer3ItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SelectedAt")
.HasColumnType("TEXT");
b.Property<int?>("SelectedGiftDropId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "SelectedAt");
b.ToTable("GameRewardSelections");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActivityLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PlayerCount")
.HasColumnType("INTEGER");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AvatarItemDescOrHairDyeDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("AvatarItemType")
.HasColumnType("INTEGER");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("Consumed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Currency")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("EquipmentModificationGuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("EquipmentPrefabName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<int>("GiftRarity")
.HasColumnType("INTEGER");
b.Property<bool>("IsGifted")
.HasColumnType("INTEGER");
b.Property<bool>("IsValid")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("PackageMaterial")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PackageVariant")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("SourceStoreItemId")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsCurrentPlatform")
.HasColumnType("INTEGER");
b.Property<int>("Xp")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "Consumed");
b.ToTable("GiftPackages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long?>("CreationRoomId")
.HasColumnType("INTEGER");
b.Property<int>("CreatorPermission")
.HasColumnType("INTEGER");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("CurrentVersionNumber")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("FirstPublishedAt")
.HasColumnType("TEXT");
b.Property<int>("GeneralPermission")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAgInvention")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublished")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("NumPlayersHaveUsedInRoom")
.HasColumnType("INTEGER");
b.Property<int>("Permission")
.HasColumnType("INTEGER");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SpawnCount")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("InstantiationCost")
.HasColumnType("INTEGER");
b.Property<long>("InventionId")
.HasColumnType("INTEGER");
b.Property<int>("LightsCost")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("VersionNumber")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InventionId", "VersionNumber")
.IsUnique();
b.ToTable("InventionVersions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("BannedAt")
.HasColumnType("TEXT");
b.Property<long>("BannedByAdminId")
.HasColumnType("INTEGER");
b.Property<string>("Cidr")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Cidr");
b.ToTable("IpBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("EarnedAt")
.HasColumnType("TEXT");
b.Property<string>("EventKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("Channel")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("LowerIsBetter")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ValueFormat")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("RoomId", "Channel");
b.HasIndex("RoomId");
b.ToTable("LeaderboardChannelMeta");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("StatChannel")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Context")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("PlatformMask")
.HasColumnType("INTEGER");
b.Property<string>("RoomNamesCsv")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("LoadingScreenTips");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long?>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowAll")
.HasColumnType("INTEGER");
b.Property<bool>("AllowAnnouncements")
.HasColumnType("INTEGER");
b.Property<bool>("AllowEventInvite")
.HasColumnType("INTEGER");
b.Property<bool>("AllowFriendRequest")
.HasColumnType("INTEGER");
b.Property<bool>("AllowMessage")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("NotificationPrefs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ClearedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("ObjectiveProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("TEXT");
b.Property<string>("LastIp")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PendingDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Caption")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("TaggedPlayerIdsCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("UploaderPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeen")
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Elo")
.HasColumnType("INTEGER");
b.Property<int>("GameMode")
.HasColumnType("INTEGER");
b.Property<int>("Losses")
.HasColumnType("INTEGER");
b.Property<int>("MatchesPlayed")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("TEXT");
b.Property<string>("Bio")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Birthday")
.HasColumnType("TEXT");
b.Property<bool>("CanReceiveInvites")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedFromDeviceId")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCommunityTeam")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeveloper")
.HasColumnType("INTEGER");
b.Property<bool>("IsJunior")
.HasColumnType("INTEGER");
b.Property<bool>("IsVerified")
.HasColumnType("INTEGER");
b.Property<string>("LastIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("LastPlatform")
.HasColumnType("INTEGER");
b.Property<string>("LastPlatformId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ProfileImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Reputation")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("XP")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Capacity")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("EndsAt")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartsAt")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("EventId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("ItemSlug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CustomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsCurated")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsJuniors")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Cheered")
.HasColumnType("INTEGER");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DataBlob")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("OwnerPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRegion")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PhotonRoomId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("RoomCode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("PrivateInstanceId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("InvitedAt")
.HasColumnType("TEXT");
b.Property<long?>("LatestInviteMessageId")
.HasColumnType("INTEGER");
b.HasKey("PrivateInstanceId", "PlayerId");
b.HasIndex("PlayerId");
b.ToTable("PrivateInstanceInvitees");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<bool>("Ignored")
.HasColumnType("INTEGER");
b.Property<bool>("Muted")
.HasColumnType("INTEGER");
b.Property<long>("RequesterId")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RequesterId", "TargetId")
.IsUnique();
b.ToTable("Relationships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Category")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("ResolutionNote")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("TEXT");
b.Property<long?>("ResolverAdminId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("TargetEventId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BanType")
.HasColumnType("INTEGER");
b.Property<long>("BannedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("BannedPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<Guid?>("CurrencyId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<Guid>("ConcurrencyCode")
.HasColumnType("TEXT");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("DailyLimit")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Amount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<byte[]>("Bytes")
.HasColumnType("BLOB");
b.Property<string>("ReferencedFilenamesCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long?>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UploadedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<bool>("AllowNewUsers")
.HasColumnType("INTEGER");
b.Property<bool>("AllowsJuniors")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<bool>("CloningAllowed")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("CustomRoomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool>("DisableMicAutoMute")
.HasColumnType("INTEGER");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<bool>("HiddenFromBrowse")
.HasColumnType("INTEGER");
b.Property<double>("HotScore")
.HasColumnType("REAL");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAGRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsDormRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
.HasColumnType("INTEGER");
b.Property<bool>("IsStudioRoom")
.HasColumnType("INTEGER");
b.Property<string>("LoadScreensJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("LocationReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayerCalculationMode")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<string>("PromoExternalContentJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("PromoImagesJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<int>("RoomWarningMask")
.HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<string>("StudioSessionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsMobile")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsVRLow")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(174)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("PaidPrice")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Accepted")
.HasColumnType("INTEGER");
b.Property<DateTime>("GrantedAt")
.HasColumnType("TEXT");
b.Property<long?>("GrantedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CanMatchmakeInto")
.HasColumnType("INTEGER");
b.Property<string>("DataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("DataModifiedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsSandbox")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("RoomSceneLocationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("StudioAssetBundleNamesCsv")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<long?>("StudioSubRoomDataSaveId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("FirstVisitAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastVisitAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChestsOpened")
.HasColumnType("INTEGER");
b.Property<DateTime>("CompletedAt")
.HasColumnType("TEXT");
b.Property<bool>("CustomGame")
.HasColumnType("INTEGER");
b.Property<int>("HealthPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.Property<int>("SecondsInAir")
.HasColumnType("INTEGER");
b.Property<int>("ShieldPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<bool>("WalkGame")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CompletedAt");
b.ToTable("RoyaleMatches");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("MatchId")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CurrentLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<int>("NextLevelAcornReward")
.HasColumnType("INTEGER");
b.Property<long>("NextLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RankIdx")
.HasColumnType("INTEGER");
b.Property<string>("RankName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("TotalXP")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("RoyalePlayerProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("AllAvatarItemsOwned")
.HasColumnType("INTEGER");
b.Property<string>("BaseRoomNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CharadesSlotBindingsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DiscoveredGameConfigsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("GlobalFriendsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("PlayMenuTagsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("ProfanityFilterDisabled")
.HasColumnType("INTEGER");
b.Property<string>("RecCenterDoorsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("RoomBlobVersionClampDisabled")
.HasColumnType("INTEGER");
b.Property<bool>("SignupsDisabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WeeklyChallengeRewardJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("WeeklyChallengesCompletedRequired")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("WeeklyChallengesJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.HasKey("Id");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Descriptor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("RedeemedAt")
.HasColumnType("TEXT");
b.Property<long?>("RedeemedByPlayerId")
.HasColumnType("INTEGER");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SignupCodes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("AvailableUntil")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsLimitedTime")
.HasColumnType("INTEGER");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Storefront")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("SubscriberPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Pk")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssignedPlayerIdsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AssignedPlayerNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("JiraBugUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("JiraUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MinNumAssignedPlayers")
.HasColumnType("INTEGER");
b.Property<string>("RoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint?>("TestPassId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("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<uint>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndDate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("WasManuallyClosed")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TestPasses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DorkNet.Server.Migrations
{
/// <summary>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.</summary>
public partial class ClubChatDefaultRepair : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE ""Clubs"" SET ""ClubChatEnabled"" = TRUE WHERE ""ClubChatEnabled"" = FALSE;");
migrationBuilder.Sql(
@"UPDATE ""TestCases"" SET ""CommentsJson"" = '[]' WHERE ""CommentsJson"" = '';");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Data repair — nothing meaningful to undo.
}
}
}
@@ -0,0 +1,3658 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("AdminPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CustomAvatarItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EquippedItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FaceFeatures")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<string>("HairColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("InventoryJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OutfitSelections")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("OutfitSelectionsV2")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("SavedOutfitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SkinColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("Avatars");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ClientVersion")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long?>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBuiltIn")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WordsJson")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CharadesWordLists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("ModerationState")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey")
.IsUnique();
b.ToTable("ChatThreads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<long?>("LastReadMessageId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SnoozeUntil")
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CheeredAt")
.HasColumnType("TEXT");
b.Property<long>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPhotoId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Slot")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "Slot")
.IsUnique();
b.ToTable("ClubAdditionalImages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AuthorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "CreatedAt");
b.ToTable("ClubAnnouncements");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AnnouncementId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CategoryTagId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("ClubCategoryTags");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowJuniors")
.HasColumnType("INTEGER");
b.Property<int>("BanStatus")
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("ClubChatEnabled")
.HasColumnType("INTEGER");
b.Property<int>("ClubType")
.HasColumnType("INTEGER");
b.Property<long?>("ClubhouseRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsRRO")
.HasColumnType("INTEGER");
b.Property<int>("Joinability")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<int>("Permissions")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ApproveMember")
.HasColumnType("INTEGER");
b.Property<bool>("BanUnban")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<bool>("CreateEvent")
.HasColumnType("INTEGER");
b.Property<bool>("EditDetails")
.HasColumnType("INTEGER");
b.Property<bool>("EditPermissionSettings")
.HasColumnType("INTEGER");
b.Property<int>("MembershipType")
.HasColumnType("INTEGER");
b.Property<bool>("PostAnnouncement")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "MembershipType")
.IsUnique();
b.ToTable("ClubRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AssignedAt")
.HasColumnType("TEXT");
b.Property<string>("CohortKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CommunityBoardRows");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int>("MaxRedemptions")
.HasColumnType("INTEGER");
b.Property<int>("RedemptionCount")
.HasColumnType("INTEGER");
b.Property<long>("RewardAmount")
.HasColumnType("INTEGER");
b.Property<int>("RewardCurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("RewardItemSlug")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Coupons");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CouponId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CouponId", "PlayerId")
.IsUnique();
b.ToTable("CouponRedemptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CurrencyType")
.IsUnique();
b.ToTable("CurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssetName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("BaseAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("CheerCount")
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("PurchaseCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<long>("CustomAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("PlayerId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("PlayerId");
b.ToTable("DormStates");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<long>("GrantedItemId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("Offer1ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer2ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer3ItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SelectedAt")
.HasColumnType("TEXT");
b.Property<int?>("SelectedGiftDropId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "SelectedAt");
b.ToTable("GameRewardSelections");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActivityLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PlayerCount")
.HasColumnType("INTEGER");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AvatarItemDescOrHairDyeDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("AvatarItemType")
.HasColumnType("INTEGER");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("Consumed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Currency")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("EquipmentModificationGuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("EquipmentPrefabName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<int>("GiftRarity")
.HasColumnType("INTEGER");
b.Property<bool>("IsGifted")
.HasColumnType("INTEGER");
b.Property<bool>("IsValid")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("PackageMaterial")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PackageVariant")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("SourceStoreItemId")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsCurrentPlatform")
.HasColumnType("INTEGER");
b.Property<int>("Xp")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "Consumed");
b.ToTable("GiftPackages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long?>("CreationRoomId")
.HasColumnType("INTEGER");
b.Property<int>("CreatorPermission")
.HasColumnType("INTEGER");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("CurrentVersionNumber")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("FirstPublishedAt")
.HasColumnType("TEXT");
b.Property<int>("GeneralPermission")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAgInvention")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublished")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("NumPlayersHaveUsedInRoom")
.HasColumnType("INTEGER");
b.Property<int>("Permission")
.HasColumnType("INTEGER");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SpawnCount")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("InstantiationCost")
.HasColumnType("INTEGER");
b.Property<long>("InventionId")
.HasColumnType("INTEGER");
b.Property<int>("LightsCost")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("VersionNumber")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InventionId", "VersionNumber")
.IsUnique();
b.ToTable("InventionVersions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("BannedAt")
.HasColumnType("TEXT");
b.Property<long>("BannedByAdminId")
.HasColumnType("INTEGER");
b.Property<string>("Cidr")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Cidr");
b.ToTable("IpBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("EarnedAt")
.HasColumnType("TEXT");
b.Property<string>("EventKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("Channel")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("LowerIsBetter")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ValueFormat")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("RoomId", "Channel");
b.HasIndex("RoomId");
b.ToTable("LeaderboardChannelMeta");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("StatChannel")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Context")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("PlatformMask")
.HasColumnType("INTEGER");
b.Property<string>("RoomNamesCsv")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("LoadingScreenTips");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long?>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowAll")
.HasColumnType("INTEGER");
b.Property<bool>("AllowAnnouncements")
.HasColumnType("INTEGER");
b.Property<bool>("AllowEventInvite")
.HasColumnType("INTEGER");
b.Property<bool>("AllowFriendRequest")
.HasColumnType("INTEGER");
b.Property<bool>("AllowMessage")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("NotificationPrefs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ClearedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("ObjectiveProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("TEXT");
b.Property<string>("LastIp")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PendingDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Caption")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("TaggedPlayerIdsCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("UploaderPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeen")
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Elo")
.HasColumnType("INTEGER");
b.Property<int>("GameMode")
.HasColumnType("INTEGER");
b.Property<int>("Losses")
.HasColumnType("INTEGER");
b.Property<int>("MatchesPlayed")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("TEXT");
b.Property<string>("Bio")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Birthday")
.HasColumnType("TEXT");
b.Property<bool>("CanReceiveInvites")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedFromDeviceId")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCommunityTeam")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeveloper")
.HasColumnType("INTEGER");
b.Property<bool>("IsJunior")
.HasColumnType("INTEGER");
b.Property<bool>("IsVerified")
.HasColumnType("INTEGER");
b.Property<string>("LastIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("LastPlatform")
.HasColumnType("INTEGER");
b.Property<string>("LastPlatformId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ProfileImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Reputation")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("XP")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Capacity")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("EndsAt")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartsAt")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("EventId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("ItemSlug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CustomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsCurated")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsJuniors")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Cheered")
.HasColumnType("INTEGER");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DataBlob")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("OwnerPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRegion")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PhotonRoomId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("RoomCode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("PrivateInstanceId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("InvitedAt")
.HasColumnType("TEXT");
b.Property<long?>("LatestInviteMessageId")
.HasColumnType("INTEGER");
b.HasKey("PrivateInstanceId", "PlayerId");
b.HasIndex("PlayerId");
b.ToTable("PrivateInstanceInvitees");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<bool>("Ignored")
.HasColumnType("INTEGER");
b.Property<bool>("Muted")
.HasColumnType("INTEGER");
b.Property<long>("RequesterId")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RequesterId", "TargetId")
.IsUnique();
b.ToTable("Relationships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Category")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("ResolutionNote")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("TEXT");
b.Property<long?>("ResolverAdminId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("TargetEventId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BanType")
.HasColumnType("INTEGER");
b.Property<long>("BannedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("BannedPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<Guid?>("CurrencyId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<Guid>("ConcurrencyCode")
.HasColumnType("TEXT");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("DailyLimit")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Amount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<byte[]>("Bytes")
.HasColumnType("BLOB");
b.Property<string>("ReferencedFilenamesCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long?>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UploadedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<bool>("AllowNewUsers")
.HasColumnType("INTEGER");
b.Property<bool>("AllowsJuniors")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<bool>("CloningAllowed")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("CustomRoomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool>("DisableMicAutoMute")
.HasColumnType("INTEGER");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<bool>("HiddenFromBrowse")
.HasColumnType("INTEGER");
b.Property<double>("HotScore")
.HasColumnType("REAL");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAGRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsDormRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
.HasColumnType("INTEGER");
b.Property<bool>("IsStudioRoom")
.HasColumnType("INTEGER");
b.Property<string>("LoadScreensJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("LocationReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayerCalculationMode")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<string>("PromoExternalContentJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("PromoImagesJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<int>("RoomWarningMask")
.HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<string>("StudioSessionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsMobile")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsVRLow")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(174)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("PaidPrice")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Accepted")
.HasColumnType("INTEGER");
b.Property<DateTime>("GrantedAt")
.HasColumnType("TEXT");
b.Property<long?>("GrantedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CanMatchmakeInto")
.HasColumnType("INTEGER");
b.Property<string>("DataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("DataModifiedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsSandbox")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("RoomSceneLocationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("StudioAssetBundleNamesCsv")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<long?>("StudioSubRoomDataSaveId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("FirstVisitAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastVisitAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChestsOpened")
.HasColumnType("INTEGER");
b.Property<DateTime>("CompletedAt")
.HasColumnType("TEXT");
b.Property<bool>("CustomGame")
.HasColumnType("INTEGER");
b.Property<int>("HealthPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.Property<int>("SecondsInAir")
.HasColumnType("INTEGER");
b.Property<int>("ShieldPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<bool>("WalkGame")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CompletedAt");
b.ToTable("RoyaleMatches");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("MatchId")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CurrentLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<int>("NextLevelAcornReward")
.HasColumnType("INTEGER");
b.Property<long>("NextLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RankIdx")
.HasColumnType("INTEGER");
b.Property<string>("RankName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("TotalXP")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("RoyalePlayerProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("AllAvatarItemsOwned")
.HasColumnType("INTEGER");
b.Property<string>("BaseRoomNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CharadesSlotBindingsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CircuitsV2LimitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DiscoveredGameConfigsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("GlobalFriendsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("PlayMenuTagsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("ProfanityFilterDisabled")
.HasColumnType("INTEGER");
b.Property<string>("RecCenterDoorsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("RoomBlobVersionClampDisabled")
.HasColumnType("INTEGER");
b.Property<bool>("SignupsDisabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WeeklyChallengeRewardJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("WeeklyChallengesCompletedRequired")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("WeeklyChallengesJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.HasKey("Id");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Descriptor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("RedeemedAt")
.HasColumnType("TEXT");
b.Property<long?>("RedeemedByPlayerId")
.HasColumnType("INTEGER");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SignupCodes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("AvailableUntil")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsLimitedTime")
.HasColumnType("INTEGER");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Storefront")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("SubscriberPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Pk")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssignedPlayerIdsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AssignedPlayerNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("JiraBugUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("JiraUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MinNumAssignedPlayers")
.HasColumnType("INTEGER");
b.Property<string>("RoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint?>("TestPassId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("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<uint>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndDate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("WasManuallyClosed")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TestPasses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DorkNet.Server.Migrations
{
/// <inheritdoc />
public partial class CircuitsV2Limits : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CircuitsV2LimitsJson",
table: "ServerSettings",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CircuitsV2LimitsJson",
table: "ServerSettings");
}
}
}
@@ -0,0 +1,3673 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("AdminPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CustomAvatarItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EquippedItemsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FaceFeatures")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<string>("HairColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("InventoryJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OutfitSelections")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("OutfitSelectionsV2")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("SavedOutfitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SkinColor")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("Avatars");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ClientVersion")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActionUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long?>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Priority")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBuiltIn")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WordsJson")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CharadesWordLists");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("ModerationState")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ThreadKey")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ThreadKey")
.IsUnique();
b.ToTable("ChatThreads");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<long?>("LastReadMessageId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SnoozeUntil")
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CheeredAt")
.HasColumnType("TEXT");
b.Property<long>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPhotoId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("TargetRoomId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Slot")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "Slot")
.IsUnique();
b.ToTable("ClubAdditionalImages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AuthorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "CreatedAt");
b.ToTable("ClubAnnouncements");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("AnnouncementId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CategoryTagId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name");
b.ToTable("ClubCategoryTags");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowJuniors")
.HasColumnType("INTEGER");
b.Property<int>("BanStatus")
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("ClubChatEnabled")
.HasColumnType("INTEGER");
b.Property<int>("ClubType")
.HasColumnType("INTEGER");
b.Property<long?>("ClubhouseRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsRRO")
.HasColumnType("INTEGER");
b.Property<int>("Joinability")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<DateTime>("JoinedAt")
.HasColumnType("TEXT");
b.Property<int>("Permissions")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ApproveMember")
.HasColumnType("INTEGER");
b.Property<bool>("BanUnban")
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<bool>("CreateEvent")
.HasColumnType("INTEGER");
b.Property<bool>("EditDetails")
.HasColumnType("INTEGER");
b.Property<bool>("EditPermissionSettings")
.HasColumnType("INTEGER");
b.Property<int>("MembershipType")
.HasColumnType("INTEGER");
b.Property<bool>("PostAnnouncement")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClubId", "MembershipType")
.IsUnique();
b.ToTable("ClubRolePermissions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ClubId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AssignedAt")
.HasColumnType("TEXT");
b.Property<string>("CohortKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Json")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CommunityBoardRows");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int>("MaxRedemptions")
.HasColumnType("INTEGER");
b.Property<int>("RedemptionCount")
.HasColumnType("INTEGER");
b.Property<long>("RewardAmount")
.HasColumnType("INTEGER");
b.Property<int>("RewardCurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("RewardItemSlug")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Coupons");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CouponId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CouponId", "PlayerId")
.IsUnique();
b.ToTable("CouponRedemptions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "CurrencyType")
.IsUnique();
b.ToTable("CurrencyBalances");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssetName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("BaseAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("CheerCount")
.HasColumnType("INTEGER");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("PurchaseCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<long>("CustomAvatarItemId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("PlayerId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("PlayerId");
b.ToTable("DormStates");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<long>("GrantedItemId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("Offer1ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer2ItemId")
.HasColumnType("INTEGER");
b.Property<long>("Offer3ItemId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RewardType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SelectedAt")
.HasColumnType("TEXT");
b.Property<int?>("SelectedGiftDropId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "SelectedAt");
b.ToTable("GameRewardSelections");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ActivityLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PlayerCount")
.HasColumnType("INTEGER");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AvatarItemDescOrHairDyeDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int?>("AvatarItemType")
.HasColumnType("INTEGER");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("Consumed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Currency")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("EquipmentModificationGuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("EquipmentPrefabName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("FromPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("GiftContext")
.HasColumnType("INTEGER");
b.Property<int>("GiftRarity")
.HasColumnType("INTEGER");
b.Property<bool>("IsGifted")
.HasColumnType("INTEGER");
b.Property<bool>("IsValid")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("PackageMaterial")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PackageVariant")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("SourceStoreItemId")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsCurrentPlatform")
.HasColumnType("INTEGER");
b.Property<int>("Xp")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RecipientPlayerId", "Consumed");
b.ToTable("GiftPackages");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long?>("CreationRoomId")
.HasColumnType("INTEGER");
b.Property<int>("CreatorPermission")
.HasColumnType("INTEGER");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("CurrentVersionNumber")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("FirstPublishedAt")
.HasColumnType("TEXT");
b.Property<int>("GeneralPermission")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAgInvention")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPublished")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("NumPlayersHaveUsedInRoom")
.HasColumnType("INTEGER");
b.Property<int>("Permission")
.HasColumnType("INTEGER");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SpawnCount")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("InstantiationCost")
.HasColumnType("INTEGER");
b.Property<long>("InventionId")
.HasColumnType("INTEGER");
b.Property<int>("LightsCost")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("VersionNumber")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InventionId", "VersionNumber")
.IsUnique();
b.ToTable("InventionVersions");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("BannedAt")
.HasColumnType("TEXT");
b.Property<long>("BannedByAdminId")
.HasColumnType("INTEGER");
b.Property<string>("Cidr")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Until")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Cidr");
b.ToTable("IpBans");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("EarnedAt")
.HasColumnType("TEXT");
b.Property<string>("EventKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("Channel")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("LowerIsBetter")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("ValueFormat")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("RoomId", "Channel");
b.HasIndex("RoomId");
b.ToTable("LeaderboardChannelMeta");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("StatChannel")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Context")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("PlatformMask")
.HasColumnType("INTEGER");
b.Property<string>("RoomNamesCsv")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("LoadingScreenTips");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("ReadAt")
.HasColumnType("TEXT");
b.Property<long>("RecipientPlayerId")
.HasColumnType("INTEGER");
b.Property<long?>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("SenderPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SentAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowAll")
.HasColumnType("INTEGER");
b.Property<bool>("AllowAnnouncements")
.HasColumnType("INTEGER");
b.Property<bool>("AllowEventInvite")
.HasColumnType("INTEGER");
b.Property<bool>("AllowFriendRequest")
.HasColumnType("INTEGER");
b.Property<bool>("AllowMessage")
.HasColumnType("INTEGER");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId", "Platform")
.IsUnique();
b.ToTable("NotificationPrefs");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ClearedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("ObjectiveProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("TEXT");
b.Property<string>("LastIp")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PendingDevices");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Caption")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("TaggedPlayerIdsCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("UploaderPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeen")
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Elo")
.HasColumnType("INTEGER");
b.Property<int>("GameMode")
.HasColumnType("INTEGER");
b.Property<int>("Losses")
.HasColumnType("INTEGER");
b.Property<int>("MatchesPlayed")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("TEXT");
b.Property<string>("BannerImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("Birthday")
.HasColumnType("TEXT");
b.Property<bool>("CanReceiveInvites")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedFromDeviceId")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DisplayEmoji")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("IdentityFlags")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCommunityTeam")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeveloper")
.HasColumnType("INTEGER");
b.Property<bool>("IsJunior")
.HasColumnType("INTEGER");
b.Property<bool>("IsVerified")
.HasColumnType("INTEGER");
b.Property<string>("LastIp")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("LastPlatform")
.HasColumnType("INTEGER");
b.Property<string>("LastPlatformId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PersonalPronouns")
.HasColumnType("INTEGER");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ProfileImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("Reputation")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("XP")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Capacity")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("EndsAt")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("StartsAt")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("EventId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("AcquiredAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("ItemSlug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CustomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsCurated")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsJuniors")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("VisitorCount")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Cheered")
.HasColumnType("INTEGER");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DataBlob")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("OwnerPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("PhotonRegion")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PhotonRoomId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("RoomCode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("PrivateInstanceId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("InvitedAt")
.HasColumnType("TEXT");
b.Property<long?>("LatestInviteMessageId")
.HasColumnType("INTEGER");
b.HasKey("PrivateInstanceId", "PlayerId");
b.HasIndex("PlayerId");
b.ToTable("PrivateInstanceInvitees");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("Favorited")
.HasColumnType("INTEGER");
b.Property<bool>("Ignored")
.HasColumnType("INTEGER");
b.Property<bool>("Muted")
.HasColumnType("INTEGER");
b.Property<long>("RequesterId")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<long>("TargetId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RequesterId", "TargetId")
.IsUnique();
b.ToTable("Relationships");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Category")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("GameSessionId")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long>("ReporterPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("ResolutionNote")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("TEXT");
b.Property<long?>("ResolverAdminId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long>("TargetEventId")
.HasColumnType("INTEGER");
b.Property<long>("TargetInventionId")
.HasColumnType("INTEGER");
b.Property<long>("TargetPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BanType")
.HasColumnType("INTEGER");
b.Property<long>("BannedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("BannedPlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<Guid?>("CurrencyId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<Guid>("ConcurrencyCode")
.HasColumnType("TEXT");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Balance")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("DailyLimit")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Amount")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomCurrencyId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<byte[]>("Bytes")
.HasColumnType("BLOB");
b.Property<string>("ReferencedFilenamesCsv")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<long?>("SubRoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("UploadedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Accessibility")
.HasColumnType("INTEGER");
b.Property<bool>("AllowNewUsers")
.HasColumnType("INTEGER");
b.Property<bool>("AllowsJuniors")
.HasColumnType("INTEGER");
b.Property<int>("CheerCount")
.HasColumnType("INTEGER");
b.Property<bool>("CloningAllowed")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("CurrentDataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("CustomRoomWarning")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool>("DisableMicAutoMute")
.HasColumnType("INTEGER");
b.Property<int>("FavoriteCount")
.HasColumnType("INTEGER");
b.Property<bool>("HiddenFromBrowse")
.HasColumnType("INTEGER");
b.Property<double>("HotScore")
.HasColumnType("REAL");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsAGRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsDormRoom")
.HasColumnType("INTEGER");
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
.HasColumnType("INTEGER");
b.Property<bool>("IsStudioRoom")
.HasColumnType("INTEGER");
b.Property<string>("LoadScreensJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("LocationReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MaxCapacity")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayerCalculationMode")
.HasColumnType("INTEGER");
b.Property<int>("MinLevel")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<string>("PromoExternalContentJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<string>("PromoImagesJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<int>("RoomWarningMask")
.HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<string>("StudioSessionId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsMobile")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsScreens")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsVRLow")
.HasColumnType("INTEGER");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("INTEGER");
b.Property<string>("TagsCsv")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VisitCount")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(174)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<string>("ReplicationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("PaidPrice")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("TEXT");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Accepted")
.HasColumnType("INTEGER");
b.Property<DateTime>("GrantedAt")
.HasColumnType("TEXT");
b.Property<long?>("GrantedByPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CanMatchmakeInto")
.HasColumnType("INTEGER");
b.Property<string>("DataBlobName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("DataModifiedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsSandbox")
.HasColumnType("INTEGER");
b.Property<int>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<int>("OrderIndex")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<string>("RoomSceneLocationId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("StudioAssetBundleNamesCsv")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.Property<long?>("StudioSubRoomDataSaveId")
.HasColumnType("INTEGER");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("FirstVisitAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastVisitAt")
.HasColumnType("TEXT");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChestsOpened")
.HasColumnType("INTEGER");
b.Property<DateTime>("CompletedAt")
.HasColumnType("TEXT");
b.Property<bool>("CustomGame")
.HasColumnType("INTEGER");
b.Property<int>("HealthPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("SecondsAlive")
.HasColumnType("INTEGER");
b.Property<int>("SecondsInAir")
.HasColumnType("INTEGER");
b.Property<int>("ShieldPotionsConsumed")
.HasColumnType("INTEGER");
b.Property<bool>("WalkGame")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CompletedAt");
b.ToTable("RoyaleMatches");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("MatchId")
.HasColumnType("INTEGER");
b.Property<int>("NumEliminations")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("CurrentLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<int>("NextLevelAcornReward")
.HasColumnType("INTEGER");
b.Property<long>("NextLevelXPThreshold")
.HasColumnType("INTEGER");
b.Property<long>("PlayerId")
.HasColumnType("INTEGER");
b.Property<int>("RankIdx")
.HasColumnType("INTEGER");
b.Property<string>("RankName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("TotalXP")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("RoyalePlayerProgress");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("AllAvatarItemsOwned")
.HasColumnType("INTEGER");
b.Property<string>("BaseRoomNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CharadesSlotBindingsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CircuitsV2LimitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DiscoveredGameConfigsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("GlobalFriendsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("PlayMenuTagsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("ProfanityFilterDisabled")
.HasColumnType("INTEGER");
b.Property<string>("RecCenterDoorsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("RoomBlobVersionClampDisabled")
.HasColumnType("INTEGER");
b.Property<bool>("SignupsDisabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("WeeklyChallengeRewardJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("WeeklyChallengesCompletedRequired")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("WeeklyChallengesJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.HasKey("Id");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatedByPlayerId")
.HasColumnType("INTEGER");
b.Property<string>("Descriptor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("RedeemedAt")
.HasColumnType("TEXT");
b.Property<long?>("RedeemedByPlayerId")
.HasColumnType("INTEGER");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("SignupCodes");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("AvailableUntil")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<bool>("IsLimitedTime")
.HasColumnType("INTEGER");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Storefront")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("SubscriberPlayerId")
.HasColumnType("INTEGER");
b.Property<long>("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<long>("Pk")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssignedPlayerIdsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AssignedPlayerNamesJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CommentsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("JiraBugUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("JiraUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("MinNumAssignedPlayers")
.HasColumnType("INTEGER");
b.Property<string>("RoomName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint?>("TestPassId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("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<uint>("Id")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndDate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("TagsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("WasManuallyClosed")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TestPasses");
});
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<long>("CreatorPlayerId")
.HasColumnType("INTEGER");
b.Property<int>("CurrencyType")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsFeatured")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("Price")
.HasColumnType("INTEGER");
b.Property<Guid>("PublicId")
.HasColumnType("TEXT");
b.Property<long>("RoomId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DorkNet.Server.Migrations
{
/// <inheritdoc />
public partial class PlayerProfile2023Fields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BannerImageName",
table: "Players",
type: "TEXT",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DisplayEmoji",
table: "Players",
type: "TEXT",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<int>(
name: "IdentityFlags",
table: "Players",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "PersonalPronouns",
table: "Players",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
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");
}
}
}
@@ -1819,6 +1819,10 @@ namespace DorkNet.Server.Migrations
b.Property<DateTime?>("BannedUntil")
.HasColumnType("TEXT");
b.Property<string>("BannerImageName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.IsRequired()
.HasMaxLength(512)
@@ -1842,6 +1846,11 @@ namespace DorkNet.Server.Migrations
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DisplayEmoji")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
@@ -1851,6 +1860,9 @@ namespace DorkNet.Server.Migrations
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("IdentityFlags")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
@@ -1892,6 +1904,9 @@ namespace DorkNet.Server.Migrations
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("PersonalPronouns")
.HasColumnType("INTEGER");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("TEXT");
@@ -3234,6 +3249,10 @@ namespace DorkNet.Server.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CircuitsV2LimitsJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DiscoveredGameConfigsJson")
.IsRequired()
.ValueGeneratedOnAdd()
+46
View File
@@ -0,0 +1,46 @@
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Services;
/// <summary>
/// Single projection from <see cref="PlayerEntity"/> 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.
/// </summary>
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>(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;
}
}
@@ -0,0 +1,109 @@
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// OAuth device-authorization grant as the 2023 client drives it.
///
/// 1. The device that wants to sign in calls <c>connect/deviceauthorization</c>
/// and shows the player the <c>user_code</c> (reader
/// <c>RecNet.Runtime/AMPFGJPKHOD.txt:399-598</c>: device_code, user_code,
/// verification_uri(_complete), expires_in, interval).
/// 2. It polls <c>connect/token</c> with
/// <c>grant_type=urn:ietf:params:oauth:grant-type:device_code</c> and
/// <c>device_code</c> (<c>LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451</c>).
/// A 400 whose <c>error</c> is <c>authorization_pending</c> or
/// <c>slow_down</c> means "keep polling"
/// (<c>LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116</c>); anything else
/// ends the attempt.
/// 3. A signed-in client approves the code with
/// <c>account/me/remoteauth</c> (form field <c>code</c>).
///
/// State lives in one PlayerSettings row per authorization, owned by the system
/// account: key <c>deviceauthorization:{device_code}</c>, value
/// <c>{user_code}|{expiresAtUtc:O}</c>, plus <c>|{approverAccountId}</c> once
/// approved. Redeeming deletes the row, so a device code is single-use.
/// </summary>
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<Created> 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);
}
/// <summary>Bind the pending authorization whose user code is
/// <paramref name="userCode"/> to <paramref name="approverId"/>. False when
/// no live, unapproved authorization carries that code.</summary>
public static async Task<bool> 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;
}
/// <summary>One poll from the device. Approved authorizations are consumed
/// and yield the approving account.</summary>
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);
}
+31 -4
View File
@@ -10,9 +10,27 @@ namespace DorkNet.Server.Services;
public sealed class JoinTimeoutService(
PlayerPresenceService presence,
NotificationService notifications,
PresenceBroadcastService fanout,
ILogger<JoinTimeoutService> logger)
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
/// <summary>
/// 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.
/// </summary>
public static TimeSpan Timeout { get; } = TimeSpan.FromMinutes(3);
private readonly ConcurrentDictionary<long, PendingJoin> _pending = new();
public void MarkPending(long playerId, RoomInstanceDto? room, bool deferPresenceCommit = false)
@@ -40,6 +58,10 @@ public sealed class JoinTimeoutService(
_ = WatchAsync(playerId, pending);
}
/// <summary>Drop the player's pending join without touching presence —
/// the client abandoned it (matchmake/none, logout).</summary>
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);
+53 -47
View File
@@ -170,45 +170,25 @@ public class NotificationService(
}
}
/// <summary>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.</summary>
/// <summary>Push fresh account payloads after a profile mutation.
/// The owner gets <c>SelfAccountUpdate</c> (private self DTO) and every
/// connected client gets <c>AccountUpdate</c> (public DTO). The 2023
/// client subscribes to both strings (RecNet.Runtime
/// OPMAPIOEIFG.txt:267 "AccountUpdate" → Action&lt;Account&gt;, :279
/// "SelfAccountUpdate" → Action&lt;SelfAccount&gt;) 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.</summary>
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 <c>{}</c> body — which silently
/// drops the presence update at the watch.</summary>
public async Task PlayerPresenceChanged(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room)
public async Task PlayerPresenceChanged(
IEnumerable<long> 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);
}
/// <summary>The PresenceUpdate envelope for one player, as a JSON string.
///
/// <paramref name="isOnline"/> 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.</summary>
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<long> friendIds, long playerId, RoomInstanceDto? room) =>
@@ -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));
@@ -48,6 +48,7 @@ public class PlayerPresenceService
private readonly ConcurrentDictionary<long, DateTimeOffset> _localRoomExpires = new();
private readonly ConcurrentDictionary<long, DateTimeOffset> _localActiveExpires = new();
private readonly ConcurrentDictionary<long, DateTimeOffset> _localBootDormGraceExpires = new();
private readonly ConcurrentDictionary<long, DateTimeOffset> _localNoRoomExpires = new();
private readonly IConnectionMultiplexer? _redis;
private readonly ILogger<PlayerPresenceService>? _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 ?? "<null>", 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 ?? "<null>");
}
@@ -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 _);
}
}
/// <summary>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.</summary>
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 _);
}
/// <summary>
/// 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
/// <c>matchmake/none</c> at :13279): it swaps its cached presence to the
/// local offline instance (RoomInstanceId -1) and posts matchmake/none,
/// ignoring the reply (<c>&lt;TryGoToOfflineRoom&gt;b__119_0</c> 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 = <c>RoomInstanceId &lt; 0</c>) and is
/// only in sync while the server answers <c>roomInstance: null</c>. 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.
/// </summary>
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);
}
/// <summary>True while the player is online with no room after
/// <see cref="SetNoRoom"/>. Cleared by the next <see cref="SetRoom"/> or
/// <see cref="Clear"/>.</summary>
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;
}
/// <summary>Keep the no-room marker alive while the player keeps
/// heartbeating from the offline room.</summary>
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);
}
/// <summary>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 ─────────────────────────
@@ -0,0 +1,188 @@
using DorkNet.Server.Data;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Services;
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// 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 <c>/goto</c> 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 <c>isOnline</c> 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.
/// </summary>
public sealed class PresenceBroadcastService(
IServiceScopeFactory scopes,
PlayerPresenceService presence,
OnlinePresenceService connections,
NotificationService notifications,
ILogger<PresenceBroadcastService> logger)
{
/// <summary>PlayerSettings key for the persisted
/// <c>/player/statusvisibility</c> choice.</summary>
public const string StatusVisibilityPrefKey = "pref:statusVisibility";
/// <summary>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 <c>isOnline</c> 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.</summary>
public bool IsOnline(long playerId) =>
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
/// <summary>Push the player's current presence (online state + committed
/// room) to every friend.</summary>
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
/// <summary>Push an offline frame regardless of any lingering markers —
/// for an explicit logout.</summary>
public Task PublishOfflineAsync(long playerId) => PublishAsync(playerId, forceOffline: true);
/// <summary>
/// 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.
/// </summary>
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<DorkNetDbContext>();
var settings = scope.ServiceProvider.GetRequiredService<ServerSettingsService>();
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<long>();
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);
}
}
/// <summary>Split a player's friends into those who may see their real
/// presence and those who must see them offline, per their status
/// visibility. <paramref name="favorited"/> holds the friends the player
/// favorited; it only matters for FavoriteFriendsOnly.</summary>
public static (List<long> VisibleTo, List<long> HiddenFrom) SplitRecipients(
IReadOnlyList<long> friendIds, int visibility, IReadOnlySet<long> favorited) =>
visibility switch
{
PresenceVisibility.Offline => (new List<long>(), friendIds.ToList()),
PresenceVisibility.FavoriteFriendsOnly => (
friendIds.Where(favorited.Contains).ToList(),
friendIds.Where(id => !favorited.Contains(id)).ToList()),
_ => (friendIds.ToList(), new List<long>()),
};
/// <summary>Stored status visibility for one player (0 = default).</summary>
public static async Task<int> 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;
}
/// <summary>Stored status visibility for many players in one query.
/// Players with no stored choice are absent from the result.</summary>
public static async Task<Dictionary<long, int>> GetStatusVisibilitiesAsync(
DorkNetDbContext db, IReadOnlyCollection<long> playerIds)
{
if (playerIds.Count == 0) return new Dictionary<long, int>();
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<long, int>(rows.Count);
foreach (var row in rows)
{
if (int.TryParse(row.Value, out var v) && PresenceVisibility.IsValid(v)) result[row.PlayerId] = v;
}
return result;
}
}
+366 -6
View File
@@ -19,7 +19,6 @@ public class RoomDataBlobService
PersistedRoomVersion.LatestVersion;
private readonly RoomRoleCollectionData _allPermsRoleData = BuildAllPermsRoleData();
private readonly byte[] _rroEditableBlob = BuildRroEditableBlob();
private readonly RoomRoleCollectionData _rroEditableRoleData = BuildRroEditableRoleData();
/// <summary>The blob served when a room_&lt;id&gt;_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);
/// <summary>The blob served for a baked Rec Room Original whose blob name
/// misses S3 (nothing saved yet). <paramref name="activityId"/> is the
/// room's <c>LocationReplicationId</c> — a real save carries it as
/// <c>activity_id</c> (Rec Center's saved blob: activity_id ==
/// cbad71af-…, its location id).</summary>
public byte[] GetRroEditableBlob(string activityId) =>
BuildRroEditableBlob(_rroEditableRoleData, activityId, "Room");
/// <param name="roomName">Name of the room's default game role (Rec
/// Center's is "Rec Center", Stunt Runner's "StuntRunner").</param>
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()
/// <summary>Shape of the stub matters: the March-2023 client fetches this
/// through <c>RecNet.Rooms.GetRoomData</c> 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):
///
/// • <c>version</c> was stamped <c>PersistedRoomVersion.LatestVersion</c>
/// (19) — past the client's ceiling of <see cref="Client2023MaxPersistedRoomVersion"/>
/// (16). The S3-hit path clamps that; the miss path never did.
/// • it lacked the skeleton every real save carries: <c>last_save_time</c>,
/// <c>activity_id</c> (= the room's LocationReplicationId), an empty
/// <c>connectable_graph_data</c>, <c>scene_settings_data</c>,
/// <c>object_model_data</c> and the four <c>room_mood_*</c> 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.</summary>
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_<ENUM> → 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);
}
/// <summary>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 <c>SpawnableTool_Runtime_*</c> 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.</summary>
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;
}
/// <summary>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).</summary>
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<byte>());
// 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<byte>());
using (var node = new MemoryStream(4))
{
AppendVarint(node, 1, (ulong)nodeId);
AppendLengthDelimited(view, 25, node.ToArray());
}
AppendLengthDelimited(view, 41, Array.Empty<byte>());
return view.ToArray();
}
public sealed record UploadedBlobSummary(int ViewsIn, int ViewsKept, int RoleViews, int GameRuleViews,
int VersionIn, int VersionOut, bool CircuitsStripped, string ActivityId);
/// <summary>Turn a real <c>PersistedRoomData</c> save (any client
/// version) into something the March-2023 client can load as room
/// <paramref name="activityId"/>'s blob:
/// • field 4 <c>activity_id</c> is rewritten to the target room's
/// location id (real saves carry their own room's);
/// • field 5 <c>output_log</c>, field 6 <c>sub_room_id</c> and every
/// field the 2023 schema does not know (&gt; 36) are dropped;
/// • with <paramref name="basicsOnly"/>, 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.</summary>
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<byte>(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));
}
/// <summary>Does this PersistenceViewData carry a GameRoleNode (field 50)
/// or a game rule (40 game_configuration_data / 41 game_rule_wrapper_data)?</summary>
private static (bool IsRole, bool IsRule) ClassifyView(ReadOnlySpan<byte> 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");
/// <summary>One <c>PersistenceViewData</c> 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).</summary>
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<byte>()); // 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<byte>()); // 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<byte>()); break;
}
}
return role.ToArray();
}
private static RoomRoleCollectionData BuildRroEditableRoleData()
@@ -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<CircuitsV2LimitSettings> GetCircuitsV2LimitsAsync()
{
var row = await GetAsync();
return ToCircuitsV2Limits(row);
}
public async Task<CircuitsV2LimitSettings> 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<PlayMenuTagSettings> SetPlayMenuTagsAsync(
@@ -663,9 +682,86 @@ public class ServerSettingsService(DorkNetDbContext db)
SplitTestSegmentProbabilities: "{}",
UpdatedAt: DateTime.UtcNow);
/// <summary>
/// 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):
/// <list type="bullet">
/// <item>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.</item>
/// <item>Chips 3000 → 30000: the room's chip cap ("Too many
/// chips.").</item>
/// <item>CloudValues 100 → 1000: the room's cloud-variable cap.</item>
/// <item>UnculledChips / VisibleChips 500 → 3000: chips past these
/// counts get culled, so every chip of a retail-sized room stays
/// visible.</item>
/// <item>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.</item>
/// </list>
/// 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.
/// </summary>
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);
/// <summary>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.</summary>
private const int UnlimitedBudget = 1_000_000_000;
/// <summary>Time windows size a <c>new int[N]</c> 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.</summary>
private const int MaxTimeWindowFrames = 3600;
/// <summary>Play-menu carousel order. <c>PlayMenuCarouselModel.TryGetDisplayPriorityMap</c>
/// 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); <c>PlayMenuCarouselController.DisplayOrderPriorityImpl</c>
/// 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.</summary>
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<GameConfigurationSetting> ToGameConfigurations(
RecCenterDoorSettings doors,
DiscoveredGameConfigSettings discovered)
DiscoveredGameConfigSettings discovered,
CircuitsV2LimitSettings circuits)
{
var rows = new List<GameConfigurationSetting>();
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);
}
/// <summary>
/// Clamps the limits to what the client can actually survive. Four of
/// these are crash guards rather than taste:
/// <list type="bullet">
/// <item>Rpcs of 0 divides by zero in the CircuitsV2 debug subscreen,
/// which renders <c>RpcRecentMax / Rpcs</c>.</item>
/// <item>A time window sizes a per-frame ring buffer, so an
/// unbounded value is an out-of-memory kill at circuit init.</item>
/// <item>Chips sizes four list preallocations in the chip-culling
/// manager, and InkPerChip × CloudValues is an unchecked int multiply,
/// so both are capped.</item>
/// <item>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.</item>
/// </list>
/// </summary>
private static CircuitsV2LimitSettings NormalizeCircuitsV2Limits(
CircuitsV2LimitSettings settings)
{
var seen = new HashSet<int>();
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<Chip>(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<CircuitsV2LimitSettings>(
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);
/// <summary>
/// One entry of the <c>UGC.CircuitsV2.Limit.Network</c> array. The client
/// sorts entries ascending by <see cref="NumPlayersForActivation"/> 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 <c>LimitConfig.NetworkConfig</c>.
/// </summary>
public sealed record CircuitsV2NetworkLimit(
int NumPlayersForActivation,
int Bytes,
int BytesTimeWindowFrame60s,
int Rpcs,
int RpcTimeWindowFrame60s);
/// <summary>
/// The <c>UGC.CircuitsV2.Limit.*</c> gameconfig family, read by
/// <c>RecRoom.CircuitsV2.Config.LimitConfig</c>. Every field maps to one
/// key; the client falls back to its baked-in defaults for any key the
/// server omits or sends unparseably.
/// </summary>
public sealed record CircuitsV2LimitSettings(
List<CircuitsV2NetworkLimit> 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,
+41 -2
View File
@@ -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);
}
}
@@ -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)
@@ -234,6 +234,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton<PlayerPresenceService>();
services.AddSingleton<JoinTimeoutService>();
services.AddSingleton<OnlinePresenceService>();
// Friend presence fan-out; resolves its scoped DB per push.
services.AddSingleton<PresenceBroadcastService>();
services.AddSingleton<PlayerLogService>();
// S3-compatible object storage (Garage in production, MinIO/disk in dev)
// — holds profile images + room-blob bytes. Stateless wrapper around
+140 -3
View File
@@ -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 }
/>
<span>Cloning allowed</span>
</label>
<label className="flex items-center gap-2 text-sm" title="What the client reads as IsRRO. On: load the baked scene from the client's assets and consult the client's built-in per-original table (which forbids creation in Rec Royale). Off: treat as a custom room — roles and blob decide. Flip a CLONE of an original off to test editable Rec Royale.">
<input
type="checkbox"
checked={isAGRoom}
onChange={e => setIsAGRoom(e.target.checked)}
/>
<span>Rec Room Original (IsRRO) — baked scene + client creation table</span>
</label>
<div className="flex gap-2 pt-2 border-t border-ink-800">
<button onClick={save} disabled={busy} className="btn-primary text-xs">
{busy ? 'Saving…' : 'Save changes'}
@@ -563,6 +574,14 @@ interface SubRoom {
canMatchmakeInto: boolean;
isSandbox: boolean;
dataBlobName: string;
/** What the client is actually told to download: scene blob, else room
* blob, else synthetic default — or '' for a baked Rec Room Original
* with no blob enabled (scene geometry only, no Maker Pen / chips). */
effectiveBlobName: string;
isBakedOriginal: boolean;
/** No RoomScenes row exists yet (seeded baked originals); the entry
* scene is synthesised on read and created by "Enable Maker Pen blob". */
isVirtual?: boolean;
}
// Per-sub-room player caps. Distinct from the room's own MaxCapacity, which is
@@ -576,6 +595,60 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
const { data, loading, error, refresh } = useApi<SubRoom[]>(`/rooms/${roomId}/subrooms`);
const [edits, setEdits] = useState<Record<number, string>>({});
const [saving, setSaving] = useState<number | null>(null);
const [blobBusy, setBlobBusy] = useState<number | null>(null);
const [resetArmed, setResetArmed] = useState<number | null>(null);
// Baked Rec Room Originals ship with no data blob, so the client never
// runs its room-data path there (no Maker Pen, no CV2 chips, no saves).
// "Enable" stamps the synthetic default name the CDN already answers with
// the RRO-editable blob; "Reset" clears the pointers back to scene-only.
// Attach a real PersistedRoomData save (any client version) to a sub-room:
// the server rewrites it for this room (activity id, 2023 version clamp,
// drops output log / sub-room id / unknown fields; "basics" keeps only the
// game-role + game-rule objects and strips circuits).
const [uploadMode, setUploadMode] = useState<'full' | 'basics'>('basics');
const uploadBlob = async (sub: SubRoom, file: File) => {
setBlobBusy(sub.subRoomId);
try {
const fd = new FormData();
fd.append('file', file, file.name);
const res = await api<{ dataBlobName: string; summary: { viewsIn: number; viewsKept: number; versionIn: number; versionOut: number } }>(
`/rooms/${roomId}/subrooms/${sub.subRoomId}/blob/upload?mode=${uploadMode}`,
{ method: 'POST', formData: fd, timeoutMs: 120000 },
);
toast.push(
`Uploaded ${res.dataBlobName}: ${res.summary.viewsKept}/${res.summary.viewsIn} objects kept, version ${res.summary.versionIn} → ${res.summary.versionOut}`,
'success',
);
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBlobBusy(null);
}
};
const setBlob = async (sub: SubRoom, action: 'enable' | 'reset') => {
setBlobBusy(sub.subRoomId);
try {
const res = await api<{ effectiveBlobName: string; changed: boolean }>(
`/rooms/${roomId}/subrooms/${sub.subRoomId}/blob/${action}`,
{ method: 'POST' },
);
toast.push(
action === 'enable'
? `${sub.name || `Sub-room ${sub.subRoomId}`} now loads ${res.effectiveBlobName}`
: `${sub.name || `Sub-room ${sub.subRoomId}`} reset to the baked scene (no blob)`,
'success',
);
setResetArmed(null);
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBlobBusy(null);
}
};
const save = async (sub: SubRoom) => {
const raw = edits[sub.subRoomId];
@@ -631,8 +704,71 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
{sub.isSandbox && <span className="badge-neutral">Sandbox</span>}
{!sub.canMatchmakeInto && <span className="badge-banned">No matchmaking</span>}
</div>
{sub.dataBlobName && (
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">{sub.dataBlobName}</div>
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">
{sub.effectiveBlobName
? sub.effectiveBlobName
: sub.isBakedOriginal
? 'no data blob — baked scene only (no Maker Pen / chips)'
: '(synthetic default blob)'}
</div>
{sub.isBakedOriginal && (
<div className="flex flex-wrap items-center gap-2 mt-1.5">
<label className="text-xs text-ink-400">Upload save</label>
<select
value={uploadMode}
onChange={e => setUploadMode(e.target.value as 'full' | 'basics')}
className="input text-xs py-0.5 w-40"
title="basics = keep only the game-role and game-rule objects and strip circuits (a newer CircuitsV2 graph is rejected by the 2023 client); full = keep every object"
>
<option value="basics">basics (roles + game rule)</option>
<option value="full">full (every object)</option>
</select>
<input
type="file"
accept=".binpb,.room,.dat,.bin"
disabled={blobBusy === sub.subRoomId}
onChange={e => {
const f = e.target.files?.[0];
if (f) uploadBlob(sub, f);
e.target.value = '';
}}
className="text-xs"
/>
</div>
)}
{sub.isBakedOriginal && (
<div className="flex items-center gap-2 mt-1.5">
{!sub.dataBlobName ? (
<button
onClick={() => setBlob(sub, 'enable')}
disabled={blobBusy === sub.subRoomId}
className="btn-secondary text-xs"
title="Serve a room data blob for this baked sub-room so Maker Pen, CV2 chips and saves work. The first in-game save replaces it with a real upload."
>
{blobBusy === sub.subRoomId ? 'Enabling…' : 'Enable Maker Pen blob'}
</button>
) : resetArmed === sub.subRoomId ? (
<>
<span className="text-xs text-danger">Drops the blob pointer; saved blobs stay restorable.</span>
<button
onClick={() => setBlob(sub, 'reset')}
disabled={blobBusy === sub.subRoomId}
className="btn-danger text-xs"
>
{blobBusy === sub.subRoomId ? 'Resetting…' : 'Confirm reset'}
</button>
<button onClick={() => setResetArmed(null)} className="btn-secondary text-xs">Cancel</button>
</>
) : (
<button
onClick={() => setResetArmed(sub.subRoomId)}
className="btn-secondary text-xs"
title="Go back to the baked scene with no data blob (escape hatch if a save broke the room)."
>
Reset to baked scene
</button>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
@@ -647,8 +783,9 @@ function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: n
/>
<button
onClick={() => save(sub)}
disabled={!dirty || saving === sub.subRoomId}
disabled={!dirty || saving === sub.subRoomId || sub.isVirtual}
className="btn-primary text-xs"
title={sub.isVirtual ? 'This room has no sub-room row yet — enable the blob first' : undefined}
>
{saving === sub.subRoomId ? 'Saving…' : 'Save'}
</button>
+238 -1
View File
@@ -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<ServerSettings | null>(null);
const [weekly, setWeekly] = useState<WeeklyChallengeSettings | null>(null);
const [gameConfigs, setGameConfigs] = useState<DiscoveredGameConfigSettings | null>(null);
const [circuits, setCircuits] = useState<CircuitsV2LimitSettings | null>(null);
const [rewardOptions, setRewardOptions] = useState<RewardOptions>({
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<ServerSettings>('/settings'),
get<WeeklyChallengeSettings>('/settings/weekly-challenges'),
get<RewardOptions>('/settings/weekly-challenges/reward-options'),
get<DiscoveredGameConfigSettings>('/settings/gameconfigs'),
get<CircuitsV2LimitSettings>('/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<CircuitsV2LimitSettings>) => {
setCircuits((current) => current ? { ...current, ...patch } : current);
};
const updateCircuitsNetwork = (index: number, patch: Partial<CircuitsV2NetworkLimit>) => {
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<CircuitsV2LimitSettings>('/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 = (
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
@@ -665,6 +722,17 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
/>
)}
{circuits && (
<CircuitsV2LimitsPanel
settings={circuits}
busy={circuitsBusy}
onChange={updateCircuits}
onNetworkChange={updateCircuitsNetwork}
onSave={() => saveCircuits(false)}
onReset={() => saveCircuits(true)}
/>
)}
{weekly && (
<div className="card !p-5 max-w-5xl mt-5">
<div className="flex flex-wrap items-start justify-between gap-4">
@@ -1068,19 +1136,168 @@ function DiscoveredGameConfigsPanel({
);
}
function CircuitsV2LimitsPanel({
settings,
busy,
onChange,
onNetworkChange,
onSave,
onReset,
}: {
settings: CircuitsV2LimitSettings;
busy: boolean;
onChange: (patch: Partial<CircuitsV2LimitSettings>) => void;
onNetworkChange: (index: number, patch: Partial<CircuitsV2NetworkLimit>) => void;
onSave: () => void;
onReset: () => void;
}) {
const number = (key: keyof CircuitsV2LimitSettings, value: string) => {
const n = Number.parseFloat(value);
onChange({ [key]: Number.isFinite(n) ? n : 0 } as Partial<CircuitsV2LimitSettings>);
};
const netNumber = (index: number, key: keyof CircuitsV2NetworkLimit, value: string) => {
const n = Number.parseInt(value, 10);
onNetworkChange(index, { [key]: Number.isFinite(n) ? n : 0 } as Partial<CircuitsV2NetworkLimit>);
};
return (
<div className="card !p-5 max-w-5xl mt-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-ink-50">CircuitsV2 limits</h2>
<p className="mt-1 text-xs text-ink-400">
Served as the <span className="font-mono">UGC.CircuitsV2.Limit.*</span> gameconfig keys.
Retail budgets a circuit 5 RPCs per frame and 1024 bytes per second and silently drops
everything past that; DorkNet ships both effectively unlimited, and raises the room-size
limits below about 10x past retail. Clients read these once
when circuits start, so players must rejoin their room to pick up a change.
</p>
<p className="mt-2 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={onReset} disabled={busy} className="btn-secondary text-xs">
<RefreshCw />
Defaults
</button>
<button onClick={onSave} disabled={busy} className="btn-primary text-xs">
{busy ? 'Saving...' : 'Save'}
</button>
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
{settings.network.map((entry, index) => (
<div key={index} className="rounded-lg border border-ink-800 bg-ink-950/30 p-4 lg:col-span-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">
Network budget{settings.network.length > 1 ? ` — from ${entry.numPlayersForActivation} players` : ''}
</h3>
<p className="mt-1 text-[11px] text-ink-500">
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.
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<NumberConfig
label="Rpcs"
value={entry.rpcs}
onChange={(v) => netNumber(index, 'rpcs', v)}
/>
<NumberConfig
label="RpcTimeWindowFrame60s"
value={entry.rpcTimeWindowFrame60s}
onChange={(v) => netNumber(index, 'rpcTimeWindowFrame60s', v)}
/>
<NumberConfig
label="Bytes"
value={entry.bytes}
onChange={(v) => netNumber(index, 'bytes', v)}
/>
<NumberConfig
label="BytesTimeWindowFrame60s"
value={entry.bytesTimeWindowFrame60s}
onChange={(v) => netNumber(index, 'bytesTimeWindowFrame60s', v)}
/>
</div>
</div>
))}
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Circuit budget</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<NumberConfig
label="Compute"
value={settings.compute}
hint="Per-frame CPU budget for all circuits in the room. Retail 50000. Keep it finite: it is what stops a runaway loop. Max 10,000,000."
onChange={(v) => number('compute', v)}
/>
<NumberConfig
label="Chips"
value={settings.chips}
hint="Room chip cap. Retail 3000. Max 1,000,000."
onChange={(v) => number('chips', v)}
/>
<NumberConfig
label="UnculledChips"
value={settings.unculledChips}
hint="Chips past this count get culled. Retail 500. Higher costs frame time."
onChange={(v) => number('unculledChips', v)}
/>
<NumberConfig
label="VisibleChips"
value={settings.visibleChips}
hint="Visible-chip limit. Retail 500. Above Chips, adding a chip also charges InkPerChip."
onChange={(v) => number('visibleChips', v)}
/>
<NumberConfig
label="InkPerChip"
value={settings.inkPerChip}
hint="Ink COST per chip against the fixed 30000-ink room cap. Retail 15; 0 = chips use no ink. Max 30,000."
onChange={(v) => number('inkPerChip', v)}
/>
</div>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Cloud variables</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<NumberConfig
label="CloudValues"
value={settings.cloudValues}
hint="Room cloud-variable cap. Retail 100. Max 10,000."
onChange={(v) => number('cloudValues', v)}
/>
<NumberConfig
label="CloudValuesAutosaveSeconds"
value={settings.cloudValuesAutosaveSeconds}
hint="Seconds between player save-data autosaves. Retail 300. Lower means more save traffic."
onChange={(v) => number('cloudValuesAutosaveSeconds', v)}
/>
</div>
</div>
</div>
</div>
);
}
function NumberConfig({
label,
value,
hint,
onChange,
}: {
label: string;
value: number;
hint?: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<span className="label font-mono normal-case tracking-normal">{label}</span>
<input className="input mt-1" type="number" value={value} onChange={(e) => onChange(e.target.value)} />
{hint && <span className="mt-1 block text-[11px] text-ink-500">{hint}</span>}
</label>
);
}
@@ -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,
+166 -65
View File
@@ -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 "<your-photon-realtime-app-id>" `
-PhotonVoiceAppId "<your-photon-voice-app-id>"
The 2023.03.21 PC build is laid out like this:
```
<install>/
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)
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.
From this branch:
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`:
```pwsh
dotnet build .\DorkNet.ClientMod\DorkNet.ClientMod.csproj `
-p:TargetFrameworks=net8.0 `
-p:MelonLoaderDir="C:\path\to\LemonLoader\melon_data\MelonLoader"
$env:DOTNET_ROOT = "C:\Program Files\dotnet"
dotnet build .\DorkNet.ClientMod -c Release
Copy-Item .\DorkNet.ClientMod\bin\Release\DorkNet.ClientMod.dll "<install>\Mods\"
```
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.
Close the game first; the copy fails while `RecRoom.exe` holds the DLL.
3. Copy `DorkNet.ClientMod\dorknet-clientmod.json.template` to
`<install>\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.
Quest notes:
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.
- 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.
> `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
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/<timestamp>_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.
---
+157
View File
@@ -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:
+3 -1
View File
@@ -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`).
---
+255
View File
@@ -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<Chip>(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 `<RpcRecentMax> / <Rpcs>`; 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` |
+101 -41
View File
@@ -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<long,uint>` from the method, but the
wire payload is `List<UnreadRoomComments>` — a `Func<List<...>, 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<Int32>, continuation `<GetPricingConfig>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/<id> 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<System.String>, 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<BNDIAONDFFF> closure adds JoinMode / ClientJoinData / AdditionalPlayersAutoFollow / SubRoomId. Response: JSON -> Matchmaking/PCFNLCMMGKB {errorCode:HMIBNIEBEKK enum, roomInstance:HCHDEHIGEBE} -> mapped by <>c.<GoToHelper>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<IFNGBPJGINL> — 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<String>) — 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<Int32> (MPBLNLMCEDL.txt:6922, same generic token as itemOwnershipLimit) continued by `<GetPricingConfig>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<BGNNOMBFMLH>. 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<CustomAvatarItemRefDto> 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<System.Boolean> — 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<Boolean>` return type as the wire type and switched the body to a bare boolean. That was wrong: the request is sent as FDKKOPAPDGF<DJMHAFPGLLN> (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<Int32> (MPBLNLMCEDL.txt:6922, the same generic token as the bare-Int32 itemOwnershipLimit call at :2981), and the continuation is `<GetPricingConfig>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 |
+68
View File
@@ -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_<ENUM>` 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
+223
View File
@@ -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<T>` 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<float>.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<int>` 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<T,U,V>`) 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("<name>", 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<T,U,V>`), 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.
+29
View File
@@ -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))