mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6605f61047 | |||
| d7b1722abd | |||
| 806b160acc | |||
| 3edeaa5484 | |||
| 27314c1335 | |||
| 36c5dd5ca9 | |||
| 258183fb94 | |||
| cf3aa29ba2 | |||
| c8a7068070 |
@@ -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;
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1642,7 +1642,21 @@ public class RoomsModerationController(
|
||||
|
||||
var newBlob = CloneBlobName(cloneId, orderIndex);
|
||||
var (dstBucket, dstKey) = BlobRouter.Route(newBlob);
|
||||
await storage.PutAsync(dstBucket, dstKey, bytes, "application/octet-stream");
|
||||
try
|
||||
{
|
||||
await storage.PutAsync(dstBucket, dstKey, bytes, "application/octet-stream");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Same reasoning as the read side: the clone row is already
|
||||
// committed, so a storage outage must degrade to an empty
|
||||
// sub-room, not a 500 that leaves a failed-looking clone still
|
||||
// pointing at the source's data.
|
||||
logger.LogWarning(ex,
|
||||
"[room-clone] could not write blob {Blob}; room {Room} sub-room {Sub} starts empty",
|
||||
newBlob, cloneId, orderIndex);
|
||||
return null;
|
||||
}
|
||||
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
|
||||
@@ -654,13 +654,28 @@ public class StorefrontsBuyController(
|
||||
$"buyPurchaseReminder:{item.Slug}");
|
||||
var giftId = await GrantWithGiftBoxAsync(pid, item, currencyType);
|
||||
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
// Upsert: (PlayerId, Key) is unique and the same reminder can be
|
||||
// bought again — a blind Add threw "UNIQUE constraint failed" and
|
||||
// turned the second purchase into a 500 AFTER the currency had
|
||||
// already been taken and the gift saved.
|
||||
var progressKey = $"purchaseReminder:{req.PurchaseReminderId}";
|
||||
var progress = await db.ObjectiveProgress
|
||||
.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == progressKey);
|
||||
if (progress is null)
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"purchaseReminder:{req.PurchaseReminderId}",
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = progressKey,
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
progress.IsCompleted = true;
|
||||
progress.ClearedAt = DateTime.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(PurchaseReminderResponse(newBalance, currencyType, item.Slug, giftId));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
@@ -808,8 +809,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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -396,14 +396,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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3654
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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_<id>_v1.dat misses S3
|
||||
@@ -37,7 +36,20 @@ public class RoomDataBlobService
|
||||
}
|
||||
|
||||
public byte[] GetDefaultBlob() => _defaultBlob;
|
||||
public byte[] GetRroEditableBlob() => _rroEditableBlob;
|
||||
public byte[] GetRroEditableBlob() => GetRroEditableBlob(string.Empty);
|
||||
|
||||
/// <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 (> 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()
|
||||
|
||||
@@ -906,10 +906,24 @@ 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"" = '';",
|
||||
};
|
||||
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 +954,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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -204,6 +204,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
|
||||
|
||||
+118
@@ -179,6 +179,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:
|
||||
|
||||
@@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user