mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 975753deac | |||
| cebe7ae3ee | |||
| 0c899a1001 | |||
| 34d4c9d115 | |||
| 6ee1d6f9b3 | |||
| 36e64e99ba | |||
| f2e7532853 | |||
| 1b3edf008d | |||
| cf3aa29ba2 |
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -88,6 +88,36 @@ public class RecNetAccount
|
||||
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// 2023-03-21 account reader (RecNet.Runtime CICAKEDCJLA / MJKDDBKGNOP /
|
||||
// AEGCKMKICFG) registers these keys in three casings each, e.g.
|
||||
// "DisplayEmoji"/"displayEmoji"/"displayemoji" (CICAKEDCJLA.txt:1398-1550).
|
||||
// Missing keys default on the client, which is why profile emoji,
|
||||
// banner, pronouns and identity flags never rendered for anyone.
|
||||
private string displayEmoji = string.Empty;
|
||||
private string bannerImage = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayEmoji")]
|
||||
public string DisplayEmoji
|
||||
{
|
||||
get => displayEmoji;
|
||||
set => displayEmoji = value ?? string.Empty;
|
||||
}
|
||||
|
||||
[JsonPropertyName("bannerImage")]
|
||||
public string BannerImage
|
||||
{
|
||||
get => bannerImage;
|
||||
set => bannerImage = value ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Pronoun flags enum, serialized as its Int32 value.</summary>
|
||||
[JsonPropertyName("personalPronouns")]
|
||||
public int PersonalPronouns { get; set; }
|
||||
|
||||
/// <summary>Identity flags enum, serialized as its Int32 value.</summary>
|
||||
[JsonPropertyName("identityFlags")]
|
||||
public int IdentityFlags { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -112,6 +142,13 @@ public class RecNetSelfAccount : RecNetAccount
|
||||
|
||||
[JsonPropertyName("parentAccountId")]
|
||||
public int? ParentAccountId { get; set; } = null;
|
||||
|
||||
// 2023 self-account reader key (CICAKEDCJLA.txt:1310). An absent key
|
||||
// defaults to 0, which the settings UI renders as "no username changes
|
||||
// left". DorkNet does not rate-limit username changes, so report a
|
||||
// generous allowance.
|
||||
[JsonPropertyName("availableUsernameChanges")]
|
||||
public int AvailableUsernameChanges { get; set; } = 99;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*.sh text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
@@ -0,0 +1,9 @@
|
||||
# Operator secrets and build outputs — never commit.
|
||||
dorknet-quest.cfg
|
||||
*.keystore
|
||||
*.so
|
||||
build/
|
||||
*_generated.h
|
||||
|
||||
# The resolved per-build target tables ARE committed (targets/<ver>-<code>.json);
|
||||
# only the generated C header is ignored.
|
||||
@@ -0,0 +1,215 @@
|
||||
# DorkNet.Quest
|
||||
|
||||
Sideload-patches the **Meta Quest** build of Rec Room **2023.03.21**
|
||||
(versionName `20230317`, versionCode `2128210`) to connect to a DorkNet server.
|
||||
|
||||
It is the Quest counterpart of `DorkNet.ClientMod` (the PC MelonLoader mod) plus
|
||||
`tools/patch-client.ps1` (the PC Photon byte-patch). Android/IL2CPP has no
|
||||
MelonLoader, so everything is done in **one injected native library**
|
||||
(`libdorknet.so`): it is added as a `DT_NEEDED` entry on `libmain.so` so the
|
||||
loader maps it before Unity, then it installs arm64 inline hooks at addresses
|
||||
resolved from the build's own `dump.cs`.
|
||||
|
||||
This build reports appVersion **`20230317`** — the *same* version the PC client
|
||||
reports — so a Quest player patched with this tool shares rooms, presence and
|
||||
Photon shards with PC DorkNet players with no server-side version change.
|
||||
|
||||
> This ships **no** Rec Room code or assets. You supply your own extracted store
|
||||
> APK and OBB. It disables the client's certificate pinning and image-signature
|
||||
> checks so a self-hosted server works; do not redistribute game binaries.
|
||||
|
||||
## What it does
|
||||
|
||||
| Hook | Effect | PC equivalent |
|
||||
| --- | --- | --- |
|
||||
| `HTTPManager.SendRequest` | rewrite `<svc>.rec.net` → `<svc>.<ServerHost>`, or into a `{service}` template such as `{service}-dev.example.com` for hyphen-style hosts (the name server supplies every other URL) | `ClientMod` UriPatches |
|
||||
| Photon `AppSettings` factory | inject the operator's Realtime/Voice/Chat app ids and fixed region (`PhotonRegion`, must match the PC clients) | `tools/patch-client.ps1` |
|
||||
| `PlatformSettings.get_AppID` / `get_MobileAppID` | return the operator's Meta app id (only when set) | — |
|
||||
| `UnityTlsContext.VerifyCallback` + BouncyCastle `NotifyServerCertificate` | accept the self-hosted TLS chain | `ClientMod` TlsPatches |
|
||||
| three certificate-pin verifiers | accept the chain (pins never match a self-hosted host) | `ClientMod` DisableTLSPinning |
|
||||
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
|
||||
| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash |
|
||||
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
|
||||
| Oculus `ovr_*` platform calls | (no-Meta build only) steer the entitlement/login path to the client's own credential screen | — |
|
||||
|
||||
### Two builds, selected by `OculusAppId`
|
||||
|
||||
- **Set** — the *Meta build*: Meta's real entitlement + login run against the
|
||||
app you registered; the platform hooks stay disarmed. Players need a Meta
|
||||
account, and your package + signing cert must be registered with that app.
|
||||
- **Blank** — the *no-Meta-account build*: the platform path is steered past
|
||||
Meta's checks to Rec Room's own username/password screen. It authenticates
|
||||
nothing — every headset sends the placeholder platform user id `1`. DorkNet's
|
||||
server treats platform id `1` (and `0`/blank) as a non-identity and keys
|
||||
accounts by `deviceId` instead (`PlayerService.IsPlaceholderPlatformId`), so
|
||||
players are **not** merged into one account and are never offered each other's
|
||||
"remembered logins". **Never** add a server rule that accepts platform id `1`
|
||||
as an identity.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Android NDK** r27c-class (`ANDROID_NDK`, or `ndk/<ver>` under
|
||||
`ANDROID_SDK_ROOT`) — compiles the hook library against bionic.
|
||||
- **Android SDK build-tools** (`zipalign`, `apksigner`) — `ANDROID_SDK_ROOT`.
|
||||
- **apktool** `apktool.jar` (`APKTOOL_JAR`, or next to `ANDROID_SDK_ROOT`).
|
||||
- **JDK** (`keytool` for the debug keystore; apksigner needs a JRE).
|
||||
- **Python 3** with **LIEF** (`pip install lief`) — the `DT_NEEDED` injection.
|
||||
- **[Il2CppDumper](https://github.com/Perfare/Il2CppDumper)** — to produce
|
||||
`dump.cs` for the target APK (see below).
|
||||
- Your own **store APK** (`com.AgainstGravity.RecRoom`, versionCode `2128210`)
|
||||
and its matching **OBB**.
|
||||
|
||||
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
|
||||
> can't write under `Documents`. `build.ps1` builds in `%TEMP%` by default;
|
||||
> keep the work dir outside protected folders.
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
# 1. Dump the target build (Il2CppDumper), once, into a scratch dir:
|
||||
dotnet Il2CppDumper.dll `
|
||||
<extracted>\lib\arm64-v8a\libil2cpp.so `
|
||||
<extracted>\assets\bin\Data\Managed\Metadata\global-metadata.dat `
|
||||
<scratch>\dump
|
||||
|
||||
# 2. Configure:
|
||||
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
|
||||
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
|
||||
|
||||
# 3. Build the signed APK:
|
||||
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
|
||||
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
|
||||
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
|
||||
DorkNet.Quest\scripts\build.ps1 `
|
||||
-Apk C:\path\to\RecRoom-store-2128210.apk `
|
||||
-DumpCs <scratch>\dump\dump.cs `
|
||||
-Obb C:\path o\main.2128210.com.AgainstGravity.RecRoom.obb
|
||||
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
|
||||
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with -Obb)
|
||||
```
|
||||
|
||||
With `-Obb`, the build also runs `scripts/package.ps1`, which writes a release
|
||||
folder ready to hand to players:
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `DorkNet-20230317.xapk` | standard XAPK: `manifest.json`, the APK, the OBB at its install path, `icon.png` |
|
||||
| `install.cmd` / `install.ps1` | one-command install on Windows (double-click `install.cmd`) |
|
||||
| `install.sh` | one-command install on macOS / Linux |
|
||||
| `README.txt` | player instructions |
|
||||
|
||||
To package an APK you already built, run `scripts/package.ps1 -Apk <apk> -Obb <obb>`.
|
||||
The package contains game binaries from your store copy, so only share it with
|
||||
people who own the game.
|
||||
|
||||
`build.ps1` chains: resolve targets → generate headers → compile
|
||||
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
|
||||
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
|
||||
zipalign → apksigner (v2+v3). It generates a fresh debug keystore in the work
|
||||
dir on first run. **Keep that keystore** — Meta ties tokens to app id + package
|
||||
+ signing cert, so re-signing with a different key breaks any Meta registration.
|
||||
It is never committed.
|
||||
|
||||
### Rebasing onto a different build
|
||||
|
||||
Obfuscated Assembly-CSharp names are re-randomised each release but shared with
|
||||
the PC build of the same release, so the PC decompile is the oracle. For a new
|
||||
build:
|
||||
|
||||
1. Dump it with Il2CppDumper.
|
||||
2. Re-run `scripts/resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so
|
||||
--version-name <v> --version-code <c> --objdump <ndk>/llvm-objdump` and
|
||||
confirm every target resolves, is ≥ 20 bytes, and has a real frame-setup
|
||||
prologue. Adjust `targets/spec.json` signatures if a name/shape changed
|
||||
(cross-check against the PC decompile first).
|
||||
3. Commit the new `targets/<v>-<c>.json`.
|
||||
|
||||
The library refuses to arm any target whose 20-byte fingerprint does not match
|
||||
the running `libil2cpp.so`, so a table can never be applied to the wrong build.
|
||||
|
||||
## Install
|
||||
|
||||
From a release folder, one command does everything (finds adb and the headset,
|
||||
installs the APK, copies the OBB to the right path, skips the OBB if it is
|
||||
already there, and launches the app):
|
||||
|
||||
```powershell
|
||||
.\install.cmd # Windows (or double-click it)
|
||||
./install.sh # macOS / Linux
|
||||
```
|
||||
|
||||
Both take an optional package path, `-Serial`/`--serial` for a specific headset
|
||||
and `-NoLaunch`/`--no-launch`. SideQuest and other XAPK installers can open the
|
||||
`.xapk` directly. The app shows up as `AppName` (default **DorkNet**) under
|
||||
Library > Unknown Sources.
|
||||
|
||||
By hand:
|
||||
|
||||
```sh
|
||||
PKG=net.dorknet.RecRoom # your PackageName
|
||||
VC=2128210
|
||||
adb uninstall $PKG 2>/dev/null
|
||||
adb install -r dorknet-recroom-20230317.apk
|
||||
adb shell mkdir -p /sdcard/Android/obb/$PKG
|
||||
adb push main.2128210.com.AgainstGravity.RecRoom.obb \
|
||||
/sdcard/Android/obb/$PKG/main.$VC.$PKG.obb
|
||||
```
|
||||
|
||||
The on-device OBB filename **must** be `main.<versionCode>.<package>.obb` —
|
||||
Unity rebuilds that exact path at runtime, so the source file's own name is
|
||||
irrelevant, only the destination is.
|
||||
|
||||
## Check it on the headset
|
||||
|
||||
**Wear the headset** — off-head it sleeps within seconds and never finishes
|
||||
init, which looks like a crash.
|
||||
|
||||
```sh
|
||||
adb logcat -c
|
||||
adb shell am start -n net.dorknet.RecRoom/com.unity3d.player.UnityPlayerActivity
|
||||
adb logcat -d -s DORKNET
|
||||
```
|
||||
|
||||
A healthy boot logs: `DorkNet.Quest hooks loaded`, `init: build 20230317/...`,
|
||||
every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<host>/`,
|
||||
`unitytls verify: forcing 0x8 -> SUCCESS`, `[http<-] 200 ...`, and
|
||||
`photon appsettings: RT=... Voice=... Chat=...`.
|
||||
|
||||
| Symptom | Likely cause |
|
||||
| --- | --- |
|
||||
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
|
||||
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.ps1`, don't hand-zip |
|
||||
| boots to an empty world | OBB missing or named for the wrong versionCode |
|
||||
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
|
||||
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
|
||||
| multiplayer never connects | Photon app ids not set in the cfg |
|
||||
| Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` |
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `src/dorknet_quest.cpp` | all hooks + init |
|
||||
| `src/config.h` / `config.cpp` | compile-time defaults + runtime cfg loader |
|
||||
| `src/third_party/And64InlineHook/` | vendored arm64 inline hooker (MIT, © Rprop) |
|
||||
| `targets/spec.json` | target signatures (class header + method), build-independent |
|
||||
| `targets/<ver>-<code>.json` | resolved RVAs + fingerprints for one build |
|
||||
| `scripts/resolve_targets.py` | dump.cs + libil2cpp.so → target table (+ prologue check) |
|
||||
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
|
||||
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
|
||||
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
|
||||
| `scripts/build.ps1` | one-shot: store APK → signed patched APK (+ release package with `-Obb`) |
|
||||
| `scripts/package.ps1` | patched APK + OBB → `.xapk` + installers |
|
||||
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
|
||||
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
|
||||
|
||||
## Credits
|
||||
|
||||
- [RecFlare](https://github.com/recflare) by djdevin. Their Quest client
|
||||
showed the way this patcher is built: one native hook library loaded
|
||||
through a `DT_NEEDED` entry on `libmain.so`, and which client checks need
|
||||
hooking. They target a newer build (20230414), so every hook target here
|
||||
was re-derived from the 20230317 build; no RecFlare code is included.
|
||||
- [And64InlineHook](https://github.com/Rprop/And64InlineHook) © Rprop (MIT) —
|
||||
vendored under `src/third_party/`.
|
||||
- [Il2CppDumper](https://github.com/Perfare/Il2CppDumper), [LIEF](https://lief.re).
|
||||
@@ -0,0 +1,74 @@
|
||||
# DorkNet.Quest build configuration.
|
||||
#
|
||||
# Copy to dorknet-quest.cfg (git-ignored) and edit. scripts/build.ps1 bakes
|
||||
# these into libdorknet.so at compile time. Omit a key to keep the built-in
|
||||
# default. This same file can be pushed to
|
||||
# /sdcard/Android/data/<PackageName>/files/dorknet-quest.cfg
|
||||
# to override on-device without a rebuild.
|
||||
|
||||
# ---- deployment ----
|
||||
|
||||
# The apex your DorkNet server is reachable at. The client's service hosts are
|
||||
# rewritten onto it: api.rec.net -> api.<ServerHost>, auth.rec.net ->
|
||||
# auth.<ServerHost>, ns.rec.net -> ns.<ServerHost>, and so on. The ns response
|
||||
# then supplies every other service URL. Your DNS must resolve each subdomain
|
||||
# (ns, api, auth, match, chat, cdn, config, ...) and the TLS cert must cover
|
||||
# them. No default -- required.
|
||||
#
|
||||
# For a server on DorkNet's hyphen host style (Domain:HostStyle = hyphen, e.g.
|
||||
# ns-dev.example.com), give a template instead: ServerHost = {service}-dev.example.com
|
||||
ServerHost = example.com
|
||||
|
||||
# Android package name for the patched build, so it coexists with the store
|
||||
# app and uses its own OBB path. Keep this stable: Meta ties platform tokens to
|
||||
# package + signing cert, and the runtime-config path derives from it.
|
||||
PackageName = net.dorknet.RecRoom
|
||||
|
||||
# Name shown in the headset library (Unknown Sources). Build-time only.
|
||||
AppName = DorkNet
|
||||
|
||||
# ---- Meta (Oculus) platform ----
|
||||
|
||||
# Your registered Meta app id. Set it for the Meta build: real entitlement and
|
||||
# Meta login run against an app your players own. Leave BLANK for the
|
||||
# no-Meta-account build -- the platform entitlement/login path is steered to the
|
||||
# client's own username/password screen. Blank sends platform id 1 from every
|
||||
# headset; DorkNet's server treats that as a placeholder and keys accounts by
|
||||
# device id (see PlayerService.IsPlaceholderPlatformId), so players are NOT
|
||||
# merged. Never add a server rule that accepts platform id 1 as an identity.
|
||||
OculusAppId =
|
||||
|
||||
# ---- Photon ----
|
||||
|
||||
# Your own Photon app ids (dashboard.photonengine.com). Rec Room's originals
|
||||
# reject external clients. Leave an id blank to keep the client's own (dead)
|
||||
# value -- multiplayer will not connect until these are set. No defaults.
|
||||
PhotonAppId =
|
||||
PhotonVoiceAppId =
|
||||
PhotonChatAppId =
|
||||
|
||||
# Optional self-hosted Photon name server (blank / 0 = Photon Cloud).
|
||||
PhotonNameServer =
|
||||
PhotonNameServerPort = 0
|
||||
|
||||
# Photon Cloud region. Must match the PC clients' PhotonCloudRegion (ClientMod
|
||||
# config), or Quest and PC players connect to different masters and never meet.
|
||||
PhotonRegion = us
|
||||
|
||||
# ---- client behaviour ----
|
||||
|
||||
# Accept the self-hosted TLS chain and skip Rec Room's certificate pinning.
|
||||
# Required for a self-hosted server; turn off only if you front it with a cert
|
||||
# chain the client's bundled CA set trusts AND you reproduce Rec Room's pins.
|
||||
EnableTlsTrustBypass = true
|
||||
|
||||
# Accept image Content-Signatures the server signs with its own key.
|
||||
AcceptUnsignedImages = true
|
||||
|
||||
# ---- diagnostics (adb logcat -s DORKNET) ----
|
||||
|
||||
LogHttp = true
|
||||
# WARNING: Debug logs request/response BODIES, including auth tokens and
|
||||
# passwords. Leave off except when debugging, and don't share the logs.
|
||||
Debug = false
|
||||
LogHttpBodyMax = 10000
|
||||
@@ -0,0 +1,248 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build a DorkNet-patched Rec Room Quest APK from a pristine store APK.
|
||||
|
||||
.DESCRIPTION
|
||||
End to end:
|
||||
1. resolve_targets.py -> per-build target table (RVA + fingerprint)
|
||||
2. gen_targets_header.py -> targets_generated.h
|
||||
3. gen_config_header.py -> config_generated.h (from your cfg)
|
||||
4. NDK clang++ -> libdorknet.so (arm64)
|
||||
5. apktool decode -> working project
|
||||
6. package rename + manifest/version edits
|
||||
7. drop libdorknet.so in, DT_NEEDED it into libmain.so (LIEF)
|
||||
8. apktool build -> zipalign -> apksigner -> the signed output APK
|
||||
|
||||
Nothing game-derived is written into the repo. The work directory defaults to
|
||||
a temp path because Windows Controlled Folder Access blocks the NDK linker and
|
||||
apksigner from writing under Documents; override with -WorkDir if needed.
|
||||
|
||||
You supply the store APK and (for install) the matching OBB. No game binaries
|
||||
ship with this tool.
|
||||
|
||||
.PARAMETER Apk
|
||||
Path to the pristine store APK (com.AgainstGravity.RecRoom, versionCode
|
||||
2128210 / version 20230317).
|
||||
|
||||
.PARAMETER Config
|
||||
dorknet-quest.cfg with your ServerHost / Photon ids / package name. Defaults
|
||||
to dorknet-quest.cfg next to the repo folder; falls back to the .example.
|
||||
|
||||
.PARAMETER WorkDir
|
||||
Scratch build directory (must be outside Controlled-Folder-Access-protected
|
||||
locations). Defaults to $env:TEMP\dorknet-quest-build.
|
||||
|
||||
.PARAMETER OutApk
|
||||
Where to write the finished APK. Default: <WorkDir>\dorknet-recroom-<ver>.apk.
|
||||
|
||||
.PARAMETER NdkRoot
|
||||
Android NDK root. Default: $env:ANDROID_NDK, else the NDK under $env:ANDROID_SDK_ROOT\ndk.
|
||||
|
||||
.PARAMETER BuildToolsDir
|
||||
Android SDK build-tools dir holding zipalign + apksigner. Default: resolved
|
||||
from $env:ANDROID_SDK_ROOT.
|
||||
|
||||
.PARAMETER Obb
|
||||
The store OBB matching the APK. When given, package.ps1 also bundles the
|
||||
result into an installable .xapk with one-command installers.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Apk,
|
||||
[string]$Config,
|
||||
[string]$WorkDir = (Join-Path $env:TEMP 'dorknet-quest-build'),
|
||||
[string]$OutApk,
|
||||
[string]$NdkRoot,
|
||||
[string]$BuildToolsDir,
|
||||
[string]$ApktoolJar,
|
||||
[string]$DumpCs,
|
||||
[string]$ApiLevel = '24',
|
||||
[string]$Obb
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$RepoDir = Split-Path -Parent $PSScriptRoot
|
||||
$Scripts = $PSScriptRoot
|
||||
$OldPkg = 'com.AgainstGravity.RecRoom'
|
||||
$SoName = 'libdorknet.so'
|
||||
|
||||
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
|
||||
|
||||
# ---- resolve tools ------------------------------------------------------
|
||||
if (-not $NdkRoot) {
|
||||
$NdkRoot = $env:ANDROID_NDK
|
||||
if (-not $NdkRoot -and $env:ANDROID_SDK_ROOT) {
|
||||
$cand = Join-Path $env:ANDROID_SDK_ROOT 'ndk'
|
||||
if (Test-Path $cand) { $NdkRoot = (Get-ChildItem $cand -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName }
|
||||
}
|
||||
}
|
||||
if (-not $NdkRoot) { throw "Android NDK not found. Pass -NdkRoot or set ANDROID_NDK / ANDROID_SDK_ROOT." }
|
||||
$Cxx = Join-Path $NdkRoot "toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android$ApiLevel-clang++.cmd"
|
||||
$ObjDump = Join-Path $NdkRoot 'toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-objdump.exe'
|
||||
Need $Cxx 'NDK aarch64 clang++'
|
||||
|
||||
if (-not $BuildToolsDir) {
|
||||
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
|
||||
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
|
||||
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
|
||||
}
|
||||
$ZipAlign = Join-Path $BuildToolsDir 'zipalign.exe'
|
||||
$ApkSigner = Join-Path $BuildToolsDir 'apksigner.bat'
|
||||
Need $ZipAlign 'zipalign'
|
||||
Need $ApkSigner 'apksigner'
|
||||
|
||||
if (-not $ApktoolJar) {
|
||||
$ApktoolJar = if ($env:APKTOOL_JAR) { $env:APKTOOL_JAR } elseif ($env:ANDROID_SDK_ROOT) { Join-Path $env:ANDROID_SDK_ROOT 'apktool.jar' } else { '' }
|
||||
}
|
||||
Need $ApktoolJar 'apktool.jar'
|
||||
Need $Apk 'store APK'
|
||||
|
||||
if (-not $Config) {
|
||||
$c = Join-Path $RepoDir 'dorknet-quest.cfg'
|
||||
$Config = if (Test-Path $c) { $c } else { Join-Path $RepoDir 'dorknet-quest.cfg.example' }
|
||||
}
|
||||
Need $Config 'config'
|
||||
Write-Host "Using config: $Config"
|
||||
|
||||
$PkgName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*PackageName\s*=' } | Select-Object -First 1)
|
||||
$PkgName = if ($PkgName) { ($PkgName -split '=', 2)[1].Trim() } else { 'net.dorknet.RecRoom' }
|
||||
$AppName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*AppName\s*=' } | Select-Object -First 1)
|
||||
$AppName = if ($AppName) { ($AppName -split '=', 2)[1].Split('#')[0].Trim() } else { '' }
|
||||
if (-not $AppName) { $AppName = 'DorkNet' }
|
||||
|
||||
# Resolve the launcher to its FULL exe path -- "& py ..." would otherwise match
|
||||
# the (case-insensitive) function name and recurse. Prefer py -3.14, else python.
|
||||
$PyCmd = (Get-Command py.exe -ErrorAction SilentlyContinue)
|
||||
if ($PyCmd) { $PyExe = $PyCmd.Source; $PyPre = @('-3.14', '-I') }
|
||||
else { $PyExe = (Get-Command python.exe -ErrorAction Stop).Source; $PyPre = @('-I') }
|
||||
function Invoke-Py { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$a)
|
||||
$pa = $PyPre + $a
|
||||
& $PyExe @pa
|
||||
if ($LASTEXITCODE) { throw "python failed: $($a -join ' ')" } }
|
||||
|
||||
# ---- work dirs ----------------------------------------------------------
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
||||
$Gen = Join-Path $WorkDir 'gen'
|
||||
$Project = Join-Path $WorkDir 'project'
|
||||
$Extract = Join-Path $WorkDir 'extract'
|
||||
New-Item -ItemType Directory -Force -Path $Gen, $Extract | Out-Null
|
||||
|
||||
# ---- 1. extract + dump + resolve targets --------------------------------
|
||||
Step 'extracting libil2cpp.so + global-metadata.dat'
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $Apk))
|
||||
try {
|
||||
foreach ($e in @('lib/arm64-v8a/libil2cpp.so', 'assets/bin/Data/Managed/Metadata/global-metadata.dat', 'lib/arm64-v8a/libmain.so')) {
|
||||
$entry = $zip.GetEntry($e); if (-not $entry) { throw "APK missing $e" }
|
||||
$dest = Join-Path $Extract (Split-Path $e -Leaf)
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true)
|
||||
}
|
||||
} finally { $zip.Dispose() }
|
||||
$Il2Cpp = Join-Path $Extract 'libil2cpp.so'
|
||||
$LibMain = Join-Path $Extract 'libmain.so'
|
||||
|
||||
if (-not $DumpCs) {
|
||||
# Prefer a dump.cs the operator points at; otherwise require one. Dumping
|
||||
# is left to Il2CppDumper (see README) to keep this script toolchain-light.
|
||||
throw "Pass -DumpCs <dump.cs> from Il2CppDumper for this APK (see DorkNet.Quest/README.md)."
|
||||
}
|
||||
Need $DumpCs 'dump.cs'
|
||||
|
||||
Step 'resolving hook targets'
|
||||
$verName = '20230317'; $verCode = '2128210'
|
||||
# Write the resolved table into the work dir (Controlled Folder Access can
|
||||
# block writes under Documents). The repo keeps a committed copy under targets/.
|
||||
$table = Join-Path $WorkDir "$verName-$verCode.json"
|
||||
Invoke-Py @((Join-Path $Scripts 'resolve_targets.py'), '--dump', $DumpCs, '--il2cpp', $Il2Cpp,
|
||||
'--version-name', $verName, '--version-code', $verCode, '--objdump', $ObjDump, '--out', $table)
|
||||
|
||||
Step 'generating headers'
|
||||
Invoke-Py @((Join-Path $Scripts 'gen_targets_header.py'), $table, (Join-Path $Gen 'targets_generated.h'))
|
||||
Invoke-Py @((Join-Path $Scripts 'gen_config_header.py'), $Config, $PkgName, (Join-Path $Gen 'config_generated.h'))
|
||||
|
||||
# ---- 4. compile libdorknet.so -------------------------------------------
|
||||
Step 'compiling libdorknet.so (arm64)'
|
||||
$src = @(
|
||||
(Join-Path $RepoDir 'src\dorknet_quest.cpp'),
|
||||
(Join-Path $RepoDir 'src\config.cpp'),
|
||||
(Join-Path $RepoDir 'src\third_party\And64InlineHook\And64InlineHook.cpp')
|
||||
)
|
||||
$So = Join-Path $WorkDir $SoName
|
||||
& $Cxx '-std=c++17' '-fPIC' '-shared' '-O2' '-fvisibility=hidden' '-static-libstdc++' `
|
||||
'-ffunction-sections' '-fdata-sections' '-Wl,--gc-sections' `
|
||||
"-I$Gen" "-I$(Join-Path $RepoDir 'src')" $src '-llog' '-o' $So
|
||||
if ($LASTEXITCODE) { throw 'compile failed' }
|
||||
Need $So 'libdorknet.so'
|
||||
|
||||
# ---- 5. apktool decode --------------------------------------------------
|
||||
Step 'apktool decode'
|
||||
if (Test-Path $Project) { Remove-Item -Recurse -Force $Project }
|
||||
& java -jar $ApktoolJar decode -f -o $Project (Resolve-Path $Apk)
|
||||
if ($LASTEXITCODE) { throw 'apktool decode failed' }
|
||||
|
||||
# ---- 6. package rename + manifest -------------------------------------
|
||||
Step "renaming package $OldPkg -> $PkgName"
|
||||
$manifest = Join-Path $Project 'AndroidManifest.xml'
|
||||
$m = Get-Content -Raw $manifest
|
||||
$m = $m.Replace($OldPkg, $PkgName)
|
||||
if ($m -notmatch 'android.intent.category.LAUNCHER') {
|
||||
$m = $m.Replace('<category android:name="android.intent.category.INFO"/>',
|
||||
'<category android:name="android.intent.category.INFO"/><category android:name="android.intent.category.LAUNCHER"/>')
|
||||
}
|
||||
Set-Content -NoNewline -Path $manifest -Value $m
|
||||
|
||||
# The launcher label comes from @string/app_name; set it in every locale.
|
||||
Step "naming the app '$AppName'"
|
||||
$labelXml = [System.Security.SecurityElement]::Escape($AppName).Replace("'", "\'")
|
||||
foreach ($sx in Get-ChildItem (Join-Path $Project 'res') -Filter strings.xml -Recurse) {
|
||||
$s = Get-Content -Raw -Encoding utf8 $sx.FullName
|
||||
$n = [regex]::Replace($s, '<string name="app_name">[^<]*</string>', "<string name=`"app_name`">$labelXml</string>")
|
||||
if ($n -ne $s) { Set-Content -NoNewline -Encoding utf8 -Path $sx.FullName -Value $n }
|
||||
}
|
||||
|
||||
# ---- 7. inject the hook library ----------------------------------------
|
||||
Step 'injecting libdorknet.so'
|
||||
Copy-Item $So (Join-Path $Project 'lib\arm64-v8a' $SoName) -Force
|
||||
$projMain = Join-Path $Project 'lib\arm64-v8a\libmain.so'
|
||||
Invoke-Py @((Join-Path $Scripts 'inject_needed.py'), $projMain, $SoName)
|
||||
|
||||
# ---- 8. rebuild + align + sign -----------------------------------------
|
||||
Step 'apktool build'
|
||||
$unsigned = Join-Path $WorkDir 'unsigned.apk'
|
||||
& java -jar $ApktoolJar build -f -o $unsigned $Project
|
||||
if ($LASTEXITCODE) { throw 'apktool build failed' }
|
||||
|
||||
$Keystore = Join-Path $WorkDir 'dorknet-debug.keystore'
|
||||
if (-not (Test-Path $Keystore)) {
|
||||
Step 'generating a fresh debug keystore (kept in the work dir, never committed)'
|
||||
& keytool -genkeypair -v -keystore $Keystore -alias dorknet -keyalg RSA -keysize 2048 `
|
||||
-validity 10000 -storepass dorknet -keypass dorknet -dname 'CN=DorkNet Quest, O=DorkNet'
|
||||
if ($LASTEXITCODE) { throw 'keytool failed' }
|
||||
}
|
||||
|
||||
Step 'zipalign'
|
||||
$aligned = Join-Path $WorkDir 'aligned.apk'
|
||||
& $ZipAlign -f -p 4 $unsigned $aligned
|
||||
if ($LASTEXITCODE) { throw 'zipalign failed' }
|
||||
|
||||
if (-not $OutApk) { $OutApk = Join-Path $WorkDir "dorknet-recroom-$verName.apk" }
|
||||
Step "apksigner -> $OutApk"
|
||||
& $ApkSigner sign --ks $Keystore --ks-key-alias dorknet --ks-pass pass:dorknet --key-pass pass:dorknet `
|
||||
--v2-signing-enabled true --v3-signing-enabled true --out $OutApk $aligned
|
||||
if ($LASTEXITCODE) { throw 'apksigner failed' }
|
||||
& $ApkSigner verify --print-certs $OutApk | Out-Null
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "DONE. Patched APK: $OutApk" -ForegroundColor Green
|
||||
Write-Host "Package: $PkgName versionCode: $verCode" -ForegroundColor Green
|
||||
if ($Obb) {
|
||||
Write-Host ""
|
||||
& (Join-Path $Scripts 'package.ps1') -Apk $OutApk -Obb $Obb -BuildToolsDir $BuildToolsDir
|
||||
} else {
|
||||
Write-Host "Install it, then push the matching OBB to:" -ForegroundColor Green
|
||||
Write-Host " /sdcard/Android/obb/$PkgName/main.$verCode.$PkgName.obb" -ForegroundColor Green
|
||||
Write-Host "(or pass -Obb to bundle both into an installable .xapk)" -ForegroundColor Green
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate config_generated.h from dorknet-quest.cfg.
|
||||
|
||||
Bakes the operator's config into the library at compile time. Keys absent from
|
||||
the cfg are simply not emitted, so the built-in defaults in config.h apply.
|
||||
Secrets (Photon ids, Meta app id) live only in the git-ignored cfg, never in a
|
||||
committed file.
|
||||
|
||||
Usage: gen_config_header.py <dorknet-quest.cfg> <package> <out/config_generated.h>
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# cfg key -> (macro, kind) where kind is "str", "bool" or "int".
|
||||
KEYS = {
|
||||
"ServerHost": ("DN_SERVER_HOST", "str"),
|
||||
"OculusAppId": ("DN_OCULUS_APPID", "str"),
|
||||
"PhotonAppId": ("DN_PHOTON_REALTIME", "str"),
|
||||
"PhotonVoiceAppId": ("DN_PHOTON_VOICE", "str"),
|
||||
"PhotonChatAppId": ("DN_PHOTON_CHAT", "str"),
|
||||
"PhotonNameServer": ("DN_PHOTON_SERVER", "str"),
|
||||
"PhotonNameServerPort": ("DN_PHOTON_PORT", "int"),
|
||||
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
|
||||
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
|
||||
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
|
||||
"LogHttp": ("DN_LOG_HTTP", "bool"),
|
||||
"Debug": ("DN_DEBUG_HTTP", "bool"),
|
||||
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
|
||||
}
|
||||
TRUE = {"true", "1", "yes", "on"}
|
||||
FALSE = {"false", "0", "no", "off"}
|
||||
|
||||
|
||||
def c_string(v):
|
||||
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
raise SystemExit("usage: gen_config_header.py <cfg> <package> <out.h>")
|
||||
cfg, package, out = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3])
|
||||
lines = ["// Generated by scripts/gen_config_header.py -- do not edit, do not commit.",
|
||||
"#ifndef DORKNET_QUEST_CONFIG_GENERATED_H",
|
||||
"#define DORKNET_QUEST_CONFIG_GENERATED_H",
|
||||
f"#define DN_PACKAGE {c_string(package)}", ""]
|
||||
|
||||
if cfg.exists():
|
||||
for raw in cfg.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line or "=" not in line or line.startswith("["):
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
key, val = key.strip(), val.strip()
|
||||
if key not in KEYS or val == "":
|
||||
continue
|
||||
macro, kind = KEYS[key]
|
||||
if kind == "str":
|
||||
lines.append(f"#define {macro} {c_string(val)}")
|
||||
elif kind == "int":
|
||||
lines.append(f"#define {macro} {int(val)}")
|
||||
elif kind == "bool":
|
||||
low = val.lower()
|
||||
if low in TRUE:
|
||||
lines.append(f"#define {macro} true")
|
||||
elif low in FALSE:
|
||||
lines.append(f"#define {macro} false")
|
||||
else:
|
||||
raise SystemExit(f"{key}: not a boolean: {val}")
|
||||
else:
|
||||
print(f"note: {cfg} not found; baking package only, all values default", file=sys.stderr)
|
||||
|
||||
lines += ["", "#endif"]
|
||||
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"wrote {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate targets_generated.h from a resolved target table.
|
||||
|
||||
The header holds each target's RVA, length and 20-byte FNV-1a fingerprint as a
|
||||
named struct field, plus the build's version. The hook library checks the
|
||||
fingerprint against the running libil2cpp.so before arming, so a table can only
|
||||
ever be applied to the build it was resolved from.
|
||||
|
||||
Usage: gen_targets_header.py <targets/NAME-CODE.json> <out/targets_generated.h>
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: gen_targets_header.py <table.json> <out.h>")
|
||||
table = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
ids = list(table["targets"].keys())
|
||||
|
||||
lines = [
|
||||
"// Generated by scripts/gen_targets_header.py -- do not edit.",
|
||||
f"// Build {table['versionName']} / versionCode {table['versionCode']}",
|
||||
f"// libil2cpp.so sha256 {table['il2cppSha256']}",
|
||||
"#ifndef DORKNET_QUEST_TARGETS_GENERATED_H",
|
||||
"#define DORKNET_QUEST_TARGETS_GENERATED_H",
|
||||
"#include <stdint.h>",
|
||||
"",
|
||||
f'#define DN_TARGET_VERSION_NAME "{table["versionName"]}"',
|
||||
f'#define DN_TARGET_VERSION_CODE "{table["versionCode"]}"',
|
||||
f"#define DN_HOOK_BYTES {table['hookBytes']}",
|
||||
"",
|
||||
"typedef struct { const char *id; uint32_t rva; uint64_t fingerprint; uint32_t length; } DnTarget;",
|
||||
"",
|
||||
"typedef struct {",
|
||||
]
|
||||
for tid in ids:
|
||||
lines.append(f" DnTarget {tid};")
|
||||
lines.append("} DnTargets;")
|
||||
lines.append("")
|
||||
lines.append("static const DnTargets dn_targets = {")
|
||||
for tid in ids:
|
||||
e = table["targets"][tid]
|
||||
length = e["length"] if e["length"] is not None else 0
|
||||
lines.append(
|
||||
f' .{tid} = {{ "{tid}", {e["rva"]:#x}u, {e["fingerprint"]}ull, {length}u }},')
|
||||
lines.append("};")
|
||||
lines.append("")
|
||||
lines.append("#endif")
|
||||
Path(sys.argv[2]).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"wrote {sys.argv[2]} ({len(ids)} targets)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add a DT_NEEDED entry to libmain.so so the Android loader pulls in
|
||||
libdorknet.so before Unity starts.
|
||||
|
||||
libmain.so is tiny (the Unity bootstrap shim) and lists libil2cpp etc. as
|
||||
DT_NEEDED; adding one more makes the loader map our hook library first. This is
|
||||
the same mechanism RecFlare uses on Quest. Idempotent: a second run is a no-op.
|
||||
|
||||
Usage: inject_needed.py <libmain.so> <needed-soname> (e.g. libdorknet.so)
|
||||
"""
|
||||
import sys
|
||||
|
||||
import lief
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: inject_needed.py <libmain.so> <soname>")
|
||||
path, soname = sys.argv[1], sys.argv[2]
|
||||
binary = lief.ELF.parse(path)
|
||||
if binary is None:
|
||||
raise SystemExit(f"could not parse {path}")
|
||||
|
||||
existing = [e.name for e in binary.dynamic_entries
|
||||
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
|
||||
if soname in existing:
|
||||
print(f"{soname} already a DT_NEEDED of {path}; nothing to do")
|
||||
return
|
||||
|
||||
binary.add_library(soname)
|
||||
binary.write(path)
|
||||
print(f"added DT_NEEDED {soname} to {path}")
|
||||
|
||||
check = lief.ELF.parse(path)
|
||||
names = [e.name for e in check.dynamic_entries
|
||||
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
|
||||
if soname not in names:
|
||||
raise SystemExit("injection did not stick")
|
||||
print("verified:", ", ".join(names))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
rem One-click installer: runs install.ps1 next to this file.
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" %*
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,145 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Install a DorkNet Rec Room .xapk onto a Meta Quest over adb.
|
||||
|
||||
.DESCRIPTION
|
||||
Installs the APK, copies the OBB to the path the game expects, then launches
|
||||
the app. Works in Windows PowerShell 5.1 and PowerShell 7. Run it with no
|
||||
arguments from the package folder, or double-click install.cmd.
|
||||
|
||||
.PARAMETER Xapk
|
||||
The package to install. Default: the only .xapk next to this script.
|
||||
|
||||
.PARAMETER Serial
|
||||
adb serial of the headset when more than one device is connected.
|
||||
|
||||
.PARAMETER NoLaunch
|
||||
Install only; don't start the app afterwards.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Xapk,
|
||||
[string]$Serial,
|
||||
[switch]$NoLaunch
|
||||
)
|
||||
|
||||
# adb writes progress to stderr; under 'Stop' Windows PowerShell would treat
|
||||
# that as a failure, so exit codes are checked by hand instead.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Set-StrictMode -Version 2
|
||||
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
|
||||
|
||||
function Say($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Fail($m) { Write-Host "ERROR: $m" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# ---- find the package ----------------------------------------------------
|
||||
if (-not $Xapk) {
|
||||
$found = @(Get-ChildItem -Path $PSScriptRoot -Filter *.xapk -File)
|
||||
if ($found.Count -eq 0) { Fail "no .xapk found next to $PSCommandPath; pass -Xapk <file>." }
|
||||
if ($found.Count -gt 1) { Fail "more than one .xapk here; pass -Xapk <file>." }
|
||||
$Xapk = $found[0].FullName
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $Xapk)) { Fail "package not found: $Xapk" }
|
||||
$Xapk = (Resolve-Path -LiteralPath $Xapk).Path
|
||||
|
||||
# ---- find adb -----------------------------------------------------------
|
||||
$adb = $null
|
||||
$cmd = Get-Command adb -ErrorAction SilentlyContinue
|
||||
if ($cmd) { $adb = $cmd.Source }
|
||||
if (-not $adb) {
|
||||
$roots = @($env:ANDROID_SDK_ROOT, $env:ANDROID_HOME, (Join-Path $env:LOCALAPPDATA 'Android\Sdk'), $PSScriptRoot) | Where-Object { $_ }
|
||||
foreach ($r in $roots) {
|
||||
foreach ($c in @((Join-Path $r 'platform-tools\adb.exe'), (Join-Path $r 'adb.exe'))) {
|
||||
if (Test-Path $c) { $adb = $c; break }
|
||||
}
|
||||
if ($adb) { break }
|
||||
}
|
||||
}
|
||||
if (-not $adb) {
|
||||
Fail ("adb not found. Install Android platform-tools from " +
|
||||
"https://developer.android.com/tools/releases/platform-tools and put adb on PATH " +
|
||||
"(or unzip platform-tools next to this script).")
|
||||
}
|
||||
|
||||
# ---- pick the headset ----------------------------------------------------
|
||||
$lines = & $adb devices | Select-Object -Skip 1 | Where-Object { $_ -match '\S' }
|
||||
$ready = @($lines | Where-Object { $_ -match "^(\S+)\s+device$" } | ForEach-Object { ($_ -split '\s+')[0] })
|
||||
$unauth = @($lines | Where-Object { $_ -match '\sunauthorized$' })
|
||||
if ($Serial) {
|
||||
if ($ready -notcontains $Serial) { Fail "device $Serial is not connected and authorised (adb devices)." }
|
||||
} elseif ($ready.Count -eq 1) {
|
||||
$Serial = $ready[0]
|
||||
} elseif ($ready.Count -gt 1) {
|
||||
Fail ("more than one device connected: " + ($ready -join ', ') + ". Pass -Serial <id>.")
|
||||
} elseif ($unauth.Count -gt 0) {
|
||||
Fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
|
||||
} else {
|
||||
Fail 'no headset found. Enable developer mode, connect it over USB, and accept "Allow USB debugging" in the headset.'
|
||||
}
|
||||
function Adb { & $adb -s $Serial @args }
|
||||
$model = (Adb shell getprop ro.product.model | Out-String).Trim()
|
||||
Say "headset: $model ($Serial)"
|
||||
|
||||
# ---- read the package ----------------------------------------------------
|
||||
$zip = [System.IO.Compression.ZipFile]::OpenRead($Xapk)
|
||||
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dorknet-install-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $tmp | Out-Null
|
||||
try {
|
||||
$me = $zip.GetEntry('manifest.json')
|
||||
if (-not $me) { Fail 'not an XAPK: manifest.json missing.' }
|
||||
$sr = New-Object System.IO.StreamReader($me.Open())
|
||||
$manifest = $sr.ReadToEnd() | ConvertFrom-Json
|
||||
$sr.Dispose()
|
||||
$pkg = $manifest.package_name
|
||||
Say "package: $($manifest.name) $pkg $($manifest.version_name) ($($manifest.version_code))"
|
||||
|
||||
function Extract($entry) {
|
||||
$out = Join-Path $tmp ([System.IO.Path]::GetFileName($entry.FullName))
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $out, $true)
|
||||
return $out
|
||||
}
|
||||
|
||||
# ---- APK -------------------------------------------------------------
|
||||
$apkEntry = $zip.GetEntry("$pkg.apk")
|
||||
if (-not $apkEntry) { $apkEntry = @($zip.Entries | Where-Object { $_.FullName -like '*.apk' })[0] }
|
||||
if (-not $apkEntry) { Fail 'no APK inside the package.' }
|
||||
Say 'installing the app'
|
||||
$apkFile = Extract $apkEntry
|
||||
$res = (Adb install -r $apkFile 2>&1 | Out-String)
|
||||
if ($res -match 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match') {
|
||||
Write-Host "An older copy of $pkg is installed with a different signing key." -ForegroundColor Yellow
|
||||
Write-Host "It has to be removed first. Your account lives on the server, but local settings on the headset are lost." -ForegroundColor Yellow
|
||||
$ans = Read-Host 'Uninstall it and continue? [y/N]'
|
||||
if ($ans -notmatch '^(y|yes)$') { Fail 'cancelled.' }
|
||||
Adb uninstall $pkg | Out-Null
|
||||
$res = (Adb install -r $apkFile 2>&1 | Out-String)
|
||||
}
|
||||
if ($res -notmatch 'Success') { Fail ("app install failed:`n" + $res.Trim()) }
|
||||
Remove-Item -Force $apkFile
|
||||
|
||||
# ---- OBB (expansion files) ------------------------------------------
|
||||
foreach ($x in @($manifest.expansions)) {
|
||||
if (-not $x) { continue }
|
||||
$entry = $zip.GetEntry($x.file)
|
||||
if (-not $entry) { Fail "package is missing $($x.file)." }
|
||||
$dest = '/sdcard/' + $x.install_path
|
||||
$have = (Adb shell "stat -c %s '$dest' 2>/dev/null" | Out-String).Trim()
|
||||
if ($have -eq [string]$entry.Length) { Say "game data already on the headset, skipping ($([System.IO.Path]::GetFileName($dest)))"; continue }
|
||||
Say ("copying game data ({0:N2} GB) - this takes a few minutes" -f ($entry.Length / 1GB))
|
||||
$f = Extract $entry
|
||||
Adb shell "mkdir -p '$([System.IO.Path]::GetDirectoryName($dest).Replace('\','/'))'" | Out-Null
|
||||
Adb push $f $dest
|
||||
if ($LASTEXITCODE) { Fail 'copying the game data failed (is there enough free space on the headset?).' }
|
||||
Remove-Item -Force $f
|
||||
}
|
||||
} finally {
|
||||
$zip.Dispose()
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Installed. In the headset: Library > Unknown Sources > $($manifest.name)." -ForegroundColor Green
|
||||
if (-not $NoLaunch) {
|
||||
Say 'launching (put the headset on)'
|
||||
Adb shell monkey -p $pkg -c android.intent.category.LAUNCHER 1 2>&1 | Out-Null
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install a DorkNet Rec Room .xapk onto a Meta Quest over adb (macOS / Linux).
|
||||
#
|
||||
# ./install.sh [package.xapk] [--serial <id>] [--no-launch]
|
||||
#
|
||||
# Needs adb (Android platform-tools) and unzip.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
xapk="" serial="" launch=1
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--serial) serial="$2"; shift 2 ;;
|
||||
--no-launch) launch=0; shift ;;
|
||||
*) xapk="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[36m==> %s\033[0m\n' "$*"; }
|
||||
fail() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
if [ -z "$xapk" ]; then
|
||||
set -- "$here"/*.xapk
|
||||
[ -e "$1" ] || fail "no .xapk found next to this script; pass it as an argument."
|
||||
[ $# -eq 1 ] || fail "more than one .xapk here; pass the one to install."
|
||||
xapk="$1"
|
||||
fi
|
||||
[ -f "$xapk" ] || fail "package not found: $xapk"
|
||||
command -v unzip >/dev/null || fail "unzip is required."
|
||||
|
||||
adb="$(command -v adb || true)"
|
||||
for c in "${ANDROID_SDK_ROOT:-}/platform-tools/adb" "${ANDROID_HOME:-}/platform-tools/adb" \
|
||||
"$HOME/Library/Android/sdk/platform-tools/adb" "$HOME/Android/Sdk/platform-tools/adb" \
|
||||
"$here/platform-tools/adb"; do
|
||||
[ -n "$adb" ] && break
|
||||
[ -x "$c" ] && adb="$c"
|
||||
done
|
||||
[ -n "$adb" ] || fail "adb not found. Install Android platform-tools: https://developer.android.com/tools/releases/platform-tools"
|
||||
|
||||
if [ -z "$serial" ]; then
|
||||
ready=$("$adb" devices | awk 'NR>1 && $2=="device"{print $1}')
|
||||
count=$(printf '%s' "$ready" | grep -c . || true)
|
||||
if [ "$count" -eq 0 ]; then
|
||||
if "$adb" devices | grep -q unauthorized; then
|
||||
fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
|
||||
fi
|
||||
fail 'no headset found. Enable developer mode, connect over USB, and accept "Allow USB debugging".'
|
||||
fi
|
||||
[ "$count" -eq 1 ] || fail "more than one device connected: $(echo $ready). Pass --serial <id>."
|
||||
serial="$ready"
|
||||
fi
|
||||
a() { "$adb" -s "$serial" "$@"; }
|
||||
say "headset: $(a shell getprop ro.product.model | tr -d '\r') ($serial)"
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
manifest="$(unzip -p "$xapk" manifest.json)" || fail "not an XAPK: manifest.json missing."
|
||||
field() { printf '%s' "$manifest" | grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"\(.*\)"/\1/'; }
|
||||
pkg="$(field package_name)"
|
||||
[ -n "$pkg" ] || fail "manifest.json has no package_name."
|
||||
say "package: $(field name) $pkg $(field version_name) ($(field version_code))"
|
||||
|
||||
say "installing the app"
|
||||
unzip -q -o "$xapk" "$pkg.apk" -d "$tmp"
|
||||
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
|
||||
if printf '%s' "$out" | grep -qE 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match'; then
|
||||
echo "An older copy of $pkg is installed with a different signing key; it has to be removed first."
|
||||
echo "Your account lives on the server, but local settings on the headset are lost."
|
||||
read -r -p "Uninstall it and continue? [y/N] " ans
|
||||
case "$ans" in y|Y|yes) ;; *) fail "cancelled." ;; esac
|
||||
a uninstall "$pkg" >/dev/null || true
|
||||
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
|
||||
fi
|
||||
printf '%s' "$out" | grep -q Success || fail "app install failed: $out"
|
||||
rm -f "$tmp/$pkg.apk"
|
||||
|
||||
# Expansion files: every "install_path" in the manifest.
|
||||
printf '%s' "$manifest" | grep -o '"install_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"\(.*\)"/\1/' |
|
||||
while read -r path; do
|
||||
dest="/sdcard/$path"
|
||||
size="$(unzip -l "$xapk" "$path" | awk 'NR==4{print $1}')"
|
||||
have="$(a shell "stat -c %s '$dest' 2>/dev/null" | tr -d '\r' || true)"
|
||||
if [ -n "$size" ] && [ "$have" = "$size" ]; then say "game data already on the headset, skipping"; continue; fi
|
||||
say "copying game data - this takes a few minutes"
|
||||
unzip -q -o "$xapk" "$path" -d "$tmp"
|
||||
a shell "mkdir -p '$(dirname "$dest")'"
|
||||
a push "$tmp/$path" "$dest" || fail "copying the game data failed (is there enough free space on the headset?)."
|
||||
rm -f "$tmp/$path"
|
||||
done
|
||||
|
||||
echo
|
||||
printf '\033[32mInstalled. In the headset: Library > Unknown Sources > %s.\033[0m\n' "$(field name)"
|
||||
if [ "$launch" -eq 1 ]; then
|
||||
say "launching (put the headset on)"
|
||||
a shell monkey -p "$pkg" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -0,0 +1,170 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bundle a patched Quest APK and its OBB into one installable package.
|
||||
|
||||
.DESCRIPTION
|
||||
Writes a release folder holding:
|
||||
DorkNet-<ver>.xapk standard XAPK (manifest.json + APK + OBB + icon)
|
||||
install.cmd / install.ps1 one-command installer for Windows
|
||||
install.sh one-command installer for macOS / Linux
|
||||
README.txt what to do with it
|
||||
|
||||
The XAPK is the usual APKPure layout, so XAPK-aware installers (SideQuest's
|
||||
file drop, SAI, APKPure) can also open it. The OBB is stored uncompressed:
|
||||
it is already compressed, and storing it keeps packaging and install fast.
|
||||
|
||||
The output contains game binaries from your own store copy. Share it only
|
||||
with people who own the game.
|
||||
|
||||
.PARAMETER Apk
|
||||
The signed, patched APK produced by build.ps1.
|
||||
|
||||
.PARAMETER Obb
|
||||
The store OBB matching the APK's versionCode (main.<code>.com.AgainstGravity.RecRoom.obb).
|
||||
|
||||
.PARAMETER OutDir
|
||||
Release folder to create. Default: <APK folder>\DorkNet-Quest-<ver>.
|
||||
|
||||
.PARAMETER BuildToolsDir
|
||||
Android SDK build-tools dir holding aapt2. Default: resolved from $env:ANDROID_SDK_ROOT.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Apk,
|
||||
[Parameter(Mandatory = $true)][string]$Obb,
|
||||
[string]$OutDir,
|
||||
[string]$BuildToolsDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
|
||||
|
||||
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
|
||||
|
||||
Need $Apk 'patched APK'
|
||||
Need $Obb 'OBB'
|
||||
$Apk = (Resolve-Path -LiteralPath $Apk).Path
|
||||
$Obb = (Resolve-Path -LiteralPath $Obb).Path
|
||||
|
||||
if (-not $BuildToolsDir) {
|
||||
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
|
||||
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
|
||||
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
|
||||
}
|
||||
$Aapt2 = Join-Path $BuildToolsDir 'aapt2.exe'
|
||||
Need $Aapt2 'aapt2'
|
||||
|
||||
# ---- read the APK's identity --------------------------------------------
|
||||
Step 'reading APK metadata'
|
||||
$badging = & $Aapt2 dump badging $Apk 2>$null
|
||||
if ($LASTEXITCODE) { throw 'aapt2 dump badging failed' }
|
||||
function Badge($pattern) { foreach ($l in $badging) { if ($l -match $pattern) { return $Matches[1] } } return $null }
|
||||
$pkg = Badge "^package: name='([^']+)'"
|
||||
$verCode = Badge "versionCode='([^']+)'"
|
||||
$verName = Badge "versionName='([^']+)'"
|
||||
$minSdk = Badge "^(?:minSdkVersion|sdkVersion):'([^']+)'"
|
||||
$tgtSdk = Badge "^targetSdkVersion:'([^']+)'"
|
||||
$label = Badge "^application-label:'([^']*)'"
|
||||
if (-not $pkg -or -not $verCode) { throw 'could not read package name / versionCode from the APK' }
|
||||
$perms = @($badging | Where-Object { $_ -match "^uses-permission: name='([^']+)'" } | ForEach-Object { $_ -replace "^uses-permission: name='([^']+)'.*", '$1' })
|
||||
$iconRef = @($badging | Where-Object { $_ -match "^application-icon-\d+:'([^']+)'" } |
|
||||
ForEach-Object { [pscustomobject]@{ Density = [int]($_ -replace "^application-icon-(\d+):.*", '$1'); Path = ($_ -replace "^application-icon-\d+:'([^']+)'.*", '$1') } } |
|
||||
Sort-Object Density -Descending | Select-Object -First 1 | ForEach-Object { $_.Path })
|
||||
|
||||
# The launcher icon may be an adaptive-icon XML; XAPK installers want a PNG, so
|
||||
# fall back to the same-named PNG from the densest mipmap/drawable folder.
|
||||
$iconPath = $null
|
||||
if ($iconRef) {
|
||||
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
|
||||
try {
|
||||
if ($iconRef -like '*.png' -and $src.GetEntry($iconRef)) { $iconPath = $iconRef }
|
||||
else {
|
||||
$base = [System.IO.Path]::GetFileNameWithoutExtension($iconRef)
|
||||
foreach ($d in 'xxxhdpi', 'xxhdpi', 'xhdpi', 'hdpi', 'mdpi', 'ldpi') {
|
||||
foreach ($kind in 'mipmap', 'drawable') {
|
||||
if (-not $iconPath -and $src.GetEntry("res/$kind-$d/$base.png")) { $iconPath = "res/$kind-$d/$base.png" }
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { $src.Dispose() }
|
||||
}
|
||||
Write-Host " $pkg version $verName ($verCode)"
|
||||
|
||||
$obbName = "main.$verCode.$pkg.obb"
|
||||
$obbEntry = "Android/obb/$pkg/$obbName"
|
||||
$apkEntry = "$pkg.apk"
|
||||
|
||||
if (-not $OutDir) { $OutDir = Join-Path (Split-Path -Parent $Apk) "DorkNet-Quest-$verName" }
|
||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
$xapk = Join-Path $OutDir "DorkNet-$verName.xapk"
|
||||
if (Test-Path $xapk) { Remove-Item -Force $xapk }
|
||||
|
||||
# ---- manifest.json -------------------------------------------------------
|
||||
$manifest = [ordered]@{
|
||||
xapk_version = 2
|
||||
package_name = $pkg
|
||||
name = if ($label) { $label } else { 'DorkNet' }
|
||||
version_code = $verCode
|
||||
version_name = $verName
|
||||
min_sdk_version = $minSdk
|
||||
target_sdk_version = $tgtSdk
|
||||
permissions = $perms
|
||||
total_size = (Get-Item $Apk).Length + (Get-Item $Obb).Length
|
||||
expansions = @([ordered]@{ file = $obbEntry; install_location = 'EXTERNAL_STORAGE'; install_path = $obbEntry })
|
||||
}
|
||||
if ($iconPath) { $manifest.icon = 'icon.png' }
|
||||
|
||||
# ---- write the XAPK ------------------------------------------------------
|
||||
Step "writing $xapk (the OBB is large; this takes a minute)"
|
||||
$zip = [System.IO.Compression.ZipFile]::Open($xapk, [System.IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
$e = $zip.CreateEntry('manifest.json', [System.IO.Compression.CompressionLevel]::Optimal)
|
||||
$w = New-Object System.IO.StreamWriter($e.Open(), (New-Object System.Text.UTF8Encoding($false)))
|
||||
$w.Write(($manifest | ConvertTo-Json -Depth 5)); $w.Dispose()
|
||||
|
||||
if ($iconPath) {
|
||||
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
|
||||
try {
|
||||
$ie = $src.GetEntry($iconPath)
|
||||
if ($ie) {
|
||||
$dst = $zip.CreateEntry('icon.png', [System.IO.Compression.CompressionLevel]::NoCompression)
|
||||
$in = $ie.Open(); $out = $dst.Open(); $in.CopyTo($out); $out.Dispose(); $in.Dispose()
|
||||
}
|
||||
} finally { $src.Dispose() }
|
||||
}
|
||||
|
||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Apk, $apkEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
|
||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Obb, $obbEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
|
||||
} finally { $zip.Dispose() }
|
||||
|
||||
# ---- installers + readme -------------------------------------------------
|
||||
Step 'adding installers'
|
||||
foreach ($f in 'install.ps1', 'install.cmd', 'install.sh') {
|
||||
Copy-Item (Join-Path $PSScriptRoot "installer\$f") (Join-Path $OutDir $f) -Force
|
||||
}
|
||||
$readme = @"
|
||||
$($manifest.name) $verName - Rec Room for DorkNet (Meta Quest)
|
||||
============================================================
|
||||
|
||||
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
|
||||
in over USB and accept "Allow USB debugging" inside the headset.
|
||||
2. Install:
|
||||
Windows: double-click install.cmd
|
||||
macOS / Linux: ./install.sh
|
||||
The installer needs adb (Android platform-tools) on PATH or in
|
||||
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
|
||||
offers to launch it.
|
||||
3. In the headset: Library > Unknown Sources > $($manifest.name).
|
||||
|
||||
The app installs as $pkg alongside any store copy of Rec Room.
|
||||
SideQuest users can also drop the .xapk onto SideQuest instead.
|
||||
"@
|
||||
Set-Content -Path (Join-Path $OutDir 'README.txt') -Value $readme -Encoding utf8
|
||||
|
||||
$sizeGb = '{0:N2}' -f ((Get-Item $xapk).Length / 1GB)
|
||||
Write-Host ""
|
||||
Write-Host "DONE. Package folder: $OutDir" -ForegroundColor Green
|
||||
Write-Host " $(Split-Path -Leaf $xapk) ($sizeGb GB)" -ForegroundColor Green
|
||||
Write-Host "Install with: $(Join-Path $OutDir 'install.cmd')" -ForegroundColor Green
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve DorkNet.Quest hook targets for one Rec Room Quest build.
|
||||
|
||||
Reads targets/spec.json, finds every target in an Il2CppDumper dump.cs by its
|
||||
class header and method signature, and verifies each against the build's
|
||||
libil2cpp.so:
|
||||
|
||||
* exactly one method matches the signature,
|
||||
* the method body is at least 20 bytes (the inline hook rewrites the first
|
||||
five instructions),
|
||||
* the first instructions are disassembled (llvm-objdump, when provided) so
|
||||
the prologue can be eyeballed before a build.
|
||||
|
||||
Output is a per-build table (targets/<versionName>-<versionCode>.json) holding
|
||||
each target's RVA, body length and an FNV-1a fingerprint of the first 20 code
|
||||
bytes. The hook library refuses to arm a target whose fingerprint does not
|
||||
match the running libil2cpp.so, so a table can never be applied to the wrong
|
||||
build. Only RVAs, lengths and hashes are written -- no game code or metadata.
|
||||
|
||||
Usage:
|
||||
python resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so \
|
||||
--version-name 20230317 --version-code 2128210 \
|
||||
[--objdump path/to/llvm-objdump] [--out targets/20230317-2128210.json]
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
HOOK_BYTES = 20
|
||||
RVA_RE = re.compile(r"^\s*// RVA: 0x([0-9A-Fa-f]+) Offset: 0x([0-9A-Fa-f]+)")
|
||||
TYPE_RE = re.compile(r"^(public|internal|private|protected)\b.*\b(class|struct|interface|enum)\b ")
|
||||
|
||||
|
||||
def fnv1a64(data):
|
||||
h = 0xCBF29CE484222325
|
||||
for b in data:
|
||||
h ^= b
|
||||
h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
||||
return h
|
||||
|
||||
|
||||
def sha256(path):
|
||||
d = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
d.update(chunk)
|
||||
return d.hexdigest()
|
||||
|
||||
|
||||
def scan_dump(dump, spec):
|
||||
"""One pass over dump.cs collecting (rva, offset) for each spec match and
|
||||
for the NEXT method after it (to bound the body length)."""
|
||||
wanted = {t["id"]: (t["class"], t["method"]) for t in spec["targets"]}
|
||||
hits = {} # id -> {rva, offset, ...}
|
||||
all_rvas = set() # every method RVA, for next-greater length bounding
|
||||
rva = off = None
|
||||
cur_class = None
|
||||
lines = dump.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
for ln in lines:
|
||||
m = RVA_RE.match(ln)
|
||||
if m:
|
||||
rva, off = int(m.group(1), 16), int(m.group(2), 16)
|
||||
if rva:
|
||||
all_rvas.add(rva)
|
||||
continue
|
||||
if TYPE_RE.match(ln):
|
||||
# dump.cs class headers end with "// TypeDefIndex: N"; drop it.
|
||||
cur_class = ln.split("// TypeDefIndex")[0].rstrip()
|
||||
continue
|
||||
# dump.cs method lines end with " { }" (or "; " for abstract); drop it.
|
||||
body = ln.strip()
|
||||
body = body.split("// RVA")[0].rstrip()
|
||||
if body.endswith("{ }"):
|
||||
body = body[:-3].rstrip()
|
||||
elif body.endswith(";"):
|
||||
body = body[:-1].rstrip()
|
||||
for tid, (cls, meth) in wanted.items():
|
||||
if tid in hits:
|
||||
continue
|
||||
# Class header may carry a base/interface list ("X : Base, IFoo").
|
||||
# Match the exact class, or that class followed by " : <bases>",
|
||||
# but never a nested "X.Y" (which continues with "." not " ").
|
||||
spec_class = cls.rstrip()
|
||||
class_ok = cur_class is not None and (
|
||||
cur_class == spec_class or cur_class.startswith(spec_class + " "))
|
||||
if class_ok and body == meth.rstrip():
|
||||
if rva is None:
|
||||
raise SystemExit(f"{tid}: matched signature with no preceding RVA line")
|
||||
hits[tid] = {"rva": rva, "offset": off, "signature": meth,
|
||||
"class": cls.rstrip()}
|
||||
# Bound each body by the next-greater RVA anywhere in the dump: dump.cs
|
||||
# lists methods in declaration order, so the following *line* is not the
|
||||
# following *function* in memory.
|
||||
ordered = sorted(all_rvas)
|
||||
import bisect
|
||||
for h in hits.values():
|
||||
i = bisect.bisect_right(ordered, h["rva"])
|
||||
h["end_rva"] = ordered[i] if i < len(ordered) else None
|
||||
return hits
|
||||
|
||||
|
||||
def objdump_prologue(objdump, il2cpp, rva, count=6):
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[objdump, "-d", f"--start-address={rva:#x}",
|
||||
f"--stop-address={rva + count * 4:#x}", str(il2cpp)],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
return f"(objdump failed: {e})"
|
||||
return "\n".join(l for l in out.splitlines() if ":\t" in l)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dump", required=True, type=Path)
|
||||
ap.add_argument("--il2cpp", required=True, type=Path)
|
||||
ap.add_argument("--version-name", required=True)
|
||||
ap.add_argument("--version-code", required=True)
|
||||
ap.add_argument("--objdump")
|
||||
ap.add_argument("--spec", type=Path,
|
||||
default=Path(__file__).resolve().parent.parent / "targets" / "spec.json")
|
||||
ap.add_argument("--out", type=Path)
|
||||
args = ap.parse_args()
|
||||
|
||||
spec = json.loads(args.spec.read_text(encoding="utf-8"))
|
||||
hits = scan_dump(args.dump, spec)
|
||||
|
||||
code = args.il2cpp.read_bytes()
|
||||
missing = [t["id"] for t in spec["targets"] if t["id"] not in hits]
|
||||
if missing:
|
||||
raise SystemExit("targets not found in dump: " + ", ".join(missing))
|
||||
|
||||
entries = {}
|
||||
ok = True
|
||||
for t in spec["targets"]:
|
||||
tid = t["id"]
|
||||
h = hits[tid]
|
||||
rva = h["rva"]
|
||||
# In this ELF Offset == VA == RVA, so the file offset is the RVA.
|
||||
length = (h["end_rva"] - rva) if h["end_rva"] else None
|
||||
fp = fnv1a64(code[rva:rva + HOOK_BYTES])
|
||||
short = length is not None and length < HOOK_BYTES
|
||||
if short:
|
||||
ok = False
|
||||
entries[tid] = {
|
||||
"rva": rva,
|
||||
"length": length,
|
||||
"fingerprint": f"{fp:#018x}",
|
||||
"why": t["why"],
|
||||
}
|
||||
flag = " TOO SHORT" if short else ""
|
||||
print(f"{tid:28s} rva={rva:#010x} len={length}{flag}")
|
||||
if args.objdump:
|
||||
print(objdump_prologue(args.objdump, args.il2cpp, rva))
|
||||
|
||||
table = {
|
||||
"versionName": args.version_name,
|
||||
"versionCode": args.version_code,
|
||||
"il2cppSha256": sha256(args.il2cpp),
|
||||
"hookBytes": HOOK_BYTES,
|
||||
"targets": entries,
|
||||
}
|
||||
out = args.out or (args.spec.parent / f"{args.version_name}-{args.version_code}.json")
|
||||
out.write_text(json.dumps(table, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"\nwrote {out}")
|
||||
if not ok:
|
||||
raise SystemExit("one or more targets are shorter than the hook patch; refuse to build")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -0,0 +1,88 @@
|
||||
// Compile-time configuration for libdorknet.so.
|
||||
//
|
||||
// scripts/gen_config_header.py turns dorknet-quest.cfg into config_generated.h
|
||||
// (put on the include path by the build), baking the operator's values into the
|
||||
// APK. Any key left out of the cfg falls back to the built-in default below.
|
||||
// The same cfg can also be pushed to
|
||||
// /sdcard/Android/data/<package>/files/dorknet-quest.cfg
|
||||
// to override at runtime without a rebuild (dn_load_runtime_config).
|
||||
#ifndef DORKNET_QUEST_CONFIG_H
|
||||
#define DORKNET_QUEST_CONFIG_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#if defined(__has_include)
|
||||
# if __has_include("config_generated.h")
|
||||
# include "config_generated.h"
|
||||
# endif
|
||||
#endif
|
||||
|
||||
// The apex the client's *.rec.net hosts are rewritten onto. "api.rec.net"
|
||||
// becomes "api.<this>", "auth.rec.net" -> "auth.<this>", etc. No default: a
|
||||
// build with no server configured would do nothing useful.
|
||||
#ifndef DN_SERVER_HOST
|
||||
# define DN_SERVER_HOST ""
|
||||
#endif
|
||||
// Empty = keep the client's own (Rec Room's) Meta app id, which selects the
|
||||
// no-Meta-account build (platform entitlement/login are steered past).
|
||||
#ifndef DN_OCULUS_APPID
|
||||
# define DN_OCULUS_APPID ""
|
||||
#endif
|
||||
// Photon app ids: per-deployment, no default. Empty leaves the client's own
|
||||
// (dead) value in place, so multiplayer will not connect until set.
|
||||
#ifndef DN_PHOTON_REALTIME
|
||||
# define DN_PHOTON_REALTIME ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_VOICE
|
||||
# define DN_PHOTON_VOICE ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_CHAT
|
||||
# define DN_PHOTON_CHAT ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_SERVER
|
||||
# define DN_PHOTON_SERVER "" // "" = Photon Cloud
|
||||
#endif
|
||||
#ifndef DN_PHOTON_PORT
|
||||
# define DN_PHOTON_PORT 0
|
||||
#endif
|
||||
// Photon Cloud fixed region. Must match the PC clients' region (ClientMod
|
||||
// PhotonCloudRegion), or Quest and PC players land on different masters.
|
||||
#ifndef DN_PHOTON_REGION
|
||||
# define DN_PHOTON_REGION "us"
|
||||
#endif
|
||||
#ifndef DN_TLS_TRUST_BYPASS
|
||||
# define DN_TLS_TRUST_BYPASS true // accept a self-hosted TLS chain + skip pinning
|
||||
#endif
|
||||
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
|
||||
# define DN_ACCEPT_UNSIGNED_IMAGES true
|
||||
#endif
|
||||
#ifndef DN_LOG_HTTP
|
||||
# define DN_LOG_HTTP true
|
||||
#endif
|
||||
#ifndef DN_DEBUG_HTTP
|
||||
# define DN_DEBUG_HTTP false // also log request/response bodies (incl. tokens!)
|
||||
#endif
|
||||
#ifndef DN_LOG_BODY_MAX
|
||||
# define DN_LOG_BODY_MAX 10000
|
||||
#endif
|
||||
|
||||
#ifndef DN_RUNTIME_CFG_NAME
|
||||
# define DN_RUNTIME_CFG_NAME "dorknet-quest.cfg"
|
||||
#endif
|
||||
// Package the runtime cfg is read from; the build's gen step overrides this.
|
||||
#ifndef DN_PACKAGE
|
||||
# define DN_PACKAGE "net.dorknet.RecRoom"
|
||||
#endif
|
||||
|
||||
// Apply /sdcard/Android/data/<DN_PACKAGE>/files/dorknet-quest.cfg over the
|
||||
// baked defaults. Each out pointer already holds the compiled-in value.
|
||||
void dn_load_runtime_config(char *server_host, size_t server_host_cap,
|
||||
char *oculus_appid, size_t oculus_appid_cap,
|
||||
char *photon_realtime, char *photon_voice, char *photon_chat,
|
||||
char *photon_server, size_t photon_server_cap, int *photon_port,
|
||||
char *photon_region, size_t photon_region_cap,
|
||||
bool *tls_bypass, bool *accept_images,
|
||||
bool *log_http, bool *debug_http, int *body_max);
|
||||
|
||||
#endif // DORKNET_QUEST_CONFIG_H
|
||||
@@ -0,0 +1,613 @@
|
||||
// DorkNet.Quest native hook library (libdorknet.so).
|
||||
//
|
||||
// Loaded via a DT_NEEDED entry added to libmain.so, so it runs before Unity.
|
||||
// It waits for libil2cpp.so, then installs arm64 inline hooks (And64InlineHook)
|
||||
// at RVAs resolved from the build's dump.cs by scripts/resolve_targets.py and
|
||||
// baked into targets_generated.h at compile time. It is the Quest counterpart
|
||||
// of DorkNet.ClientMod (the PC MelonLoader mod) and does the same jobs:
|
||||
//
|
||||
// * rewrite <service>.rec.net hosts to <service>.<ServerHost> (the ns
|
||||
// response then supplies every other service URL) -- like ClientMod's
|
||||
// UriPatches,
|
||||
// * inject the operator's Photon app ids -- like the PC byte patch
|
||||
// tools/patch-client.ps1,
|
||||
// * optionally set the operator's Meta (Oculus) app id,
|
||||
// * accept the self-hosted TLS chain and the image signature the operator's
|
||||
// server produces -- like ClientMod's TlsPatches / the PC ImageSigning,
|
||||
// * neutralise the integrity callbacks that would reject a re-signed,
|
||||
// hook-loaded client -- like ClientMod's AntiTamper/ToxMod/FileHash patches,
|
||||
// * when no Meta app id is set, steer the Oculus platform path past its
|
||||
// entitlement/login checks to the client's own credential screen.
|
||||
//
|
||||
// Every RVA is checked against a 20-byte FNV-1a fingerprint from the target
|
||||
// table before it is hooked, so the wrong build is refused rather than
|
||||
// miswritten. Obfuscated Assembly-CSharp names are re-randomised per release
|
||||
// but shared with the PC build of the same release; this build (20230317 /
|
||||
// versionCode 2128210) shares its names with the PC 2023-03-21 decompile.
|
||||
//
|
||||
// No Rec Room code or assets are included. Third-party inline hooker in
|
||||
// src/third_party/And64InlineHook (MIT, (c) Rprop).
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "third_party/And64InlineHook/And64InlineHook.hpp"
|
||||
#include "config.h"
|
||||
#include "targets_generated.h"
|
||||
|
||||
extern "C" int __android_log_print(int prio, const char *tag, const char *fmt, ...);
|
||||
#define LOGI(...) __android_log_print(4, "DORKNET", __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(6, "DORKNET", __VA_ARGS__)
|
||||
|
||||
// --------------------------------------------------------------- config -----
|
||||
|
||||
static const char *kOfficialSuffix = ".rec.net";
|
||||
|
||||
static char g_server_host[160] = DN_SERVER_HOST; // <service>.rec.net -> <service>.<this>
|
||||
static char g_oculus_appid[64] = DN_OCULUS_APPID; // "" = keep the client's own
|
||||
static char g_photon_realtime[64] = DN_PHOTON_REALTIME;
|
||||
static char g_photon_voice[64] = DN_PHOTON_VOICE;
|
||||
static char g_photon_chat[64] = DN_PHOTON_CHAT;
|
||||
static char g_photon_server[128] = DN_PHOTON_SERVER; // "" = Photon Cloud
|
||||
static int g_photon_port = DN_PHOTON_PORT;
|
||||
static char g_photon_region[16] = DN_PHOTON_REGION;
|
||||
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
|
||||
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
|
||||
static bool g_log_http = DN_LOG_HTTP;
|
||||
static bool g_debug_http = DN_DEBUG_HTTP;
|
||||
static int g_body_max = DN_LOG_BODY_MAX;
|
||||
|
||||
// AppSettings field offsets (dump.cs; unchanged across the 2023 builds).
|
||||
static const size_t kAppIdRealtimeOff = 0x10;
|
||||
static const size_t kAppIdChatOff = 0x18;
|
||||
static const size_t kAppIdVoiceOff = 0x20;
|
||||
static const size_t kUseNameServerOff = 0x30; // bool
|
||||
static const size_t kFixedRegionOff = 0x38;
|
||||
static const size_t kServerOff = 0x48;
|
||||
static const size_t kPortOff = 0x50; // int
|
||||
static const size_t kProtocolOff = 0x60; // int (Udp = 0)
|
||||
|
||||
// HTTPRequest / HTTPResponse field offsets (dump.cs).
|
||||
static const size_t kUriFieldOffset = 0x10; // HTTPRequest.<Uri>
|
||||
static const size_t kReqMethodOffset = 0x18; // HTTPRequest.<MethodType> (byte)
|
||||
static const size_t kReqResponseOffset = 0x70; // HTTPRequest.<Response>
|
||||
static const size_t kReqExceptionOffset = 0x80; // HTTPRequest.<Exception>
|
||||
static const size_t kReqStateOffset = 0xB4; // HTTPRequest.<State> (int)
|
||||
static const size_t kRespStatusOffset = 0x18; // HTTPResponse.<StatusCode> (int)
|
||||
static const size_t kRespDataOffset = 0x48; // HTTPResponse.<Data> (byte[])
|
||||
|
||||
// Il2CppArray data layout on 64-bit.
|
||||
static const size_t kArrayLengthOff = 0x18;
|
||||
static const size_t kArrayDataOff = 0x20;
|
||||
|
||||
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
|
||||
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
|
||||
static const unsigned kMsgUserGetUserProof = 578880643u;
|
||||
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
|
||||
static const unsigned long long kNoPlatformUserId = 1ULL;
|
||||
|
||||
static const size_t kLogChunk = 1500;
|
||||
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
|
||||
static const size_t kErrorStateCodeOff = 0x4;
|
||||
|
||||
// --------------------------------------------------------------- il2cpp ------
|
||||
|
||||
typedef void *(*il2cpp_object_get_class_t)(void *obj);
|
||||
typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *name, int argc);
|
||||
typedef void *(*il2cpp_object_new_t)(void *klass);
|
||||
typedef void *(*il2cpp_string_new_t)(const char *str);
|
||||
typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc);
|
||||
|
||||
static il2cpp_object_get_class_t il2cpp_object_get_class;
|
||||
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
|
||||
static il2cpp_object_new_t il2cpp_object_new;
|
||||
static il2cpp_string_new_t il2cpp_string_new;
|
||||
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
|
||||
|
||||
static uintptr_t g_il2cpp_base;
|
||||
|
||||
// Il2CppString: [klass][monitor][int32 length][char16 chars...]
|
||||
static void str_to_utf8(void *s, char *out, size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (!s || cap == 0) return;
|
||||
int32_t len = *(int32_t *)((char *)s + 0x10);
|
||||
if (len < 0) return;
|
||||
const uint16_t *chars = (const uint16_t *)((char *)s + 0x14);
|
||||
size_t i = 0;
|
||||
for (; i < (size_t)len && i + 1 < cap; i++)
|
||||
out[i] = chars[i] < 0x80 ? (char)chars[i] : '?';
|
||||
out[i] = '\0';
|
||||
}
|
||||
|
||||
static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *len) {
|
||||
*len = 0;
|
||||
if (!obj) return nullptr;
|
||||
void *cls = il2cpp_object_get_class(obj);
|
||||
if (!cls) return nullptr;
|
||||
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||
if (!m) return nullptr;
|
||||
void *exc = nullptr;
|
||||
void *arr = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||
if (!arr || exc) return nullptr;
|
||||
*len = (size_t) * (uint64_t *)((char *)arr + kArrayLengthOff);
|
||||
return (const unsigned char *)((char *)arr + kArrayDataOff);
|
||||
}
|
||||
|
||||
static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (!obj) return;
|
||||
void *cls = il2cpp_object_get_class(obj);
|
||||
if (!cls) return;
|
||||
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||
if (!m) return;
|
||||
void *exc = nullptr;
|
||||
void *s = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||
if (s && !exc) str_to_utf8(s, out, cap);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- logging ----
|
||||
|
||||
static void log_chunked(const char *prefix, const char *text) {
|
||||
size_t n = strlen(text);
|
||||
if (n <= kLogChunk) { LOGI("%s%s", prefix, text); return; }
|
||||
char buf[kLogChunk + 1];
|
||||
for (size_t off = 0, part = 1; off < n; off += kLogChunk, part++) {
|
||||
size_t take = n - off < kLogChunk ? n - off : kLogChunk;
|
||||
memcpy(buf, text + off, take);
|
||||
buf[take] = '\0';
|
||||
LOGI("%s[%zu] %s", prefix, part, buf);
|
||||
}
|
||||
}
|
||||
|
||||
static bool looks_binary(const unsigned char *d, size_t n) {
|
||||
if (n == 0) return false;
|
||||
size_t sample = n < 4096 ? n : 4096, nontext = 0;
|
||||
for (size_t i = 0; i < sample; i++) {
|
||||
unsigned char b = d[i];
|
||||
if (b == 0) return true;
|
||||
if (b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D) nontext++;
|
||||
}
|
||||
return nontext * 100 / sample > 10;
|
||||
}
|
||||
|
||||
static void format_body(const unsigned char *d, size_t n, char *out, size_t cap) {
|
||||
if (!d || n == 0) { snprintf(out, cap, "<empty>"); return; }
|
||||
size_t limit = (size_t)g_body_max;
|
||||
if (limit > cap - 64) limit = cap - 64;
|
||||
if (!looks_binary(d, n)) {
|
||||
size_t take = n < limit ? n : limit;
|
||||
memcpy(out, d, take);
|
||||
out[take] = '\0';
|
||||
if (take < n) snprintf(out + take, cap - take, "... <truncated %zu bytes>", n - take);
|
||||
return;
|
||||
}
|
||||
int w = snprintf(out, cap, "<binary %zu bytes>", n);
|
||||
out[w < 0 ? 0 : (size_t)w] = '\0';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- hooks -------
|
||||
|
||||
static void *(*orig_SendRequest)(void *request);
|
||||
|
||||
static void *hk_SendRequest(void *request) {
|
||||
if (!request) return orig_SendRequest(request);
|
||||
void *uri = *(void **)((char *)request + kUriFieldOffset);
|
||||
if (!uri) return orig_SendRequest(request);
|
||||
void *uri_class = il2cpp_object_get_class(uri);
|
||||
if (!uri_class) return orig_SendRequest(request);
|
||||
|
||||
void *m_abs = il2cpp_class_get_method_from_name(uri_class, "get_AbsoluteUri", 0);
|
||||
if (!m_abs) return orig_SendRequest(request);
|
||||
void *exc = nullptr;
|
||||
void *s = il2cpp_runtime_invoke(m_abs, uri, nullptr, &exc);
|
||||
if (!s || exc) return orig_SendRequest(request);
|
||||
|
||||
char url[2048];
|
||||
str_to_utf8(s, url, sizeof(url));
|
||||
|
||||
if (g_log_http) {
|
||||
int method = *(unsigned char *)((char *)request + kReqMethodOffset);
|
||||
static const char *methods[] = {"GET", "HEAD", "POST", "PUT", "DELETE",
|
||||
"PATCH", "MERGE", "OPTIONS", "CONNECT"};
|
||||
const char *mname = (method >= 0 && method < 9) ? methods[method] : "?";
|
||||
LOGI("[http] %s %s", mname, url);
|
||||
if (g_debug_http) {
|
||||
size_t blen = 0;
|
||||
const unsigned char *body = invoke_bytes(request, "GetEntityBody", &blen);
|
||||
if (body && blen) {
|
||||
size_t cap = (size_t)g_body_max + 128;
|
||||
char *text = (char *)malloc(cap);
|
||||
if (text) { format_body(body, blen, text, cap); log_chunked("[http] body=", text); free(text); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite any "<service>.rec.net" host to "<service>.<ServerHost>".
|
||||
const char *scheme_end = strstr(url, "://");
|
||||
if (!scheme_end) return orig_SendRequest(request);
|
||||
const char *host = scheme_end + 3;
|
||||
const char *host_end = host;
|
||||
while (*host_end && *host_end != '/' && *host_end != ':' && *host_end != '?') host_end++;
|
||||
size_t host_len = (size_t)(host_end - host);
|
||||
size_t suf = strlen(kOfficialSuffix);
|
||||
if (host_len <= suf || strncasecmp(host_end - suf, kOfficialSuffix, suf) != 0)
|
||||
return orig_SendRequest(request);
|
||||
|
||||
char newurl[1024];
|
||||
size_t head = (size_t)(host - url); // "https://"
|
||||
size_t service_len = host_len - suf; // "api", "auth", ...
|
||||
// ServerHost is either a plain apex ("example.com" -> "api.example.com") or a
|
||||
// template with "{service}" for servers on DorkNet's hyphen host style
|
||||
// ("{service}-dev.example.com" -> "ns-dev.example.com").
|
||||
static const char kServiceToken[] = "{service}";
|
||||
const char *token = strstr(g_server_host, kServiceToken);
|
||||
size_t new_host_len = token ? strlen(g_server_host) - (sizeof(kServiceToken) - 1) + service_len
|
||||
: service_len + 1 + strlen(g_server_host);
|
||||
if (head + new_host_len + strlen(host_end) + 1 >= sizeof(newurl))
|
||||
return orig_SendRequest(request);
|
||||
memcpy(newurl, url, head);
|
||||
size_t p = head;
|
||||
if (token) {
|
||||
size_t before = (size_t)(token - g_server_host);
|
||||
memcpy(newurl + p, g_server_host, before);
|
||||
p += before;
|
||||
memcpy(newurl + p, host, service_len);
|
||||
p += service_len;
|
||||
strcpy(newurl + p, token + sizeof(kServiceToken) - 1);
|
||||
} else {
|
||||
memcpy(newurl + p, host, service_len);
|
||||
p += service_len;
|
||||
newurl[p++] = '.';
|
||||
strcpy(newurl + p, g_server_host);
|
||||
}
|
||||
strcat(newurl + head, host_end);
|
||||
|
||||
void *new_str = il2cpp_string_new(newurl);
|
||||
void *new_uri = il2cpp_object_new(uri_class);
|
||||
void *ctor = il2cpp_class_get_method_from_name(uri_class, ".ctor", 1);
|
||||
if (!new_str || !new_uri || !ctor) return orig_SendRequest(request);
|
||||
void *args[1] = {new_str};
|
||||
exc = nullptr;
|
||||
il2cpp_runtime_invoke(ctor, new_uri, args, &exc);
|
||||
if (exc) return orig_SendRequest(request);
|
||||
*(void **)((char *)request + kUriFieldOffset) = new_uri;
|
||||
LOGI("intercepted %s -> %s", url, newurl);
|
||||
return orig_SendRequest(request);
|
||||
}
|
||||
|
||||
static void (*orig_CallCallback)(void *thiz, void *method);
|
||||
static void hk_CallCallback(void *thiz, void *method) {
|
||||
if (!thiz || !g_log_http) { orig_CallCallback(thiz, method); return; }
|
||||
char url[2048];
|
||||
invoke_str(*(void **)((char *)thiz + kUriFieldOffset), "get_AbsoluteUri", url, sizeof(url));
|
||||
int state = *(int *)((char *)thiz + kReqStateOffset);
|
||||
void *resp = *(void **)((char *)thiz + kReqResponseOffset);
|
||||
if (!resp) {
|
||||
void *ex = *(void **)((char *)thiz + kReqExceptionOffset);
|
||||
if (ex) {
|
||||
char msg[900];
|
||||
invoke_str(ex, "ToString", msg, sizeof(msg));
|
||||
LOGI("[http<-] NO-RESPONSE state=%d %s | %s", state, url, msg);
|
||||
} else {
|
||||
LOGI("[http<-] NO-RESPONSE state=%d %s | (no exception)", state, url);
|
||||
}
|
||||
} else {
|
||||
int status = *(int *)((char *)resp + kRespStatusOffset);
|
||||
LOGI("[http<-] %d %s", status, url);
|
||||
if (g_debug_http || status < 200 || status >= 300) {
|
||||
size_t blen = 0;
|
||||
const unsigned char *data = invoke_bytes(resp, "get_Data", &blen);
|
||||
size_t cap = (size_t)g_body_max + 128;
|
||||
char *text = (char *)malloc(cap);
|
||||
if (text) { format_body(data, blen, text, cap); log_chunked("[http<-] body=", text); free(text); }
|
||||
}
|
||||
}
|
||||
orig_CallCallback(thiz, method);
|
||||
}
|
||||
|
||||
static void *hk_get_AppID(void *method) {
|
||||
(void)method;
|
||||
return il2cpp_string_new(g_oculus_appid);
|
||||
}
|
||||
|
||||
static void *(*orig_Photon)(void *thiz, void *method);
|
||||
static void *hk_Photon(void *thiz, void *method) {
|
||||
void *s = orig_Photon(thiz, method);
|
||||
if (s) {
|
||||
if (g_photon_realtime[0]) *(void **)((char *)s + kAppIdRealtimeOff) = il2cpp_string_new(g_photon_realtime);
|
||||
if (g_photon_chat[0]) *(void **)((char *)s + kAppIdChatOff) = il2cpp_string_new(g_photon_chat);
|
||||
if (g_photon_voice[0]) *(void **)((char *)s + kAppIdVoiceOff) = il2cpp_string_new(g_photon_voice);
|
||||
*(unsigned char *)((char *)s + kUseNameServerOff) = 1;
|
||||
*(void **)((char *)s + kFixedRegionOff) = il2cpp_string_new(g_photon_region);
|
||||
*(int *)((char *)s + kProtocolOff) = 0;
|
||||
if (g_photon_server[0]) {
|
||||
*(void **)((char *)s + kServerOff) = il2cpp_string_new(g_photon_server);
|
||||
*(int *)((char *)s + kPortOff) = g_photon_port ? g_photon_port : 4533;
|
||||
}
|
||||
static int logged = 0;
|
||||
if (logged < 2) {
|
||||
logged++;
|
||||
LOGI("photon appsettings: RT=%s Voice=%s Chat=%s region=%s server=%s port=%d",
|
||||
g_photon_realtime[0] ? g_photon_realtime : "(unset)",
|
||||
g_photon_voice[0] ? g_photon_voice : "(unset)",
|
||||
g_photon_chat[0] ? g_photon_chat : "(unset)", g_photon_region,
|
||||
g_photon_server[0] ? g_photon_server : "(cloud)", g_photon_port);
|
||||
if (!g_photon_realtime[0])
|
||||
LOGE("photon app ids are not configured; multiplayer will try Rec Room's dead apps");
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static uint32_t (*orig_UnityTls)(void *thiz, uint64_t chain, void *es, void *method);
|
||||
static uint32_t hk_UnityTls(void *thiz, uint64_t chain, void *es, void *method) {
|
||||
uint32_t r = orig_UnityTls(thiz, chain, es, method);
|
||||
if (r != kUnityTlsSuccess) {
|
||||
static int logged = 0;
|
||||
if (logged < 3) { logged++; LOGI("unitytls verify: forcing 0x%x -> SUCCESS", r); }
|
||||
if (es) *(uint32_t *)((char *)es + kErrorStateCodeOff) = kUnityTlsSuccess;
|
||||
}
|
||||
return kUnityTlsSuccess;
|
||||
}
|
||||
|
||||
// void NotifyServerCertificate(Certificate): a no-op accepts the chain.
|
||||
static void hk_NotifyServerCertificate(void *thiz, void *cert, void *method) {
|
||||
(void)thiz; (void)cert; (void)method;
|
||||
}
|
||||
|
||||
// bool pin verifiers: accept unconditionally (replace-only, so no mismatch
|
||||
// report is ever built and the client never quits).
|
||||
static bool hk_pin_true4(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
|
||||
static bool hk_pin_true4b(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
|
||||
|
||||
static bool hk_image_true(void *data, void *sig, void *method) {
|
||||
(void)data; (void)sig; (void)method;
|
||||
return true;
|
||||
}
|
||||
|
||||
// void callbacks the client runs on an integrity rejection -- no-op them so a
|
||||
// re-signed, hook-loaded sideload is not warned/quit (as DorkNet.ClientMod does
|
||||
// on PC). The quit funnels and the report are left to run but are logged.
|
||||
static void hk_void0(void *thiz, void *method) { (void)thiz; (void)method; }
|
||||
static void hk_void1(void *thiz, void *arg, void *method) { (void)thiz; (void)arg; (void)method; }
|
||||
|
||||
static bool hk_toxmod_false(void *method) { (void)method; return false; }
|
||||
|
||||
static const char *cheat_reason(int r) {
|
||||
switch (r) {
|
||||
case 0: return "Obscured"; case 1: return "Time"; case 2: return "Inject";
|
||||
case 3: return "GiftCount"; case 4: return "Engine"; case 5: return "UnknownDll";
|
||||
case 6: return "ImageSignature"; case 7: return "AvatarHack";
|
||||
case 100: return "NetworkCertificatePublicKey"; case 101: return "NetworkCertificateIssuer";
|
||||
case 102: return "NetworkCertificateMissing"; case 103: return "NetworkCertificateMismatch";
|
||||
case 150: return "AutosaveChecksumMismatch"; case 151: return "AutosaveSubRoomIdMismatch";
|
||||
case 152: return "AutosaveChecksumException"; case 200: return "Photon_MissingHash";
|
||||
case 201: return "Photon_CorruptHash"; case 203: return "Photon_DifferentHash";
|
||||
case 300: return "AppData_Runtime_LengthMismatch"; case 301: return "AppData_Runtime_LastWriteTimeMismatch";
|
||||
case 302: return "AppData_Runtime_FileModified"; case 310: return "AppData_Boot_InvalidSignature";
|
||||
case 311: return "AppData_Boot_UnableToVerifySignatures"; case 320: return "Config_MissingHash";
|
||||
case 321: return "Config_DifferentHash"; case 400: return "Photon_InstantiateTool";
|
||||
case 500: return "Memory_Hash_Mismatch"; case 600: return "Driver_Invalid_Signature";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
static void log_caller(const char *what, void *ret) {
|
||||
uintptr_t a = (uintptr_t)ret;
|
||||
if (g_il2cpp_base && a > g_il2cpp_base)
|
||||
LOGI("[caller] %s <- libil2cpp rva 0x%lx", what, (unsigned long)(a - g_il2cpp_base));
|
||||
else
|
||||
LOGI("[caller] %s <- %p", what, ret);
|
||||
}
|
||||
|
||||
static void *(*orig_CheatReport)(int reason, void *msg, uint64_t code, void *method);
|
||||
static void *hk_CheatReport(int reason, void *msg, uint64_t code, void *method) {
|
||||
char text[512];
|
||||
str_to_utf8(msg, text, sizeof(text));
|
||||
LOGI("[integrity] report reason=%d (%s) msg=\"%s\"", reason, cheat_reason(reason), text);
|
||||
log_caller("report", __builtin_return_address(0));
|
||||
return orig_CheatReport(reason, msg, code, method);
|
||||
}
|
||||
|
||||
static void (*orig_FatalQuit)(int code, void *reason, void *method);
|
||||
static void hk_FatalQuit(int code, void *reason, void *method) {
|
||||
char msg[512];
|
||||
str_to_utf8(reason, msg, sizeof(msg));
|
||||
LOGI("[exit] FatalApplicationQuit(%d, \"%s\")", code, msg);
|
||||
log_caller("fatal quit", __builtin_return_address(0));
|
||||
orig_FatalQuit(code, reason, method);
|
||||
}
|
||||
|
||||
static void (*orig_TryQuit)(int code, void *method);
|
||||
static void hk_TryQuit(int code, void *method) {
|
||||
LOGI("[exit] TryApplicationQuit(%d)", code);
|
||||
log_caller("try quit", __builtin_return_address(0));
|
||||
orig_TryQuit(code, method);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- oculus platform -----
|
||||
|
||||
typedef bool (*ovr_IsError_t)(void *m);
|
||||
typedef unsigned (*ovr_GetType_t)(void *m);
|
||||
static ovr_IsError_t orig_ovr_IsError;
|
||||
static ovr_GetType_t ovr_GetType;
|
||||
|
||||
static bool hk_ovr_IsError(void *m) {
|
||||
bool real = orig_ovr_IsError(m);
|
||||
if (!real || !ovr_GetType) return real;
|
||||
unsigned t = ovr_GetType(m);
|
||||
if (t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof) return false;
|
||||
return real;
|
||||
}
|
||||
|
||||
static void *(*orig_ovr_GetUserProof)(void *m);
|
||||
static void *hk_ovr_GetUserProof(void *m) {
|
||||
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
|
||||
return p ? p : (void *)0x1;
|
||||
}
|
||||
|
||||
static const char *(*orig_ovr_GetNonce)(void *proof);
|
||||
static const char *hk_ovr_GetNonce(void *proof) {
|
||||
if (proof && proof != (void *)0x1 && orig_ovr_GetNonce) {
|
||||
const char *n = orig_ovr_GetNonce(proof);
|
||||
if (n && n[0]) return n;
|
||||
}
|
||||
return kPlaceholderNonce;
|
||||
}
|
||||
|
||||
static unsigned long long (*orig_ovr_GetLoggedInUserID)();
|
||||
static unsigned long long hk_ovr_GetLoggedInUserID() {
|
||||
unsigned long long real = orig_ovr_GetLoggedInUserID ? orig_ovr_GetLoggedInUserID() : 0;
|
||||
return real ? real : kNoPlatformUserId;
|
||||
}
|
||||
|
||||
static unsigned long long (*orig_ovr_User_GetID)(void *u);
|
||||
static unsigned long long hk_ovr_User_GetID(void *u) {
|
||||
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
|
||||
return real ? real : kNoPlatformUserId;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- init ------
|
||||
|
||||
static uintptr_t module_base(const char *name) {
|
||||
FILE *f = fopen("/proc/self/maps", "r");
|
||||
if (!f) return 0;
|
||||
char line[512];
|
||||
uintptr_t base = 0;
|
||||
while (fgets(line, sizeof(line), f))
|
||||
if (strstr(line, name)) { base = (uintptr_t)strtoull(line, nullptr, 16); break; }
|
||||
fclose(f);
|
||||
return base;
|
||||
}
|
||||
|
||||
static uint64_t fnv1a(const unsigned char *d, size_t n) {
|
||||
uint64_t h = 0xCBF29CE484222325ULL;
|
||||
for (size_t i = 0; i < n; i++) { h ^= d[i]; h *= 0x100000001B3ULL; }
|
||||
return h;
|
||||
}
|
||||
|
||||
// Arm a target by id: verify the 20-byte fingerprint, then install the hook.
|
||||
static bool arm(const DnTarget *t, void *hook, void **orig) {
|
||||
void *addr = (void *)(g_il2cpp_base + t->rva);
|
||||
uint64_t fp = fnv1a((const unsigned char *)addr, DN_HOOK_BYTES);
|
||||
if (fp != t->fingerprint) {
|
||||
LOGE("REFUSING %s: rva 0x%lx fingerprint 0x%llx != expected 0x%llx (wrong build?)",
|
||||
t->id, (unsigned long)t->rva, (unsigned long long)fp,
|
||||
(unsigned long long)t->fingerprint);
|
||||
return false;
|
||||
}
|
||||
void *dummy = nullptr;
|
||||
A64HookFunction(addr, hook, orig ? orig : &dummy);
|
||||
LOGI("armed %s @ rva 0x%lx", t->id, (unsigned long)t->rva);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool resolve_il2cpp(void *h) {
|
||||
#define SYM(n) n = (n##_t)dlsym(h, #n); if (!n) { LOGE("dlsym " #n " failed"); return false; }
|
||||
SYM(il2cpp_object_get_class)
|
||||
SYM(il2cpp_class_get_method_from_name)
|
||||
SYM(il2cpp_object_new)
|
||||
SYM(il2cpp_string_new)
|
||||
SYM(il2cpp_runtime_invoke)
|
||||
#undef SYM
|
||||
return true;
|
||||
}
|
||||
|
||||
static void arm_oculus_platform_bypass() {
|
||||
void *ovr = nullptr;
|
||||
for (int i = 0; i < 600; i++) {
|
||||
ovr = dlopen("libovrplatformloader.so", RTLD_NOLOAD | RTLD_NOW);
|
||||
if (ovr) break;
|
||||
usleep(100 * 1000);
|
||||
}
|
||||
if (!ovr) { LOGE("libovrplatformloader.so never appeared; platform bypass not armed"); return; }
|
||||
ovr_GetType = (ovr_GetType_t)dlsym(ovr, "ovr_Message_GetType");
|
||||
void *isErr = dlsym(ovr, "ovr_Message_IsError");
|
||||
if (ovr_GetType && isErr) {
|
||||
A64HookFunction(isErr, (void *)hk_ovr_IsError, (void **)&orig_ovr_IsError);
|
||||
LOGI("platform entitlement/proof bypass armed (no Meta app id configured)");
|
||||
} else {
|
||||
LOGE("ovr symbols missing; platform bypass not armed");
|
||||
return;
|
||||
}
|
||||
void *a;
|
||||
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
|
||||
if ((a = dlsym(ovr, "ovr_User_GetID")))
|
||||
A64HookFunction(a, (void *)hk_ovr_User_GetID, (void **)&orig_ovr_User_GetID);
|
||||
if ((a = dlsym(ovr, "ovr_Message_GetUserProof")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
|
||||
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce);
|
||||
}
|
||||
|
||||
static void *init_thread(void *) {
|
||||
void *h = nullptr;
|
||||
for (int i = 0; i < 600; i++) {
|
||||
h = dlopen("libil2cpp.so", RTLD_NOLOAD | RTLD_NOW);
|
||||
if (h) break;
|
||||
usleep(100 * 1000);
|
||||
}
|
||||
if (!h) { LOGE("libil2cpp.so never appeared; aborting"); return nullptr; }
|
||||
g_il2cpp_base = module_base("libil2cpp.so");
|
||||
if (!g_il2cpp_base) { LOGE("could not find libil2cpp.so base"); return nullptr; }
|
||||
if (!resolve_il2cpp(h)) return nullptr;
|
||||
dn_load_runtime_config(g_server_host, sizeof(g_server_host), g_oculus_appid, sizeof(g_oculus_appid),
|
||||
g_photon_realtime, g_photon_voice, g_photon_chat,
|
||||
g_photon_server, sizeof(g_photon_server), &g_photon_port,
|
||||
g_photon_region, sizeof(g_photon_region),
|
||||
&g_tls_bypass, &g_accept_images, &g_log_http, &g_debug_http, &g_body_max);
|
||||
|
||||
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
|
||||
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
|
||||
|
||||
arm(&dn_targets.http_send_request, (void *)hk_SendRequest, (void **)&orig_SendRequest);
|
||||
if (g_log_http)
|
||||
arm(&dn_targets.http_call_callback, (void *)hk_CallCallback, (void **)&orig_CallCallback);
|
||||
arm(&dn_targets.photon_app_settings, (void *)hk_Photon, (void **)&orig_Photon);
|
||||
|
||||
if (g_oculus_appid[0]) {
|
||||
arm(&dn_targets.oculus_get_app_id, (void *)hk_get_AppID, nullptr);
|
||||
arm(&dn_targets.oculus_get_mobile_app_id, (void *)hk_get_AppID, nullptr);
|
||||
LOGI("oculus app id override: %s", g_oculus_appid);
|
||||
}
|
||||
|
||||
if (g_tls_bypass) {
|
||||
arm(&dn_targets.tls_unity_verify, (void *)hk_UnityTls, (void **)&orig_UnityTls);
|
||||
arm(&dn_targets.tls_bc_notify_server_certificate, (void *)hk_NotifyServerCertificate, nullptr);
|
||||
arm(&dn_targets.pin_nameserver, (void *)hk_pin_true4, nullptr);
|
||||
arm(&dn_targets.pin_https, (void *)hk_pin_true4b, nullptr);
|
||||
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
|
||||
}
|
||||
|
||||
if (g_accept_images)
|
||||
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
|
||||
|
||||
// Integrity callbacks that would reject a re-signed, hook-loaded client.
|
||||
arm(&dn_targets.hile_warning_callback, (void *)hk_void1, nullptr);
|
||||
arm(&dn_targets.unknown_dll_callback, (void *)hk_void0, nullptr);
|
||||
arm(&dn_targets.file_hash_callback, (void *)hk_void1, nullptr);
|
||||
arm(&dn_targets.toxmod_can_initialize, (void *)hk_toxmod_false, nullptr);
|
||||
|
||||
// Log-only diagnostics.
|
||||
arm(&dn_targets.cheat_report, (void *)hk_CheatReport, (void **)&orig_CheatReport);
|
||||
arm(&dn_targets.fatal_application_quit, (void *)hk_FatalQuit, (void **)&orig_FatalQuit);
|
||||
arm(&dn_targets.try_application_quit, (void *)hk_TryQuit, (void **)&orig_TryQuit);
|
||||
|
||||
if (!g_oculus_appid[0]) arm_oculus_platform_bypass();
|
||||
LOGI("DorkNet.Quest init complete");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void on_load() {
|
||||
LOGI("DorkNet.Quest hooks loaded (build " __DATE__ " " __TIME__ ")");
|
||||
pthread_t t;
|
||||
pthread_create(&t, nullptr, init_thread, nullptr);
|
||||
pthread_detach(t);
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
/*
|
||||
* @date : 2018/04/18
|
||||
* @author : Rprop (r_prop@outlook.com)
|
||||
* https://github.com/Rprop/And64InlineHook
|
||||
*/
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Rprop (r_prop@outlook.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
#define __STDC_FORMAT_MACROS
|
||||
#include <inttypes.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <sys/mman.h>
|
||||
#include <android/log.h>
|
||||
|
||||
#if defined(__aarch64__)
|
||||
|
||||
#include "And64InlineHook.hpp"
|
||||
#define A64_MAX_INSTRUCTIONS 5
|
||||
#define A64_MAX_REFERENCES (A64_MAX_INSTRUCTIONS * 2)
|
||||
#define A64_NOP 0xd503201fu
|
||||
#define A64_JNIEXPORT __attribute__((visibility("default")))
|
||||
#define A64_LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, "A64_HOOK", __VA_ARGS__))
|
||||
#ifndef NDEBUG
|
||||
# define A64_LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "A64_HOOK", __VA_ARGS__))
|
||||
#else
|
||||
# define A64_LOGI(...) ((void)0)
|
||||
#endif // NDEBUG
|
||||
typedef uint32_t *__restrict *__restrict instruction;
|
||||
struct context
|
||||
{
|
||||
struct fix_info
|
||||
{
|
||||
uint32_t *bp;
|
||||
uint32_t ls; // left-shift counts
|
||||
uint32_t ad; // & operand
|
||||
};
|
||||
struct insns_info
|
||||
{
|
||||
union
|
||||
{
|
||||
uint64_t insu;
|
||||
int64_t ins;
|
||||
void *insp;
|
||||
};
|
||||
fix_info fmap[A64_MAX_REFERENCES];
|
||||
};
|
||||
int64_t basep;
|
||||
int64_t endp;
|
||||
insns_info dat[A64_MAX_INSTRUCTIONS];
|
||||
|
||||
public:
|
||||
inline bool is_in_fixing_range(const int64_t absolute_addr) {
|
||||
return absolute_addr >= this->basep && absolute_addr < this->endp;
|
||||
}
|
||||
inline intptr_t get_ref_ins_index(const int64_t absolute_addr) {
|
||||
return static_cast<intptr_t>((absolute_addr - this->basep) / sizeof(uint32_t));
|
||||
}
|
||||
inline intptr_t get_and_set_current_index(uint32_t *__restrict inp, uint32_t *__restrict outp) {
|
||||
intptr_t current_idx = this->get_ref_ins_index(reinterpret_cast<int64_t>(inp));
|
||||
this->dat[current_idx].insp = outp;
|
||||
return current_idx;
|
||||
}
|
||||
inline void reset_current_ins(const intptr_t idx, uint32_t *__restrict outp) {
|
||||
this->dat[idx].insp = outp;
|
||||
}
|
||||
void insert_fix_map(const intptr_t idx, uint32_t *bp, uint32_t ls = 0u, uint32_t ad = 0xffffffffu) {
|
||||
for (auto &f : this->dat[idx].fmap) {
|
||||
if (f.bp == NULL) {
|
||||
f.bp = bp;
|
||||
f.ls = ls;
|
||||
f.ad = ad;
|
||||
return;
|
||||
} //if
|
||||
}
|
||||
// What? GGing..
|
||||
}
|
||||
void process_fix_map(const intptr_t idx) {
|
||||
for (auto &f : this->dat[idx].fmap) {
|
||||
if (f.bp == NULL) break;
|
||||
*(f.bp) = *(f.bp) | (((int32_t(this->dat[idx].ins - reinterpret_cast<int64_t>(f.bp)) >> 2) << f.ls) & f.ad);
|
||||
f.bp = NULL;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
#define __intval(p) reinterpret_cast<intptr_t>(p)
|
||||
#define __uintval(p) reinterpret_cast<uintptr_t>(p)
|
||||
#define __ptr(p) reinterpret_cast<void *>(p)
|
||||
#define __page_size 4096
|
||||
#define __page_align(n) __align_up(static_cast<uintptr_t>(n), __page_size)
|
||||
#define __ptr_align(x) __ptr(__align_down(reinterpret_cast<uintptr_t>(x), __page_size))
|
||||
#define __align_up(x, n) (((x) + ((n) - 1)) & ~((n) - 1))
|
||||
#define __align_down(x, n) ((x) & -(n))
|
||||
#define __countof(x) static_cast<intptr_t>(sizeof(x) / sizeof((x)[0])) // must be signed
|
||||
#define __atomic_increase(p) __sync_add_and_fetch(p, 1)
|
||||
#define __sync_cmpswap(p, v, n) __sync_bool_compare_and_swap(p, v, n)
|
||||
#define __predict_true(exp) __builtin_expect((exp) != 0, 1)
|
||||
#define __flush_cache(c, n) __builtin___clear_cache(reinterpret_cast<char *>(c), reinterpret_cast<char *>(c) + n)
|
||||
#define __make_rwx(p, n) ::mprotect(__ptr_align(p), \
|
||||
__page_align(__uintval(p) + n) != __page_align(__uintval(p)) ? __page_align(n) + __page_size : __page_align(n), \
|
||||
PROT_READ | PROT_WRITE | PROT_EXEC)
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_branch_imm(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
static constexpr uint32_t mbits = 6u;
|
||||
static constexpr uint32_t mask = 0xfc000000u; // 0b11111100000000000000000000000000
|
||||
static constexpr uint32_t rmask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
static constexpr uint32_t op_b = 0x14000000u; // "b" ADDR_PCREL26
|
||||
static constexpr uint32_t op_bl = 0x94000000u; // "bl" ADDR_PCREL26
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
const uint32_t opc = ins & mask;
|
||||
switch (opc) {
|
||||
case op_b:
|
||||
case op_bl:
|
||||
{
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>(ins << mbits) >> (mbits - 2u)); // sign-extended
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
// whether the branch should be converted to absolute jump
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (rmask >> 1)) {
|
||||
bool b_aligned = (reinterpret_cast<uint64_t>(*outpp + 2) & 7u) == 0u;
|
||||
if (opc == op_b) {
|
||||
if (b_aligned != true) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = 0x58000051u; // LDR X17, #0x8
|
||||
(*outpp)[1] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 4;
|
||||
} else {
|
||||
if (b_aligned == true) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = 0x58000071u; // LDR X17, #12
|
||||
(*outpp)[1] = 0x1000009eu; // ADR X30, #16
|
||||
(*outpp)[2] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 3, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 5;
|
||||
} //if
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, 0u, rmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = opc | (new_pc_offset & ~mask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_cond_comp_test_branch(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t lmask01 = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t mask0 = 0xff000010u; // 0b11111111000000000000000000010000
|
||||
static constexpr uint32_t op_bc = 0x54000000u; // "b.c" ADDR_PCREL19
|
||||
static constexpr uint32_t mask1 = 0x7f000000u; // 0b01111111000000000000000000000000
|
||||
static constexpr uint32_t op_cbz = 0x34000000u; // "cbz" Rt, ADDR_PCREL19
|
||||
static constexpr uint32_t op_cbnz = 0x35000000u; // "cbnz" Rt, ADDR_PCREL19
|
||||
static constexpr uint32_t lmask2 = 0xfff8001fu; // 0b11111111111110000000000000011111
|
||||
static constexpr uint32_t mask2 = 0x7f000000u; // 0b01111111000000000000000000000000
|
||||
static constexpr uint32_t op_tbz = 0x36000000u; // 0b00110110000000000000000000000000 "tbz" Rt, BIT_NUM, ADDR_PCREL14
|
||||
static constexpr uint32_t op_tbnz = 0x37000000u; // 0b00110111000000000000000000000000 "tbnz" Rt, BIT_NUM, ADDR_PCREL14
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
uint32_t lmask = lmask01;
|
||||
if ((ins & mask0) != op_bc) {
|
||||
uint32_t opc = ins & mask1;
|
||||
if (opc != op_cbz && opc != op_cbnz) {
|
||||
opc = ins & mask2;
|
||||
if (opc != op_tbz && opc != op_tbnz) {
|
||||
return false;
|
||||
} //if
|
||||
lmask = lmask2;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
const uint32_t msb = __builtin_clz(~lmask);
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>((ins & ~lmask) << msb) >> (lsb - 2u + msb));
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (~lmask >> (lsb + 1))) {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 4) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = (((8u >> 2u) << lsb) & ~lmask) | (ins & lmask); // B.C #0x8
|
||||
(*outpp)[1] = 0x14000005u; // B #0x14
|
||||
(*outpp)[2] = 0x58000051u; // LDR X17, #0x8
|
||||
(*outpp)[3] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 4, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 6;
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, lsb, ~lmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~lmask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_loadlit(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
const uint32_t ins = *(*inpp);
|
||||
|
||||
// memory prefetch("prfm"), just skip it
|
||||
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897420050.html
|
||||
if ((ins & 0xff000000u) == 0xd8000000u) {
|
||||
ctxp->process_fix_map(ctxp->get_and_set_current_index(*inpp, *outpp));
|
||||
++(*inpp);
|
||||
return true;
|
||||
} //if
|
||||
|
||||
static constexpr uint32_t msb = 8u;
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t mask_30 = 0x40000000u; // 0b01000000000000000000000000000000
|
||||
static constexpr uint32_t mask_31 = 0x80000000u; // 0b10000000000000000000000000000000
|
||||
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t mask_ldr = 0xbf000000u; // 0b10111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldr = 0x18000000u; // 0b00011000000000000000000000000000 "LDR Wt/Xt, label" | ADDR_PCREL19
|
||||
static constexpr uint32_t mask_ldrv = 0x3f000000u; // 0b00111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldrv = 0x1c000000u; // 0b00011100000000000000000000000000 "LDR St/Dt/Qt, label" | ADDR_PCREL19
|
||||
static constexpr uint32_t mask_ldrsw = 0xff000000u; // 0b11111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldrsw = 0x98000000u; // "LDRSW Xt, label" | ADDR_PCREL19 | load register signed word
|
||||
// LDR S0, #0 | 0b00011100000000000000000000000000 | 32-bit
|
||||
// LDR D0, #0 | 0b01011100000000000000000000000000 | 64-bit
|
||||
// LDR Q0, #0 | 0b10011100000000000000000000000000 | 128-bit
|
||||
// INVALID | 0b11011100000000000000000000000000 | may be 256-bit
|
||||
|
||||
uint32_t mask = mask_ldr;
|
||||
uintptr_t faligned = (ins & mask_30) ? 7u : 3u;
|
||||
if ((ins & mask_ldr) != op_ldr) {
|
||||
mask = mask_ldrv;
|
||||
if (faligned != 7u)
|
||||
faligned = (ins & mask_31) ? 15u : 3u;
|
||||
if ((ins & mask_ldrv) != op_ldrv) {
|
||||
if ((ins & mask_ldrsw) != op_ldrsw) {
|
||||
return false;
|
||||
} //if
|
||||
mask = mask_ldrsw;
|
||||
faligned = 7u;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + ((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u);
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
// special_fix_type may encounter issue when there are mixed data and code
|
||||
if (special_fix_type || (llabs(new_pc_offset) + (faligned + 1u - 4u) / 4u) >= (~lmask >> (lsb + 1))) { // inaccurate, but it works
|
||||
while ((reinterpret_cast<uint64_t>(*outpp + 2) & faligned) != 0u) {
|
||||
*(*outpp)++ = A64_NOP;
|
||||
}
|
||||
ctxp->reset_current_ins(current_idx, *outpp);
|
||||
|
||||
// Note that if memory at absolute_addr is writeable (non-const), we will fail to fetch it.
|
||||
// And what's worse, we may unexpectedly overwrite something if special_fix_type is true...
|
||||
uint32_t ns = static_cast<uint32_t>((faligned + 1) / sizeof(uint32_t));
|
||||
(*outpp)[0] = (((8u >> 2u) << lsb) & ~mask) | (ins & lmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000001u + ns; // B #0xc
|
||||
memcpy(*outpp + 2, reinterpret_cast<void *>(absolute_addr), faligned + 1);
|
||||
*outpp += 2 + ns;
|
||||
} else {
|
||||
faligned >>= 2; // new_pc_offset is shifted and 4-byte aligned
|
||||
while ((new_pc_offset & faligned) != 0) {
|
||||
*(*outpp)++ = A64_NOP;
|
||||
new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
}
|
||||
ctxp->reset_current_ins(current_idx, *outpp);
|
||||
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~mask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_pcreladdr(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
// Load a PC-relative address into a register
|
||||
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897645644.html
|
||||
static constexpr uint32_t msb = 8u;
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t mask = 0x9f000000u; // 0b10011111000000000000000000000000
|
||||
static constexpr uint32_t rmask = 0x0000001fu; // 0b00000000000000000000000000011111
|
||||
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t fmask = 0x00ffffffu; // 0b00000000111111111111111111111111
|
||||
static constexpr uint32_t max_val = 0x001fffffu; // 0b00000000000111111111111111111111
|
||||
static constexpr uint32_t op_adr = 0x10000000u; // "adr" Rd, ADDR_PCREL21
|
||||
static constexpr uint32_t op_adrp = 0x90000000u; // "adrp" Rd, ADDR_ADRP
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
intptr_t current_idx;
|
||||
switch (ins & mask) {
|
||||
case op_adr:
|
||||
{
|
||||
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes);
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp));
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (max_val >> 1)) {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000003u; // B #0xc
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 4;
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr & ~3ull);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp));
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, lsb, fmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
// the lsb_bytes will never be changed, so we can use lmask to keep it
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << (lsb - 2u)) & fmask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
}
|
||||
break;
|
||||
case op_adrp:
|
||||
{
|
||||
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int32_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
|
||||
int64_t absolute_addr = (reinterpret_cast<int64_t>(*inpp) & ~0xfffll) + ((((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes) << 12);
|
||||
A64_LOGI("ins = 0x%.8X, pc = %p, abs_addr = %p",
|
||||
ins, *inpp, reinterpret_cast<int64_t *>(absolute_addr));
|
||||
if (ctxp->is_in_fixing_range(absolute_addr)) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr/* & ~3ull*/);
|
||||
if (ref_idx > current_idx) {
|
||||
// the bottom 12 bits of absolute_addr are masked out,
|
||||
// so ref_idx must be less than or equal to current_idx!
|
||||
A64_LOGE("ref_idx must be less than or equal to current_idx!");
|
||||
} //if
|
||||
|
||||
// *absolute_addr may be changed due to relocation fixing
|
||||
A64_LOGI("What is the correct way to fix this?");
|
||||
*(*outpp)++ = ins; // 0x90000000u;
|
||||
} else {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000003u; // B #0xc
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr)); // potential overflow?
|
||||
*outpp += 4;
|
||||
} //if
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
ctxp->process_fix_map(current_idx);
|
||||
++(*inpp);
|
||||
return true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static void __fix_instructions(uint32_t *__restrict inp, int32_t count, uint32_t *__restrict outp)
|
||||
{
|
||||
context ctx;
|
||||
ctx.basep = reinterpret_cast<int64_t>(inp);
|
||||
ctx.endp = reinterpret_cast<int64_t>(inp + count);
|
||||
memset(ctx.dat, 0, sizeof(ctx.dat));
|
||||
static_assert(sizeof(ctx.dat) / sizeof(ctx.dat[0]) == A64_MAX_INSTRUCTIONS,
|
||||
"please use A64_MAX_INSTRUCTIONS!");
|
||||
#ifndef NDEBUG
|
||||
if (count > A64_MAX_INSTRUCTIONS) {
|
||||
A64_LOGE("too many fixing instructions!");
|
||||
} //if
|
||||
#endif // NDEBUG
|
||||
|
||||
uint32_t *const outp_base = outp;
|
||||
|
||||
while (--count >= 0) {
|
||||
if (__fix_branch_imm(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_cond_comp_test_branch(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_loadlit(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_pcreladdr(&inp, &outp, &ctx)) continue;
|
||||
|
||||
// without PC-relative offset
|
||||
ctx.process_fix_map(ctx.get_and_set_current_index(inp, outp));
|
||||
*(outp++) = *(inp++);
|
||||
}
|
||||
|
||||
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
auto callback = reinterpret_cast<int64_t>(inp);
|
||||
auto pc_offset = static_cast<int64_t>(callback - reinterpret_cast<int64_t>(outp)) >> 2;
|
||||
if (llabs(pc_offset) >= (mask >> 1)) {
|
||||
if ((reinterpret_cast<uint64_t>(outp + 2) & 7u) != 0u) {
|
||||
outp[0] = A64_NOP;
|
||||
++outp;
|
||||
} //if
|
||||
outp[0] = 0x58000051u; // LDR X17, #0x8
|
||||
outp[1] = 0xd61f0220u; // BR X17
|
||||
*reinterpret_cast<int64_t *>(outp + 2) = callback;
|
||||
outp += 4;
|
||||
} else {
|
||||
outp[0] = 0x14000000u | (pc_offset & mask); // "B" ADDR_PCREL26
|
||||
++outp;
|
||||
} //if
|
||||
|
||||
const uintptr_t total = (outp - outp_base) * sizeof(uint32_t);
|
||||
__flush_cache(outp_base, total); // necessary
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
extern "C" {
|
||||
static __attribute__((__aligned__(__page_size))) uint32_t __insns_pool[A64_MAX_BACKUPS][A64_MAX_INSTRUCTIONS * 10];
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
class A64HookInit
|
||||
{
|
||||
public:
|
||||
A64HookInit()
|
||||
{
|
||||
__make_rwx(__insns_pool, sizeof(__insns_pool));
|
||||
A64_LOGI("insns pool initialized.");
|
||||
}
|
||||
};
|
||||
static A64HookInit __init;
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static uint32_t *FastAllocateTrampoline()
|
||||
{
|
||||
static_assert((A64_MAX_INSTRUCTIONS * 10 * sizeof(uint32_t)) % 8 == 0, "8-byte align");
|
||||
static volatile int32_t __index = -1;
|
||||
|
||||
int32_t i = __atomic_increase(&__index);
|
||||
if (__predict_true(i >= 0 && i < __countof(__insns_pool))) {
|
||||
return __insns_pool[i];
|
||||
} //if
|
||||
|
||||
A64_LOGE("failed to allocate trampoline!");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
A64_JNIEXPORT void *A64HookFunctionV(void *const symbol, void *const replace,
|
||||
void *const rwx, const uintptr_t rwx_size)
|
||||
{
|
||||
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
|
||||
uint32_t *trampoline = static_cast<uint32_t *>(rwx), *original = static_cast<uint32_t *>(symbol);
|
||||
|
||||
static_assert(A64_MAX_INSTRUCTIONS >= 5, "please fix A64_MAX_INSTRUCTIONS!");
|
||||
auto pc_offset = static_cast<int64_t>(__intval(replace) - __intval(symbol)) >> 2;
|
||||
if (llabs(pc_offset) >= (mask >>1)) {
|
||||
int32_t count = (reinterpret_cast<uint64_t>(original + 2) & 7u) != 0u ? 5 : 4;
|
||||
if (trampoline) {
|
||||
if (rwx_size < count * 10u) {
|
||||
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", count * 10u);
|
||||
return NULL;
|
||||
} //if
|
||||
__fix_instructions(original, count, trampoline);
|
||||
} //if
|
||||
|
||||
if (__make_rwx(original, 5 * sizeof(uint32_t)) == 0) {
|
||||
if (count == 5) {
|
||||
original[0] = A64_NOP;
|
||||
++original;
|
||||
} //if
|
||||
original[0] = 0x58000051u; // LDR X17, #0x8
|
||||
original[1] = 0xd61f0220u; // BR X17
|
||||
*reinterpret_cast<int64_t *>(original + 2) = __intval(replace);
|
||||
__flush_cache(symbol, 5 * sizeof(uint32_t));
|
||||
|
||||
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
|
||||
symbol, replace, 5 * sizeof(uint32_t));
|
||||
} else {
|
||||
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
|
||||
errno, original, 5 * sizeof(uint32_t));
|
||||
trampoline = NULL;
|
||||
} //if
|
||||
} else {
|
||||
if (trampoline) {
|
||||
if (rwx_size < 1u * 10u) {
|
||||
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", 1u * 10u);
|
||||
return NULL;
|
||||
} //if
|
||||
__fix_instructions(original, 1, trampoline);
|
||||
} //if
|
||||
|
||||
if (__make_rwx(original, 1 * sizeof(uint32_t)) == 0) {
|
||||
__sync_cmpswap(original, *original, 0x14000000u | (pc_offset & mask)); // "B" ADDR_PCREL26
|
||||
__flush_cache(symbol, 1 * sizeof(uint32_t));
|
||||
|
||||
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
|
||||
symbol, replace, 1 * sizeof(uint32_t));
|
||||
} else {
|
||||
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
|
||||
errno, original, 1 * sizeof(uint32_t));
|
||||
trampoline = NULL;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
return trampoline;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
A64_JNIEXPORT void A64HookFunction(void *const symbol, void *const replace, void **result)
|
||||
{
|
||||
void *trampoline = NULL;
|
||||
if (result != NULL) {
|
||||
trampoline = FastAllocateTrampoline();
|
||||
*result = trampoline;
|
||||
if (trampoline == NULL) return;
|
||||
} //if
|
||||
|
||||
// fix Android 10 .text segment is read-only by default
|
||||
__make_rwx(symbol, 5 * sizeof(size_t));
|
||||
|
||||
trampoline = A64HookFunctionV(symbol, replace, trampoline, A64_MAX_INSTRUCTIONS * 10u);
|
||||
if (trampoline == NULL && result != NULL) {
|
||||
*result = NULL;
|
||||
} //if
|
||||
}
|
||||
}
|
||||
|
||||
#endif // defined(__aarch64__)
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* @date : 2018/04/18
|
||||
* @author : Rprop (r_prop@outlook.com)
|
||||
* https://github.com/Rprop/And64InlineHook
|
||||
*/
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Rprop (r_prop@outlook.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
#pragma once
|
||||
#define A64_MAX_BACKUPS 256
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
void A64HookFunction(void *const symbol, void *const replace, void **result);
|
||||
void *A64HookFunctionV(void *const symbol, void *const replace,
|
||||
void *const rwx, const uintptr_t rwx_size);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 RLib
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"versionName": "20230317",
|
||||
"versionCode": "2128210",
|
||||
"il2cppSha256": "842a496cc59948af03fe435e0abe0a173cbf2a6bf439be48096ad2b2c2dccbb9",
|
||||
"hookBytes": 20,
|
||||
"targets": {
|
||||
"http_send_request": {
|
||||
"rva": 108025384,
|
||||
"length": 456,
|
||||
"fingerprint": "0x8cd03afe41eecdef",
|
||||
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
|
||||
},
|
||||
"http_call_callback": {
|
||||
"rva": 108061608,
|
||||
"length": 424,
|
||||
"fingerprint": "0x98ed61ac83527269",
|
||||
"why": "Logs each finished request's status (and the body of non-2xx responses)."
|
||||
},
|
||||
"oculus_get_app_id": {
|
||||
"rva": 166405008,
|
||||
"length": 32,
|
||||
"fingerprint": "0xaa5a50a8d4246c46",
|
||||
"why": "Returns the operator's Meta app id when OculusAppId is set."
|
||||
},
|
||||
"oculus_get_mobile_app_id": {
|
||||
"rva": 166380836,
|
||||
"length": 32,
|
||||
"fingerprint": "0x5f5d3bc64dedb8de",
|
||||
"why": "Same as get_AppID; the Quest build reads this getter."
|
||||
},
|
||||
"photon_app_settings": {
|
||||
"rva": 69268008,
|
||||
"length": 196,
|
||||
"fingerprint": "0xc8050d166579e43b",
|
||||
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
|
||||
},
|
||||
"tls_unity_verify": {
|
||||
"rva": 159789160,
|
||||
"length": 884,
|
||||
"fingerprint": "0xf8d4a696f15e6f0e",
|
||||
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
|
||||
},
|
||||
"tls_bc_notify_server_certificate": {
|
||||
"rva": 103301824,
|
||||
"length": 284,
|
||||
"fingerprint": "0x7f061bfc843e881f",
|
||||
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
|
||||
},
|
||||
"pin_nameserver": {
|
||||
"rva": 96989180,
|
||||
"length": 332,
|
||||
"fingerprint": "0x659c928fe96e1008",
|
||||
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
|
||||
},
|
||||
"pin_https": {
|
||||
"rva": 103249928,
|
||||
"length": 1032,
|
||||
"fingerprint": "0x943a238f04d54362",
|
||||
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
|
||||
},
|
||||
"pin_bc_verifier": {
|
||||
"rva": 82659376,
|
||||
"length": 1128,
|
||||
"fingerprint": "0x60415d1d29a7315d",
|
||||
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
|
||||
},
|
||||
"image_signature_verify": {
|
||||
"rva": 97302848,
|
||||
"length": 732,
|
||||
"fingerprint": "0xe1549ef6f4691a33",
|
||||
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
|
||||
},
|
||||
"hile_warning_callback": {
|
||||
"rva": 75750004,
|
||||
"length": 352,
|
||||
"fingerprint": "0xccf510feeada40d9",
|
||||
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
|
||||
},
|
||||
"unknown_dll_callback": {
|
||||
"rva": 57998052,
|
||||
"length": 112,
|
||||
"fingerprint": "0x84e1fed3a70e7560",
|
||||
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
|
||||
},
|
||||
"file_hash_callback": {
|
||||
"rva": 52872472,
|
||||
"length": 1052,
|
||||
"fingerprint": "0x03a8b6f1b373e600",
|
||||
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
|
||||
},
|
||||
"toxmod_can_initialize": {
|
||||
"rva": 55928080,
|
||||
"length": 1040,
|
||||
"fingerprint": "0xf72dd6f5dbb16216",
|
||||
"why": "Third-party voice moderation service; forced false, as on PC."
|
||||
},
|
||||
"cheat_report": {
|
||||
"rva": 106127656,
|
||||
"length": 2248,
|
||||
"fingerprint": "0x9c1e0ef53e5a3ec8",
|
||||
"why": "Log-only: names any integrity report so a new one is visible in logcat."
|
||||
},
|
||||
"fatal_application_quit": {
|
||||
"rva": 55748824,
|
||||
"length": 348,
|
||||
"fingerprint": "0x8cd03afe41eecdef",
|
||||
"why": "Log-only: prints the quit reason and caller."
|
||||
},
|
||||
"try_application_quit": {
|
||||
"rva": 55748412,
|
||||
"length": 412,
|
||||
"fingerprint": "0xb12b0044ac4f844f",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Hook targets, identified by the class header and method signature exactly as",
|
||||
"Il2CppDumper prints them in dump.cs. scripts/resolve_targets.py turns this into a",
|
||||
"per-build table (RVA, length, code fingerprint). Obfuscated names are fixed per",
|
||||
"release and shared with the PC build of the same release; when rebasing, re-check",
|
||||
"each one against the PC decompile and update the signature here."
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"id": "http_send_request",
|
||||
"class": "public static class HTTPManager ",
|
||||
"method": "public static HTTPRequest SendRequest(HTTPRequest request)",
|
||||
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
|
||||
},
|
||||
{
|
||||
"id": "http_call_callback",
|
||||
"class": "public sealed class HTTPRequest ",
|
||||
"method": "internal void CallCallback()",
|
||||
"why": "Logs each finished request's status (and the body of non-2xx responses)."
|
||||
},
|
||||
{
|
||||
"id": "oculus_get_app_id",
|
||||
"class": "public sealed class PlatformSettings ",
|
||||
"method": "public static string get_AppID()",
|
||||
"why": "Returns the operator's Meta app id when OculusAppId is set."
|
||||
},
|
||||
{
|
||||
"id": "oculus_get_mobile_app_id",
|
||||
"class": "public sealed class PlatformSettings ",
|
||||
"method": "public static string get_MobileAppID()",
|
||||
"why": "Same as get_AppID; the Quest build reads this getter."
|
||||
},
|
||||
{
|
||||
"id": "photon_app_settings",
|
||||
"class": "internal class NFOMFIAOKFO ",
|
||||
"method": "public AppSettings LBKJOHGFHOO()",
|
||||
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
|
||||
},
|
||||
{
|
||||
"id": "tls_unity_verify",
|
||||
"class": "internal class UnityTlsContext ",
|
||||
"method": "private UnityTls.unitytls_x509verify_result VerifyCallback(UnityTls.unitytls_x509list_ref chain, UnityTls.unitytls_errorstate* errorState)",
|
||||
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
|
||||
},
|
||||
{
|
||||
"id": "tls_bc_notify_server_certificate",
|
||||
"class": "public class LegacyTlsAuthentication ",
|
||||
"method": "public virtual void NotifyServerCertificate(Certificate serverCertificate)",
|
||||
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
|
||||
},
|
||||
{
|
||||
"id": "pin_nameserver",
|
||||
"class": "public class PAPLNIPKAMG ",
|
||||
"method": "private static bool GKHFNFDOKDK(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
|
||||
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
|
||||
},
|
||||
{
|
||||
"id": "pin_https",
|
||||
"class": "public static class LJEHIHEHMCN ",
|
||||
"method": "public static bool JFIODJJKMBF(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
|
||||
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
|
||||
},
|
||||
{
|
||||
"id": "pin_bc_verifier",
|
||||
"class": "public class LJEHIHEHMCN.NALNEIDKBOC ",
|
||||
"method": "public bool IsValid(Uri HEEAJLIFCHN, X509CertificateStructure[] MFLGMAEAFON)",
|
||||
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
|
||||
},
|
||||
{
|
||||
"id": "image_signature_verify",
|
||||
"class": "public class KLJOGJHBONK ",
|
||||
"method": "private static bool PBLLFOKJAMK(byte[] HBMMCJPOJGP, byte[] JJGOGMKDLGI)",
|
||||
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
|
||||
},
|
||||
{
|
||||
"id": "hile_warning_callback",
|
||||
"class": "private sealed class FPIBGPIAOBI.KHIIHOIFDMP ",
|
||||
"method": "internal void <CreateHileWarning>b__0(bool shouldQuit)",
|
||||
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "unknown_dll_callback",
|
||||
"class": "private sealed class CheatManager.NOBCKPOGHEB ",
|
||||
"method": "internal void <OnUnknownDLLDetected>b__0()",
|
||||
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
|
||||
},
|
||||
{
|
||||
"id": "file_hash_callback",
|
||||
"class": "private sealed class BEPEAHOOLDB.PBENKLNHBHL ",
|
||||
"method": "internal void <InitializeFileHashChecker>b__0(JFDPIJFDHAD.ACCABIOJGNE result)",
|
||||
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "toxmod_can_initialize",
|
||||
"class": "public class ToxModVoiceComponent ",
|
||||
"method": "public static bool CanInitializeToxMod()",
|
||||
"why": "Third-party voice moderation service; forced false, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "cheat_report",
|
||||
"class": "public class FPIBGPIAOBI ",
|
||||
"method": "public static LDGADANDBIO IEIHCFLCHBE(FPIBGPIAOBI.HHKINDNBLEE EHPFPOFFCEJ, string EPAIMCEIMPA, Nullable<int> BNCEPENKLDF)",
|
||||
"why": "Log-only: names any integrity report so a new one is visible in logcat."
|
||||
},
|
||||
{
|
||||
"id": "fatal_application_quit",
|
||||
"class": "public class SessionManager ",
|
||||
"method": "public static void FatalApplicationQuit(int DKOGJICFAEP, string EPAIMCEIMPA = \"\")",
|
||||
"why": "Log-only: prints the quit reason and caller."
|
||||
},
|
||||
{
|
||||
"id": "try_application_quit",
|
||||
"class": "public class SessionManager ",
|
||||
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using DorkNet.Server.Services;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The March-2023 client budgets every CircuitsV2 network send against
|
||||
/// <c>RecRoom.CircuitsV2.Config.LimitConfig</c>, which it fills from the
|
||||
/// <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. Retail ships 5 RPCs per
|
||||
/// frame; past that the send is dropped and the room save is flagged
|
||||
/// <c>cv2_rpc_limited</c>. DorkNet serves budgets that lift the cap, and
|
||||
/// these tests pin both the wire shape the client's Newtonsoft reader
|
||||
/// binds and the clamps that keep a hand-edited value from crashing it.
|
||||
/// </summary>
|
||||
public sealed class CircuitsV2LimitsTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public CircuitsV2LimitsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Gameconfigs_endpoint_serves_the_network_budget()
|
||||
{
|
||||
using var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
|
||||
var rows = await client.GetFromJsonAsync<List<GameConfigurationSetting>>(
|
||||
"api/gameconfigs/v1/all");
|
||||
|
||||
var network = rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Network");
|
||||
var entry = JsonDocument.Parse(network.Value).RootElement.EnumerateArray().Single();
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||
// A server that never saved limits (empty column) serves the
|
||||
// current defaults, not the retail values.
|
||||
Assert.Equal("30000", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Chips").Value);
|
||||
Assert.Equal("0", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.InkPerChip").Value);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GameConfigurationSetting> Emit(CircuitsV2LimitSettings limits) =>
|
||||
ServerSettingsService.ToGameConfigurations(
|
||||
ServerSettingsService.DefaultRecCenterDoors(),
|
||||
ServerSettingsService.DefaultDiscoveredGameConfigs(),
|
||||
limits);
|
||||
|
||||
private static string ValueOf(IReadOnlyList<GameConfigurationSetting> rows, string key) =>
|
||||
rows.Single(r => r.Key == key).Value;
|
||||
|
||||
private static JsonElement NetworkOf(CircuitsV2LimitSettings limits) =>
|
||||
JsonDocument.Parse(ValueOf(Emit(limits), "UGC.CircuitsV2.Limit.Network")).RootElement;
|
||||
|
||||
[Fact]
|
||||
public void Defaults_lift_the_rpc_and_byte_budgets()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits());
|
||||
|
||||
Assert.Equal(JsonValueKind.Array, network.ValueKind);
|
||||
var entry = Assert.Single(network.EnumerateArray());
|
||||
// Retail is 5 RPCs / 1024 bytes; anything at those values means the
|
||||
// budget never actually got lifted.
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||
Assert.True(entry.GetProperty("Bytes").GetInt32() >= 1_000_000);
|
||||
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Network_json_uses_the_field_names_the_client_binds()
|
||||
{
|
||||
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits())
|
||||
.EnumerateArray().First();
|
||||
|
||||
// LimitConfig.NetworkConfig's il2cpp field names, verbatim.
|
||||
Assert.True(entry.TryGetProperty("NumPlayersForActivation", out _));
|
||||
Assert.True(entry.TryGetProperty("Bytes", out _));
|
||||
Assert.True(entry.TryGetProperty("BytesTimeWindowFrame60s", out _));
|
||||
Assert.True(entry.TryGetProperty("Rpcs", out _));
|
||||
Assert.True(entry.TryGetProperty("RpcTimeWindowFrame60s", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scalar_limits_serialize_invariant_without_separators()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Chips = 123456,
|
||||
CloudValuesAutosaveSeconds = 300f,
|
||||
});
|
||||
|
||||
// The client parses these with int/float.TryParse under
|
||||
// InvariantCulture, so a grouped or comma-decimal value silently
|
||||
// falls back to its baked default.
|
||||
Assert.Equal("123456", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
|
||||
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
|
||||
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_raise_the_room_size_limits_past_retail()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits());
|
||||
|
||||
// Retail: Compute 50000, Chips 3000, CloudValues 100,
|
||||
// Unculled/VisibleChips 500, InkPerChip 15.
|
||||
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||
Assert.Equal("30000", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
|
||||
Assert.Equal("1000", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
|
||||
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.UnculledChips"));
|
||||
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.VisibleChips"));
|
||||
// InkPerChip is a cost, so the lift is downward.
|
||||
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
// A save cadence, not a capacity limit: left at retail.
|
||||
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_visible_chips_do_not_exceed_the_chip_cap()
|
||||
{
|
||||
var limits = ServerSettingsService.DefaultCircuitsV2Limits();
|
||||
|
||||
// The add-chip path only charges InkPerChip when Chips < VisibleChips
|
||||
// (CircuitsV2Manager_NestedType_AMEJFMPHNEO.txt ISIL 228-259), so the
|
||||
// defaults keep that path off as well as zeroing the cost.
|
||||
Assert.True(limits.VisibleChips <= limits.Chips);
|
||||
Assert.True(limits.UnculledChips <= limits.Chips);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ink_cost_and_cloud_values_are_capped_so_their_product_fits_an_int()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
InkPerChip = int.MaxValue,
|
||||
CloudValues = int.MaxValue,
|
||||
});
|
||||
|
||||
// The client computes InkPerChip * Math.Min(numChips, CloudValues)
|
||||
// in plain int; InkPerChip past the hard-coded 30000-ink room cap
|
||||
// also makes a single chip unaffordable.
|
||||
var ink = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
var cloud = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
|
||||
Assert.Equal(30_000, ink);
|
||||
Assert.Equal(10_000, cloud);
|
||||
Assert.True((long)ink * cloud < int.MaxValue / 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_ink_cost_is_floored_at_zero()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { InkPerChip = -5 });
|
||||
|
||||
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chips_are_bounded_because_the_culling_manager_preallocates_by_them()
|
||||
{
|
||||
var low = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = 0 });
|
||||
var high = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = int.MaxValue });
|
||||
|
||||
// Four List<Chip>(Chips) at culling-manager construction: 0 or less
|
||||
// throws, and int.MaxValue is an out-of-memory kill.
|
||||
Assert.Equal("1", ValueOf(low, "UGC.CircuitsV2.Limit.Chips"));
|
||||
Assert.Equal("1000000", ValueOf(high, "UGC.CircuitsV2.Limit.Chips"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_stays_finite_because_it_is_the_runaway_loop_guard()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Compute = int.MaxValue });
|
||||
|
||||
Assert.Equal("10000000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_rpcs_is_clamped_because_the_debug_subscreen_divides_by_it()
|
||||
{
|
||||
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network = [new(0, 1024, 60, Rpcs: 0, RpcTimeWindowFrame60s: 1)],
|
||||
}).EnumerateArray().First();
|
||||
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Time_windows_are_capped_so_the_ring_buffer_stays_small()
|
||||
{
|
||||
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network = [new(0, 1024, int.MaxValue, 5, int.MaxValue)],
|
||||
}).EnumerateArray().First();
|
||||
|
||||
// Each window frame is one int in a client-side ring buffer, so an
|
||||
// unbounded window is an out-of-memory kill at circuit init.
|
||||
Assert.Equal(3600, entry.GetProperty("RpcTimeWindowFrame60s").GetInt32());
|
||||
Assert.Equal(3600, entry.GetProperty("BytesTimeWindowFrame60s").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lowest_entry_is_forced_to_activate_at_zero_players()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network =
|
||||
[
|
||||
new(20, 4096, 60, 50, 1),
|
||||
new(8, 2048, 60, 25, 1),
|
||||
],
|
||||
});
|
||||
|
||||
// Without an entry at 0 the client rejects the whole array and
|
||||
// falls back to the retail 5-RPC budget.
|
||||
var entries = network.EnumerateArray().ToList();
|
||||
Assert.Equal(0, entries[0].GetProperty("NumPlayersForActivation").GetInt32());
|
||||
// The rewrite keeps that entry's own budget.
|
||||
Assert.Equal(25, entries[0].GetProperty("Rpcs").GetInt32());
|
||||
Assert.Equal(20, entries[1].GetProperty("NumPlayersForActivation").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_thresholds_collapse_and_entries_sort_ascending()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network =
|
||||
[
|
||||
new(16, 4096, 60, 50, 1),
|
||||
new(0, 1024, 60, 10, 1),
|
||||
new(16, 8192, 60, 99, 1),
|
||||
],
|
||||
});
|
||||
|
||||
var thresholds = network.EnumerateArray()
|
||||
.Select(e => e.GetProperty("NumPlayersForActivation").GetInt32())
|
||||
.ToList();
|
||||
Assert.Equal([0, 16], thresholds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_network_list_falls_back_to_the_default_budget()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network = [],
|
||||
});
|
||||
|
||||
var entry = Assert.Single(network.EnumerateArray());
|
||||
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,13 @@ public sealed class GameClientEndpointContractTests :
|
||||
if (!string.Equals(route.Method, "DELETE", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
// Case-insensitive to match the method comparison above: a route
|
||||
// declared "Account/v1/me" would otherwise slip through and delete
|
||||
// the probe player mid-run.
|
||||
var template = route.Template.Trim('/');
|
||||
return template is "account/me" or "account/v1/me" or "account/v2/me";
|
||||
return template.Equals("account/me", StringComparison.OrdinalIgnoreCase)
|
||||
|| template.Equals("account/v1/me", StringComparison.OrdinalIgnoreCase)
|
||||
|| template.Equals("account/v2/me", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static async Task<EndpointProbeResult> ProbeAsync(
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>GET api/incentivizedreferrals/referrals?take=&continuationToken=</c> is
|
||||
/// assembled by the client's URL builder (RecNet.Runtime/LINKIECPDML.txt:677-737),
|
||||
/// so it never showed up as a route literal and had no handler. The reply is the
|
||||
/// GPJIMAHMAIO page: <c>{Referrals:[{InviteeAccountId, CreatedAt, VerifiedAt}],
|
||||
/// ContinuationToken}</c> (KOEEACLMAFE.txt:203-238, ADAFNGNADFH.txt:279-346).
|
||||
/// </summary>
|
||||
public sealed class IncentivizedReferralsTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public IncentivizedReferralsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Referrals_page_lists_credited_invitees_with_a_continuation_token()
|
||||
{
|
||||
using var setup = Client();
|
||||
var referrer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var inviteeA = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var inviteeB = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
// Same row LinkController.CreditReferralAsync writes on the invitee.
|
||||
foreach (var invitee in new[] { inviteeA, inviteeB })
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = invitee.PlayerId,
|
||||
Key = $"referral:credited:{referrer.PlayerId}:{invitee.PlayerId}",
|
||||
Value = DateTime.UtcNow.ToString("O"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = Client(referrer);
|
||||
var first = JsonDocument.Parse(
|
||||
await client.GetStringAsync("/api/incentivizedreferrals/referrals?take=1")).RootElement;
|
||||
var firstPage = Get(first, "Referrals");
|
||||
Assert.Equal(1, firstPage.GetArrayLength());
|
||||
var token = Get(first, "ContinuationToken").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(token));
|
||||
|
||||
var second = JsonDocument.Parse(await client.GetStringAsync(
|
||||
$"/api/incentivizedreferrals/referrals?take=1&continuationToken={token}")).RootElement;
|
||||
var secondPage = Get(second, "Referrals");
|
||||
Assert.Equal(1, secondPage.GetArrayLength());
|
||||
Assert.Equal(JsonValueKind.Null, Get(second, "ContinuationToken").ValueKind);
|
||||
|
||||
var ids = new[] { firstPage[0], secondPage[0] }
|
||||
.Select(e => Get(e, "InviteeAccountId").GetInt64())
|
||||
.OrderBy(x => x)
|
||||
.ToArray();
|
||||
Assert.Equal(new[] { inviteeA.PlayerId, inviteeB.PlayerId }.OrderBy(x => x), ids);
|
||||
// CreatedAt is a non-nullable DateTime on the client: it must be present.
|
||||
Assert.Equal(JsonValueKind.String, Get(firstPage[0], "CreatedAt").ValueKind);
|
||||
}
|
||||
|
||||
// The client formatter registers PascalCase, camelCase and lowercase.
|
||||
private static JsonElement Get(JsonElement e, string pascal) =>
|
||||
e.TryGetProperty(pascal, out var v) ? v : e.GetProperty(char.ToLowerInvariant(pascal[0]) + pascal[1..]);
|
||||
|
||||
private HttpClient Client(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.API.Avatar.V4;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire contract of the 2023-03-21 client's outfit / wardrobe menu, as read
|
||||
/// from RecNet.Runtime ISIL: saved-outfit slots (BEPMJICDNDD via converter
|
||||
/// IMMOJHBENFI), the custom-avatar-item gates the menu queries when it opens,
|
||||
/// the locked-item lookup, the owned list's per-entry ids, and the gift
|
||||
/// generate call the menu's gift flow makes.
|
||||
/// </summary>
|
||||
public sealed class OutfitMenu2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public OutfitMenu2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Creation_allowed_gate_is_a_success_error_envelope()
|
||||
{
|
||||
// MPBLNLMCEDL.GGMDLMLJDDK parses DJMHAFPGLLN {Success, Error}
|
||||
// (LMCCNLLHLCJ.txt:191-254); a bare boolean is what logged
|
||||
// "Failed to query if local account can create Custom Avatar Items".
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var body = await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationAllowedForAccount");
|
||||
Assert.Equal(JsonValueKind.Object, body.ValueKind);
|
||||
Assert.Equal(JsonValueKind.True, body.GetProperty("Success").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, body.GetProperty("Error").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sibling_gates_and_min_price_are_bare_scalars()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationEnabled")).ValueKind);
|
||||
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isRenderingEnabled")).ValueKind);
|
||||
|
||||
// <GetPricingConfig>b__69_0(Int32 minimumPrice) — a bare integer.
|
||||
var min = await GetJsonAsync(client, "/api/customAvatarItems/v1/minPriceForPublicItem");
|
||||
Assert.Equal(JsonValueKind.Number, min.ValueKind);
|
||||
Assert.True(min.GetInt32() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Saved_outfit_round_trips_every_client_key()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var shirt = Guid.NewGuid();
|
||||
// Exactly what the client's writer (IMMOJHBENFI.txt:781-910) emits.
|
||||
var outfit = new Dictionary<string, object?>
|
||||
{
|
||||
["Slot"] = 3,
|
||||
["PreviewImageName"] = "preview-3.jpg",
|
||||
["OutfitSelections"] = "",
|
||||
["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"a\",\"b\"]}",
|
||||
["FaceFeatures"] = "{\"ver\":3}",
|
||||
["SkinColor"] = "skin-guid",
|
||||
["HairColor"] = "hair-guid",
|
||||
["CustomAvatarItems"] = new[] { new { CustomAvatarItemId = shirt, BodyPart = 2 } },
|
||||
};
|
||||
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
|
||||
|
||||
var list = await GetJsonAsync(client, "/api/avatar/v3/saved");
|
||||
var saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
|
||||
Assert.Equal("preview-3.jpg", saved.GetProperty("PreviewImageName").GetString());
|
||||
Assert.Equal("{\"v\":2,\"items\":[\"a\",\"b\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
|
||||
Assert.Equal("{\"ver\":3}", saved.GetProperty("FaceFeatures").GetString());
|
||||
Assert.Equal("skin-guid", saved.GetProperty("SkinColor").GetString());
|
||||
Assert.Equal("hair-guid", saved.GetProperty("HairColor").GetString());
|
||||
var items = saved.GetProperty("CustomAvatarItems");
|
||||
Assert.Equal(1, items.GetArrayLength());
|
||||
Assert.Equal(shirt, items[0].GetProperty("CustomAvatarItemId").GetGuid());
|
||||
Assert.Equal(2, items[0].GetProperty("BodyPart").GetInt32());
|
||||
|
||||
// Overwriting the slot replaces it rather than adding a second row.
|
||||
outfit["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"c\"]}";
|
||||
outfit["CustomAvatarItems"] = Array.Empty<object>();
|
||||
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
|
||||
list = await GetJsonAsync(client, "/api/avatar/v3/saved");
|
||||
saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
|
||||
Assert.Equal("{\"v\":2,\"items\":[\"c\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
|
||||
Assert.Equal(0, saved.GetProperty("CustomAvatarItems").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gift_generate_accepts_the_clients_form_body()
|
||||
{
|
||||
// Live trace: this exact form body was answered 415 under [FromBody].
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
using var response = await client.PostAsync("/api/avatar/v2/gifts/generate",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["GiftContext"] = "12000",
|
||||
["IsGameGift"] = "True",
|
||||
["Message"] = "Activity completed!",
|
||||
}));
|
||||
Assert.NotEqual(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.True(doc.RootElement.GetProperty("Id").GetInt64() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locked_items_carry_the_full_owned_item_shape()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
HashSet<string> owned;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var inv = await db.Avatars.Where(a => a.PlayerId == player.PlayerId)
|
||||
.Select(a => a.InventoryJson).FirstAsync();
|
||||
owned = (JsonSerializer.Deserialize<List<string>>(inv) ?? new())
|
||||
.Select(d => d.Replace("wardrobe-", "").Split(',')[0])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
var notOwned = AvatarItemsController.Catalog.Keys
|
||||
.Where(g => Guid.TryParse(g, out _) && !owned.Contains(g)).Take(3).ToList();
|
||||
Assert.Equal(3, notOwned.Count);
|
||||
|
||||
var query = string.Join("&", notOwned.Select(g => "desc=" + Uri.EscapeDataString(g + ",,,")));
|
||||
var rows = await GetJsonAsync(client, "/api/avatar/v1/lockeditems?" + query);
|
||||
Assert.Equal(3, rows.GetArrayLength());
|
||||
var ids = new List<int>();
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
// Same IFNGBPJGINL keys v4/items carries (CLIANCNHNIE reader).
|
||||
Assert.Equal(JsonValueKind.String, row.GetProperty("TagList").ValueKind);
|
||||
Assert.True(row.GetProperty("IsBaseAvatarItem").ValueKind is JsonValueKind.True or JsonValueKind.False);
|
||||
ids.Add(row.GetProperty("AvatarItemId").GetInt32());
|
||||
}
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owning_a_base_item_and_its_colorways_yields_unique_ids()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
// Two colorways of one base item that the runtime catalog knows.
|
||||
var group = StoreService.ColorVariantCatalog.Values
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.descriptor)
|
||||
&& AvatarItemsController.Catalog.ContainsKey(v.item_guid))
|
||||
.GroupBy(v => v.item_guid, StringComparer.OrdinalIgnoreCase)
|
||||
.First(g => g.Select(v => v.descriptor).Distinct().Count() >= 2);
|
||||
var baseGuid = group.Key;
|
||||
var variants = group.Select(v => v.descriptor).Distinct().Take(2).ToList();
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
|
||||
var inv = JsonSerializer.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
inv.Add(baseGuid);
|
||||
inv.AddRange(variants);
|
||||
avatar.InventoryJson = JsonSerializer.Serialize(inv);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var sameBase = items.EnumerateArray()
|
||||
.Where(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]
|
||||
.Equals(baseGuid, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
Assert.Equal(3, sameBase.Count);
|
||||
|
||||
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task PostJsonAsync(HttpClient client, string path, object body)
|
||||
{
|
||||
using var response = await client.PostAsync(path,
|
||||
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,42 @@ public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
|
||||
}
|
||||
}
|
||||
|
||||
// Beard_Close: one of the client's defaultUnlockedVariants.
|
||||
private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f";
|
||||
|
||||
[Fact]
|
||||
public async Task Default_unlocked_items_are_owned_by_every_player()
|
||||
{
|
||||
// The client replaces its unlocked set with each list it downloads,
|
||||
// so v4/items must carry the free default items or they show a price.
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
||||
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
|
||||
// A default item the starter inventory also holds appears once.
|
||||
Assert.Equal(descs.Count, descs.Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
|
||||
var locked = await GetJsonAsync(client, $"/api/avatar/v1/lockeditems?avatarItemDescs={DefaultItemGuid},,,");
|
||||
Assert.Equal(0, locked.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_unlocked_endpoint_serves_defaults_before_login()
|
||||
{
|
||||
using var anonymous = ApiClient();
|
||||
var items = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
||||
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Equal(
|
||||
DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.DefaultUnlockedGuids.Count,
|
||||
descs.Count);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Presence must follow the player through every transition, for the player
|
||||
/// themselves (the heartbeat) and for everyone else (bulk /player and the
|
||||
/// PresenceUpdate push).
|
||||
///
|
||||
/// The heartbeat side is decided by the client's compare
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:18415-18467): a cached instance with
|
||||
/// a negative id — the offline room — is in sync ONLY with roomInstance null,
|
||||
/// and anything else makes the client cancel what it is doing and chase the
|
||||
/// server's room. matchmake/none is how the client tells us it went to that
|
||||
/// offline room, so after it the heartbeat must stay null.
|
||||
/// </summary>
|
||||
public sealed class PresenceTransitionTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public PresenceTransitionTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_to_the_offline_room_makes_the_heartbeat_report_no_room()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
// Enter the dorm for real: matchmake, then report the join.
|
||||
var dormInstance = await MatchmakeDormAsync(client);
|
||||
await ReportJoinAsync(client, dormInstance);
|
||||
var before = await HeartbeatAsync(client);
|
||||
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(before)));
|
||||
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode, $"POST /matchmake/none -> {(int)none.StatusCode}");
|
||||
|
||||
// Twice: the first beat must not re-seed a dorm the second one returns.
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var beat = await HeartbeatAsync(client);
|
||||
var room = RoomInstanceOf(beat);
|
||||
Assert.True(room.ValueKind == JsonValueKind.Null,
|
||||
$"""
|
||||
After matchmake/none the client holds its offline room (instance -1)
|
||||
and is only in sync with a null roomInstance. The heartbeat answered
|
||||
with a room, which makes the client cancel the offline load and
|
||||
chase it:
|
||||
|
||||
{beat.GetRawText()}
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_while_a_join_is_pending_abandons_the_join()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
// The client gave up on this dorm join (e.g. a Photon timeout) and fell
|
||||
// back to the offline room before reporting a result.
|
||||
await MatchmakeDormAsync(client);
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
|
||||
var beat = await HeartbeatAsync(client);
|
||||
Assert.True(RoomInstanceOf(beat).ValueKind == JsonValueKind.Null,
|
||||
$"heartbeat still echoed the abandoned join target: {beat.GetRawText()}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Matchmaking_again_after_leaving_restores_the_room()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
Assert.True(RoomInstanceOf(await HeartbeatAsync(client)).ValueKind == JsonValueKind.Null);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(client);
|
||||
await ReportJoinAsync(client, dormInstance);
|
||||
|
||||
var beat = await HeartbeatAsync(client);
|
||||
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(beat)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Friends_see_a_player_in_the_offline_room_as_online_with_no_room()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(subject);
|
||||
await ReportJoinAsync(subject, dormInstance);
|
||||
using var none = await subject.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.True(row.GetProperty("isOnline").GetBoolean(),
|
||||
$"a player sitting in the offline room is online: {row.GetRawText()}");
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logging_out_shows_the_player_offline_and_roomless()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(subject);
|
||||
await ReportJoinAsync(subject, dormInstance);
|
||||
Assert.True((await BulkPresenceAsync(viewer, subjectId)).GetProperty("isOnline").GetBoolean());
|
||||
|
||||
using var logout = await subject.PostAsync("/player/logout", Form());
|
||||
Assert.True(logout.IsSuccessStatusCode);
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.False(row.GetProperty("isOnline").GetBoolean(), row.GetRawText());
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bulk_presence_reports_the_stored_status_visibility()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
using var put = await subject.PutAsync("/player/statusvisibility",
|
||||
Form(("statusVisibility", "2")));
|
||||
Assert.True(put.IsSuccessStatusCode, $"PUT statusvisibility -> {(int)put.StatusCode}");
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.Equal(2, row.GetProperty("statusVisibility").GetInt32());
|
||||
|
||||
// And the player's own heartbeat agrees.
|
||||
var beat = await HeartbeatAsync(subject);
|
||||
Assert.Equal(2, beat.GetProperty("statusVisibility").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_push_carries_online_state_independently_of_the_room()
|
||||
{
|
||||
var room = new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = 42,
|
||||
RoomId = 7,
|
||||
Name = "DormRoom",
|
||||
PhotonRoomId = "^dormroom_p1",
|
||||
};
|
||||
|
||||
// Online, no room: the offline-room state. Used to be sent as OFFLINE
|
||||
// because isOnline was derived from the room.
|
||||
var onlineNoRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, null, isOnline: true));
|
||||
Assert.True(onlineNoRoom.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, onlineNoRoom.GetProperty("roomInstance").ValueKind);
|
||||
|
||||
// Offline never leaks a room, even if one is still cached.
|
||||
var offline = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: false));
|
||||
Assert.False(offline.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, offline.GetProperty("roomInstance").ValueKind);
|
||||
|
||||
// Online in a room: redacted join coordinates, visibility passed through,
|
||||
// appVersion a STRING.
|
||||
var inRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: true, statusVisibility: 1));
|
||||
Assert.True(inRoom.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(42, inRoom.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64());
|
||||
Assert.Equal(string.Empty, inRoom.GetProperty("roomInstance").GetProperty("photonRoomId").GetString());
|
||||
Assert.Equal(1, inRoom.GetProperty("statusVisibility").GetInt32());
|
||||
Assert.Equal(JsonValueKind.String, inRoom.GetProperty("appVersion").ValueKind);
|
||||
|
||||
static JsonElement Msg(string payload)
|
||||
{
|
||||
var root = JsonDocument.Parse(payload).RootElement;
|
||||
// String-keyed channel: the client registers "PresenceUpdate".
|
||||
Assert.Equal("PresenceUpdate", root.GetProperty("Id").GetString());
|
||||
return root.GetProperty("Msg");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_pushes_honour_the_players_status_visibility()
|
||||
{
|
||||
// Live PresenceUpdate frames must hide a player exactly as GET /player
|
||||
// does, or "appear offline" leaks every room change to friends.
|
||||
long[] friends = [10, 11, 12];
|
||||
var favorited = new HashSet<long> { 11 };
|
||||
|
||||
var pub = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Public, favorited);
|
||||
Assert.Equal(friends, pub.VisibleTo);
|
||||
Assert.Empty(pub.HiddenFrom);
|
||||
|
||||
// Every recipient is already a friend, so FriendsOnly hides nobody.
|
||||
var friendsOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FriendsOnly, favorited);
|
||||
Assert.Equal(friends, friendsOnly.VisibleTo);
|
||||
Assert.Empty(friendsOnly.HiddenFrom);
|
||||
|
||||
var favoritesOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FavoriteFriendsOnly, favorited);
|
||||
Assert.Equal([11L], favoritesOnly.VisibleTo);
|
||||
Assert.Equal([10L, 12L], favoritesOnly.HiddenFrom);
|
||||
|
||||
var offline = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Offline, favorited);
|
||||
Assert.Empty(offline.VisibleTo);
|
||||
Assert.Equal(friends, offline.HiddenFrom);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<HttpClient> SignedInMatchClientAsync()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://match.{_factory.ApexDomain}");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<long> PlayerIdOfAsync(HttpClient client)
|
||||
{
|
||||
var beat = await HeartbeatAsync(client);
|
||||
return beat.GetProperty("playerId").GetInt64();
|
||||
}
|
||||
|
||||
private static async Task<string> MatchmakeDormAsync(HttpClient client)
|
||||
{
|
||||
using var resp = await client.PostAsync("/matchmake/dorm", Form(("MaxPersistenceVersion", "45")));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"POST /matchmake/dorm -> {(int)resp.StatusCode}: {body}");
|
||||
var id = FindInstanceId(JsonDocument.Parse(body).RootElement);
|
||||
Assert.False(string.IsNullOrEmpty(id), $"no roomInstanceId in the dorm reply: {body}");
|
||||
return id!;
|
||||
}
|
||||
|
||||
private static async Task ReportJoinAsync(HttpClient client, string instanceId)
|
||||
{
|
||||
using var resp = await client.PostAsync($"/roominstance/{instanceId}/reportjoinresult", Form(("result", "0")));
|
||||
Assert.True(resp.IsSuccessStatusCode, $"reportjoinresult -> {(int)resp.StatusCode}");
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> HeartbeatAsync(HttpClient client)
|
||||
{
|
||||
using var resp = await client.PostAsync("/player/heartbeat", Form(("LoginLock", Guid.NewGuid().ToString())));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"POST /player/heartbeat -> {(int)resp.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> BulkPresenceAsync(HttpClient viewer, long subjectId)
|
||||
{
|
||||
using var resp = await viewer.GetAsync($"/player?{subjectId}");
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"GET /player -> {(int)resp.StatusCode}: {body}");
|
||||
foreach (var row in JsonDocument.Parse(body).RootElement.EnumerateArray())
|
||||
{
|
||||
if (row.GetProperty("playerId").GetInt64() == subjectId) return row.Clone();
|
||||
}
|
||||
Assert.Fail($"no row for {subjectId}: {body}");
|
||||
return default;
|
||||
}
|
||||
|
||||
private static JsonElement RoomInstanceOf(JsonElement presence)
|
||||
{
|
||||
Assert.True(presence.TryGetProperty("roomInstance", out var room),
|
||||
$"no roomInstance key: {presence.GetRawText()}");
|
||||
return room;
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) =>
|
||||
new(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
|
||||
|
||||
private static string? FindInstanceId(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value.ToString();
|
||||
if (property.Value.ValueKind == JsonValueKind.Object
|
||||
&& FindInstanceId(property.Value) is { } nested)
|
||||
return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-game profile edits for the 2023-03-21 client. Every edit goes through
|
||||
/// OPMAPIOEIFG.OBAGJBBIFGB → PUT "account/me/" + suffix with a single
|
||||
/// urlencoded field whose name differs from the suffix (displayEmoji,
|
||||
/// pronounFlags, identityFlags, imageName). None of these routes existed, so
|
||||
/// every edit failed with "Failed to modify <suffix>", and no account
|
||||
/// DTO emitted the fields, so nothing showed up for anyone. Online-status
|
||||
/// visibility (PUT player/statusvisibility) was persisted but GET /player
|
||||
/// always reported Public and never hid the player.
|
||||
/// </summary>
|
||||
public sealed class ProfileEdit2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ProfileEdit2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Emoji_edit_persists_and_is_visible_to_other_players()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "🤪");
|
||||
|
||||
var me = await GetJsonAsync(ownerClient, "/account/me");
|
||||
Assert.Equal("🤪", me.GetProperty("displayEmoji").GetString());
|
||||
|
||||
var seen = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}");
|
||||
Assert.Equal("🤪", seen.GetProperty("displayEmoji").GetString());
|
||||
|
||||
// Empty clears it.
|
||||
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "");
|
||||
me = await GetJsonAsync(ownerClient, "/account/me");
|
||||
Assert.Equal("", me.GetProperty("displayEmoji").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emoji_edit_rejects_non_whitelisted_and_canonicalizes_variation_selector()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
|
||||
using (var bad = await PutFormAsync(client, "/account/me/emoji", "displayEmoji", "not-an-emoji"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
|
||||
// "✌" without U+FE0F resolves to the list's "✌️".
|
||||
await PutFormOkAsync(client, "/account/me/emoji", "displayEmoji", "✌");
|
||||
var me = await GetJsonAsync(client, "/account/me");
|
||||
Assert.Equal("✌️", me.GetProperty("displayEmoji").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pronouns_identity_flags_and_banner_round_trip_on_every_account_read()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/personalpronouns", "pronounFlags", "5");
|
||||
await PutFormOkAsync(ownerClient, "/account/me/identityflags", "identityFlags", "3");
|
||||
await PutFormOkAsync(ownerClient, "/account/me/bannerimage", "imageName", "banner-test.jpg");
|
||||
|
||||
void AssertProfile(JsonElement a)
|
||||
{
|
||||
Assert.Equal(5, a.GetProperty("personalPronouns").GetInt32());
|
||||
Assert.Equal(3, a.GetProperty("identityFlags").GetInt32());
|
||||
Assert.Equal("banner-test.jpg", a.GetProperty("bannerImage").GetString());
|
||||
}
|
||||
|
||||
AssertProfile(await GetJsonAsync(ownerClient, "/account/me"));
|
||||
AssertProfile(await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}"));
|
||||
|
||||
var bulk = await GetJsonAsync(viewerClient, $"/account/bulk?id={owner.PlayerId}");
|
||||
AssertProfile(bulk.EnumerateArray().Single(a => a.GetProperty("accountId").GetInt64() == owner.PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flag_edits_reject_non_numeric_values()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
|
||||
using (var r = await PutFormAsync(client, "/account/me/personalpronouns", "pronounFlags", "HeHim"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||
using (var r = await PutFormAsync(client, "/account/me/identityflags", "identityFlags", "-1"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Self_account_reports_username_changes_remaining()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
var me = await GetJsonAsync(client, "/account/me");
|
||||
// Absent/0 makes the settings UI say no username changes are left.
|
||||
Assert.True(me.GetProperty("availableUsernameChanges").GetInt32() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bio_edit_round_trips_through_public_bio_lookup()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/bio", "bio", "hello from the profile");
|
||||
var bio = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}/bio");
|
||||
Assert.Equal("hello from the profile", bio.GetProperty("bio").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_visibility_is_reported_to_self_and_hides_appear_offline_player()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
PutOnline(owner.PlayerId);
|
||||
|
||||
using var ownerMatch = MatchClient(owner);
|
||||
using var viewerMatch = MatchClient(viewer);
|
||||
|
||||
// Baseline: Public → viewer sees owner online.
|
||||
var before = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||
Assert.True(before.GetProperty("isOnline").GetBoolean());
|
||||
|
||||
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "3");
|
||||
|
||||
// Self keeps its own choice (the dialog reads the cached self presence).
|
||||
var self = await PresenceOfAsync(ownerMatch, owner.PlayerId);
|
||||
Assert.Equal(3, self.GetProperty("statusVisibility").GetInt32());
|
||||
Assert.True(self.GetProperty("isOnline").GetBoolean());
|
||||
|
||||
// Others see an offline player with no room.
|
||||
var other = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||
Assert.False(other.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, other.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Friends_only_visibility_shows_friends_and_hides_strangers()
|
||||
{
|
||||
var (owner, friend) = await TwoPlayersAsync();
|
||||
using var setup = AccountsClient();
|
||||
var stranger = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
PutOnline(owner.PlayerId);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Relationships.Add(new RelationshipEntity
|
||||
{
|
||||
RequesterId = owner.PlayerId,
|
||||
TargetId = friend.PlayerId,
|
||||
Status = RelationshipStatus.Friend,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ownerMatch = MatchClient(owner);
|
||||
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "1");
|
||||
|
||||
using var friendMatch = MatchClient(friend);
|
||||
using var strangerMatch = MatchClient(stranger);
|
||||
Assert.True((await PresenceOfAsync(friendMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||
Assert.False((await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(1, (await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("statusVisibility").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_pref_key_matches_status_visibility_writer()
|
||||
{
|
||||
// MatchPlayerController writes the pref; PlayerPresenceController reads it.
|
||||
var field = typeof(MatchPlayerController).GetField(
|
||||
"PrefStatusVisibility",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
Assert.NotNull(field);
|
||||
Assert.Equal(PlayerPresenceController.PrefStatusVisibility, (string)field!.GetRawConstantValue()!);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private void PutOnline(long playerId)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
presence.SetRoom(playerId, new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = 900_000 + playerId,
|
||||
RoomId = 1,
|
||||
SubRoomId = 0,
|
||||
PhotonRoomId = $"^profiletest{playerId}",
|
||||
PhotonRegionId = "us",
|
||||
MaxCapacity = 8,
|
||||
});
|
||||
presence.MarkActive(playerId);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PresenceOfAsync(HttpClient client, long playerId)
|
||||
{
|
||||
var list = await GetJsonAsync(client, $"/player?{playerId}");
|
||||
return list.EnumerateArray().Single(p => p.GetProperty("playerId").GetInt64() == playerId);
|
||||
}
|
||||
|
||||
private async Task<(GameClientSession, GameClientSession)> TwoPlayersAsync()
|
||||
{
|
||||
using var setup = AccountsClient();
|
||||
var a = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var b = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
return (a, b);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> PutFormAsync(HttpClient client, string path, string field, string value)
|
||||
=> await client.PutAsync(path, new FormUrlEncodedContent(new Dictionary<string, string> { [field] = value }));
|
||||
|
||||
private static async Task PutFormOkAsync(HttpClient client, string path, string field, string value)
|
||||
{
|
||||
using var response = await PutFormAsync(client, path, field, value);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"PUT {path} → {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
private HttpClient AccountsClient(GameClientSession? session = null) => Client("accounts", session);
|
||||
|
||||
private HttpClient MatchClient(GameClientSession session) => Client("match", session);
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} → {(int)response.StatusCode}: {body}");
|
||||
using var document = JsonDocument.Parse(body);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A Quest client patched without its own Meta app has no platform session,
|
||||
/// so every headset reports the same placeholder platform user id ("1").
|
||||
/// Accounts must stay keyed by device id: two headsets get two accounts, a
|
||||
/// headset gets its own account back on the next boot, and the placeholder is
|
||||
/// never stored or matched by the remembered-logins lookup — otherwise every
|
||||
/// Quest player would be offered every other Quest player's account.
|
||||
/// </summary>
|
||||
public sealed class QuestPlaceholderPlatformIdTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
// RecNet platform value the 2023 Quest client sends on /connect/token.
|
||||
// The fix does not depend on it; any platform with a placeholder id is
|
||||
// handled the same way.
|
||||
private const string QuestPlatform = "1";
|
||||
private const string PlaceholderPlatformId = "1";
|
||||
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public QuestPlaceholderPlatformIdTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true)]
|
||||
[InlineData("", true)]
|
||||
[InlineData(" ", true)]
|
||||
[InlineData("0", true)]
|
||||
[InlineData("1", true)]
|
||||
[InlineData(" 1 ", true)]
|
||||
[InlineData("10", false)]
|
||||
[InlineData("2173678582678296", false)]
|
||||
public void Placeholder_platform_ids_are_recognised(string? platformId, bool expected)
|
||||
=> Assert.Equal(expected, PlayerService.IsPlaceholderPlatformId(platformId));
|
||||
|
||||
[Fact]
|
||||
public async Task Two_headsets_with_the_placeholder_platform_id_get_separate_accounts()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
var headsetA = $"quest-headset-a-{Guid.NewGuid():N}";
|
||||
var headsetB = $"quest-headset-b-{Guid.NewGuid():N}";
|
||||
|
||||
var a = await CreateAccountAsync(client, headsetA);
|
||||
var b = await CreateAccountAsync(client, headsetB);
|
||||
Assert.NotEqual(a, b);
|
||||
|
||||
// Same headset, next boot: its own account comes back.
|
||||
var aAgain = await CreateAccountAsync(client, headsetA);
|
||||
Assert.Equal(a, aAgain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Placeholder_platform_id_never_lists_remembered_logins()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
|
||||
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
$"/cachedlogin/forplatformid/{QuestPlatform}/{PlaceholderPlatformId}");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
using var json = JsonDocument.Parse(body);
|
||||
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
|
||||
Assert.Equal(0, json.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_real_platform_id_still_lists_its_remembered_login()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
var realPlatformId = $"{Random.Shared.NextInt64(10_000_000_000, 99_999_999_999)}";
|
||||
var id = await CreateAccountAsync(client, $"quest-meta-{Guid.NewGuid():N}", realPlatformId);
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
$"/cachedlogin/forplatformid/{QuestPlatform}/{realPlatformId}");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
|
||||
Assert.Contains(id.ToString(), body);
|
||||
}
|
||||
|
||||
// The 2023 client's account-creation request (LBNJFPOLCDL: grant_type,
|
||||
// client_id, platform, platform_id, device_id, ...). No username/password,
|
||||
// so the server takes the device-id path.
|
||||
private async Task<long> CreateAccountAsync(
|
||||
HttpClient client, string deviceId, string platformId = PlaceholderPlatformId)
|
||||
{
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "create_account",
|
||||
["client_id"] = "recroom",
|
||||
["platform"] = QuestPlatform,
|
||||
["platform_id"] = platformId,
|
||||
["device_id"] = deviceId,
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token")
|
||||
{
|
||||
Content = new FormUrlEncodedContent(form),
|
||||
};
|
||||
request.Headers.Accept.ParseAdd("application/json");
|
||||
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.17");
|
||||
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"POST /connect/token -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var accessToken = json.RootElement.GetProperty("access_token").GetString()!;
|
||||
|
||||
using var userInfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo");
|
||||
userInfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var userInfoResponse = await client.SendAsync(userInfo);
|
||||
userInfoResponse.EnsureSuccessStatusCode();
|
||||
using var userJson = JsonDocument.Parse(await userInfoResponse.Content.ReadAsStringAsync());
|
||||
return long.Parse(userJson.RootElement.GetProperty("sub").GetString()!);
|
||||
}
|
||||
|
||||
private HttpClient Client(string host)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Writes whose client method returns the untyped promise (<c>LDGADANDBIO</c>)
|
||||
/// but whose reply is still parsed — as <c>DJMHAFPGLLN</c>, the
|
||||
/// <c>{Success, Error}</c> RecNetResult — by <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
|
||||
/// (ExpandRecNetResult, RecNet.Runtime/BFDGEKKNAPB.txt:353).
|
||||
///
|
||||
/// The failure modes are silent server-side: an empty 200 parses to null and
|
||||
/// null-derefs, so the player sees "Failed to …" after the change was applied;
|
||||
/// a body without <c>Success</c> parses as a failure whose <c>Error</c> is null,
|
||||
/// and <c>Promise.Rejected(null)</c> throws "Error message cannot be null".
|
||||
/// So the assertion that matters is the body, read the way the client's
|
||||
/// formatter reads it (LMCCNLLHLCJ.txt: Success/success, Error/error).
|
||||
/// </summary>
|
||||
public sealed class RecNetResultEnvelopeTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RecNetResultEnvelopeTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Club_member_writes_answer_a_success_result_not_an_empty_body()
|
||||
{
|
||||
using var setup = Client("clubs");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var joiner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var clubId = await SeedOpenClubAsync(owner.PlayerId);
|
||||
|
||||
using var joinerClient = Client("clubs", joiner);
|
||||
// requesttojoin is verb 3 (PUT) through IKMMOCKDKAF.JNFEEDLCGHH, form-encoded.
|
||||
using var join = await joinerClient.PutAsync(
|
||||
$"/club/{clubId}/members/requesttojoin", new FormUrlEncodedContent([]));
|
||||
await AssertRecNetSuccessAsync(join, "requesttojoin");
|
||||
|
||||
using var leave = await joinerClient.PostAsync(
|
||||
$"/club/{clubId}/members/leave", new FormUrlEncodedContent([]));
|
||||
await AssertRecNetSuccessAsync(leave, "leave");
|
||||
|
||||
using var clearHome = await joinerClient.DeleteAsync("/club/home/me");
|
||||
await AssertRecNetSuccessAsync(clearHome, "DELETE club/home/me");
|
||||
|
||||
using var ownerClient = Client("clubs", owner);
|
||||
using var delete = await ownerClient.DeleteAsync($"/club/{clubId}");
|
||||
await AssertRecNetSuccessAsync(delete, "DELETE club/{id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_markers_and_privacy_toggle_answer_a_success_result()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = Client("api", player);
|
||||
|
||||
using var commentRead = await client.PutAsync("/comments/read/1/1", content: null);
|
||||
await AssertRecNetSuccessAsync(commentRead, "PUT comments/read");
|
||||
|
||||
using var accounts = Client("accounts", player);
|
||||
using var privacy = await accounts.PutAsync(
|
||||
"/accountprivacysettings/recenthistoryvisibility",
|
||||
new FormUrlEncodedContent([new("isRecentHistoryVisible", "false")]));
|
||||
await AssertRecNetSuccessAsync(privacy, "PUT recenthistoryvisibility");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_warning_carries_an_error_message()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = Client("api", player);
|
||||
|
||||
using var response = await client.PostAsync("/api/playerwarnings",
|
||||
new FormUrlEncodedContent([new("WarnedPlayerId", player.PlayerId.ToString())]));
|
||||
var root = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(ReadSuccess(root));
|
||||
// Without Error the client's Promise.Rejected(null) throws instead of
|
||||
// surfacing the refusal.
|
||||
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_token_is_a_bare_string_redeemed_once_for_its_own_account()
|
||||
{
|
||||
using var setup = Client("auth");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var auth = Client("auth", player);
|
||||
|
||||
using var mint = await auth.PostAsync("/account/me/createlogintoken", content: null);
|
||||
var raw = await mint.Content.ReadAsStringAsync();
|
||||
Assert.True(mint.IsSuccessStatusCode, raw);
|
||||
// FGLDKEJLAKB<String> LGKLFFPACGL(): a JSON string, not an object.
|
||||
var token = JsonSerializer.Deserialize<string>(raw);
|
||||
Assert.False(string.IsNullOrEmpty(token));
|
||||
|
||||
using var anon = Client("auth");
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "login_token",
|
||||
["account_id"] = player.PlayerId.ToString(),
|
||||
["login_token"] = token!,
|
||||
};
|
||||
using var redeemed = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||
var redeemedBody = await redeemed.Content.ReadAsStringAsync();
|
||||
Assert.True(redeemed.IsSuccessStatusCode, redeemedBody);
|
||||
Assert.Equal(player.PlayerId, await SubjectOfAsync(redeemedBody));
|
||||
|
||||
using var again = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_code_poll_waits_for_remoteauth_approval_then_signs_in_the_approver()
|
||||
{
|
||||
using var anon = Client("auth");
|
||||
var approver = await GameClientSessionFactory.CreateAsync(anon, _factory.ApexDomain);
|
||||
|
||||
using var start = await anon.PostAsync("/connect/deviceauthorization", content: null);
|
||||
var started = JsonDocument.Parse(await start.Content.ReadAsStringAsync()).RootElement;
|
||||
var deviceCode = started.GetProperty("device_code").GetString()!;
|
||||
var userCode = started.GetProperty("user_code").GetString()!;
|
||||
|
||||
var poll = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
|
||||
["device_code"] = deviceCode,
|
||||
};
|
||||
|
||||
// Before approval the client must be told to keep polling: a 400
|
||||
// whose error is authorization_pending (JAEKFNKDNCP.txt:97-107).
|
||||
using (var pending = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll)))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.BadRequest, pending.StatusCode);
|
||||
var body = JsonDocument.Parse(await pending.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("authorization_pending", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
using var approverClient = Client("auth", approver);
|
||||
using (var bad = await approverClient.PostAsync("/account/me/remoteauth",
|
||||
new FormUrlEncodedContent([new("code", "NOPE0000")])))
|
||||
{
|
||||
var root = JsonDocument.Parse(await bad.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(ReadSuccess(root));
|
||||
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||
}
|
||||
|
||||
using var approve = await approverClient.PostAsync("/account/me/remoteauth",
|
||||
new FormUrlEncodedContent([new("code", userCode.ToLowerInvariant())]));
|
||||
await AssertRecNetSuccessAsync(approve, "remoteauth");
|
||||
|
||||
using var granted = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||
var grantedBody = await granted.Content.ReadAsStringAsync();
|
||||
Assert.True(granted.IsSuccessStatusCode, grantedBody);
|
||||
Assert.Equal(approver.PlayerId, await SubjectOfAsync(grantedBody));
|
||||
|
||||
// Single use.
|
||||
using var reused = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, reused.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gameconfig_carries_the_play_menu_carousel_ordering_in_the_parsed_format()
|
||||
{
|
||||
using var client = Client("api");
|
||||
var rows = JsonDocument.Parse(await client.GetStringAsync("/api/gameconfigs/v1/all")).RootElement;
|
||||
var row = rows.EnumerateArray().FirstOrDefault(r =>
|
||||
string.Equals(Prop(r, "Key"), "PlayMenuCarouselOrdering_1", StringComparison.Ordinal));
|
||||
Assert.NotEqual(JsonValueKind.Undefined, row.ValueKind);
|
||||
|
||||
// PlayMenuCarouselModel.TryGetDisplayPriorityMap: Split(';'), then each
|
||||
// entry Split('=') must give exactly 2 parts with an Int32 priority, or
|
||||
// the entry is logged as "Failed to parse priority mapping".
|
||||
var entries = Prop(row, "Value")!.Split(';');
|
||||
Assert.All(entries, e =>
|
||||
{
|
||||
var parts = e.Split('=');
|
||||
Assert.Equal(2, parts.Length);
|
||||
Assert.True(int.TryParse(parts[1].Trim(), out _), e);
|
||||
});
|
||||
Assert.Contains(entries, e => e.StartsWith("hotrooms=", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_strings_download_answers_an_empty_table()
|
||||
{
|
||||
using var client = Client("strings");
|
||||
using var response = await client.GetAsync("/strings?locale=en-US&scopePrefix=client.&eTag=");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, body);
|
||||
Assert.Equal(JsonValueKind.Object, JsonDocument.Parse(body).RootElement.ValueKind);
|
||||
}
|
||||
|
||||
/// <summary>PUT playerevents/v2/{id}/multiinstance sends the two broadcast
|
||||
/// permission fields as boxed enums, i.e. member names (BNDIAONDFFF.txt:947
|
||||
/// falls back to Object.ToString). Parsing them as ints stored 0 every time.</summary>
|
||||
[Theory]
|
||||
[InlineData("RoomOwners", 256)]
|
||||
[InlineData("All", int.MaxValue)]
|
||||
[InlineData("None", 0)]
|
||||
[InlineData("256", 256)]
|
||||
[InlineData("roomowners, None", 256)]
|
||||
public void Broadcast_permission_names_parse_to_their_values(string raw, int expected) =>
|
||||
Assert.Equal(expected,
|
||||
DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController.ParseBroadcastPermissions(raw));
|
||||
|
||||
[Fact]
|
||||
public void Unknown_broadcast_permission_name_keeps_the_stored_value() =>
|
||||
Assert.Null(DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController
|
||||
.ParseBroadcastPermissions("SomethingElse"));
|
||||
|
||||
private async Task<long> SeedOpenClubAsync(long ownerId)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var club = new ClubEntity
|
||||
{
|
||||
Name = $"Env{Guid.NewGuid():N}"[..16],
|
||||
CreatorPlayerId = ownerId,
|
||||
Joinability = 0, // Open
|
||||
};
|
||||
db.Clubs.Add(club);
|
||||
await db.SaveChangesAsync();
|
||||
return club.Id;
|
||||
}
|
||||
|
||||
private async Task<long> SubjectOfAsync(string tokenBody)
|
||||
{
|
||||
var access = JsonDocument.Parse(tokenBody).RootElement.GetProperty("access_token").GetString();
|
||||
using var client = Client("auth");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access);
|
||||
var info = JsonDocument.Parse(await client.GetStringAsync("/connect/userinfo")).RootElement;
|
||||
return long.Parse(info.GetProperty("sub").GetString()!);
|
||||
}
|
||||
|
||||
private static async Task AssertRecNetSuccessAsync(HttpResponseMessage response, string what)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{what} -> {(int)response.StatusCode}: {body}");
|
||||
Assert.False(string.IsNullOrWhiteSpace(body), $"{what} answered an empty body");
|
||||
var root = JsonDocument.Parse(body).RootElement;
|
||||
Assert.True(ReadSuccess(root), $"{what} body has no Success=true: {body}");
|
||||
}
|
||||
|
||||
// The client formatter registers exactly these spellings.
|
||||
private static bool ReadSuccess(JsonElement root) =>
|
||||
(root.TryGetProperty("Success", out var s) || root.TryGetProperty("success", out s))
|
||||
&& s.ValueKind == JsonValueKind.True;
|
||||
|
||||
private static string? ReadError(JsonElement root) =>
|
||||
root.TryGetProperty("Error", out var e) || root.TryGetProperty("error", out e)
|
||||
? e.ValueKind == JsonValueKind.String ? e.GetString() : null
|
||||
: null;
|
||||
|
||||
private static string? Prop(JsonElement row, string name) =>
|
||||
row.TryGetProperty(name, out var v) || row.TryGetProperty(char.ToLowerInvariant(name[0]) + name[1..], out v)
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using RecRoom.Protobuf;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Baked Rec Room Originals (Rec Royale, Paintball, …) ship with an empty
|
||||
/// <see cref="RoomSceneEntity.DataBlobName"/>, and every details builder and
|
||||
/// matchmaking resolver turns that into an empty <c>DataBlob</c>. The client
|
||||
/// reads that as "no room data at all": no persistence views, no room-role
|
||||
/// overlay, no Maker Pen, no CV2 chips, no room mood engine. Real Rec Room
|
||||
/// served these rooms a blob — Rec Royale's game-rule chip lives in it, not
|
||||
/// in the scene — so DorkNet needs a switch that gives such a room one.
|
||||
///
|
||||
/// The property pinned here: the admin "enable blob" action stamps the
|
||||
/// synthetic default name, that name flows through the game-client details
|
||||
/// verbatim while <c>IsRRO</c> stays true (the client still loads the baked
|
||||
/// map, and only additionally fetches the blob), the CDN answers that name
|
||||
/// with the RRO-editable blob (permissive room roles, no objects), and
|
||||
/// "reset" puts everything back to scene-only.
|
||||
/// </summary>
|
||||
public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RroRoomBlobTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Baked_original_gets_no_blob_until_an_admin_enables_one()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
|
||||
var roomName = $"Royale{Guid.NewGuid():N}"[..18];
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
|
||||
// Shape of a seeded baked original: system-owned, AG-flagged,
|
||||
// tagged recroomoriginal, no blob anywhere.
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = roomName,
|
||||
CreatorPlayerId = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "recroomoriginal,sport",
|
||||
CurrentDataBlobName = string.Empty,
|
||||
LocationReplicationId = "b010171f-4875-4e89-baba-61e878cd41e1",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = string.Empty });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
|
||||
using var gameClient = Client("rooms", player);
|
||||
using var adminClient = Client("admin", admin);
|
||||
using var cdnClient = Client("cdn");
|
||||
|
||||
// ── before: baked scene only ────────────────────────────────────────
|
||||
var before = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||
Assert.True(before.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.Equal(string.Empty, SubRoomZero(before).GetProperty("DataBlob").GetString());
|
||||
|
||||
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||
var row = listed.EnumerateArray().Single();
|
||||
Assert.True(row.GetProperty("isBakedOriginal").GetBoolean());
|
||||
Assert.Equal(string.Empty, row.GetProperty("effectiveBlobName").GetString());
|
||||
|
||||
// ── enable: the synthetic name lands on the scene AND the room ─────
|
||||
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||
Assert.True(enabled.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
|
||||
Assert.Equal(synthetic, enabled.GetProperty("dataBlobName").GetString());
|
||||
Assert.Equal(synthetic, enabled.GetProperty("roomCurrentDataBlobName").GetString());
|
||||
|
||||
// Idempotent: a second enable changes nothing and keeps the name.
|
||||
var again = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||
Assert.False(again.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(synthetic, again.GetProperty("effectiveBlobName").GetString());
|
||||
|
||||
// The game client now gets the blob name, and IsRRO is untouched —
|
||||
// that flag is what makes it load the baked map (a false there sends
|
||||
// it looking for geometry inside the blob and the join fails).
|
||||
var after = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||
Assert.True(after.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.Equal(synthetic, SubRoomZero(after).GetProperty("DataBlob").GetString());
|
||||
|
||||
// ── the CDN answers that name with the RRO-editable blob ───────────
|
||||
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
|
||||
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
|
||||
Assert.True(blobResponse.IsSuccessStatusCode,
|
||||
$"GET cdn /room/{synthetic} -> {(int)blobResponse.StatusCode}");
|
||||
var persisted = PersistedRoomData.Parser.ParseFrom(blobBytes);
|
||||
Assert.NotNull(persisted.RoomRoleData);
|
||||
Assert.NotEmpty(persisted.RoomRoleData.RoomRoles);
|
||||
Assert.True(persisted.RoomRoleData.RoomRoles.All(r => r.CanUseMakerPen is { Overrides: true, InnerValue: true }));
|
||||
|
||||
// The March-2023 client rejects a blob past PersistedRoomVersion 16
|
||||
// (the old stub was stamped 19 and loaded as "no room data"), and a
|
||||
// real save carries the room's location id as activity_id. Our proto
|
||||
// doesn't type activity_id, so look for it on the wire.
|
||||
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)persisted.Version);
|
||||
var blobText = System.Text.Encoding.UTF8.GetString(blobBytes);
|
||||
Assert.Contains("b010171f-4875-4e89-baba-61e878cd41e1", blobText);
|
||||
|
||||
// Every real room save carries three GameRoleNode chips (default role
|
||||
// named after the room, "In-Game", "Eliminated" — well-known GUIDs).
|
||||
// The 2023 client grants Maker Pen off the player's GameRole, so a
|
||||
// blob with no role objects leaves even a co-owner with nothing.
|
||||
Assert.Equal(3, CountTopLevelPersistenceViews(blobBytes));
|
||||
Assert.Contains("In-Game", blobText);
|
||||
Assert.Contains("Eliminated", blobText);
|
||||
Assert.Contains("2c721342-c7ed-49fe-b8f0-93f8d282a6df", blobText);
|
||||
Assert.Contains("0b9ec39e-2cc9-4825-8321-da11561f1c4f", blobText);
|
||||
Assert.Contains("c48bac02-a743-4797-94c4-e2d6f0749004", blobText);
|
||||
Assert.Contains(roomName, blobText);
|
||||
|
||||
// ── reset: back to scene-only ───────────────────────────────────────
|
||||
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
|
||||
Assert.True(reset.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(string.Empty, reset.GetProperty("effectiveBlobName").GetString());
|
||||
|
||||
var restored = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||
Assert.True(restored.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.Equal(string.Empty, SubRoomZero(restored).GetProperty("DataBlob").GetString());
|
||||
}
|
||||
|
||||
/// <summary>The seeded Rec Royale rows have NO RoomScenes at all (the admin
|
||||
/// page showed "Scenes 0 / No sub-rooms"). The admin list must synthesise
|
||||
/// the entry scene the way the game-client details do, and enable must
|
||||
/// create the row rather than 404.</summary>
|
||||
[Fact]
|
||||
public async Task Room_without_scene_rows_gets_a_virtual_entry_scene_and_enable_creates_it()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_670_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"NoScene{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "recroomoriginal",
|
||||
MaxCapacity = 24,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
|
||||
using var gameClient = Client("rooms", player);
|
||||
using var adminClient = Client("admin", admin);
|
||||
|
||||
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||
var virtualRow = listed.EnumerateArray().Single();
|
||||
Assert.True(virtualRow.GetProperty("isVirtual").GetBoolean());
|
||||
Assert.Equal(0, virtualRow.GetProperty("subRoomId").GetInt32());
|
||||
Assert.Equal(24, virtualRow.GetProperty("maxPlayers").GetInt32());
|
||||
Assert.Equal(string.Empty, virtualRow.GetProperty("effectiveBlobName").GetString());
|
||||
|
||||
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||
Assert.True(enabled.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(synthetic, enabled.GetProperty("effectiveBlobName").GetString());
|
||||
|
||||
// The row now exists with the game-side defaults, and the game client
|
||||
// sees the blob on its entry scene.
|
||||
var relisted = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||
var real = relisted.EnumerateArray().Single();
|
||||
Assert.False(real.TryGetProperty("isVirtual", out _));
|
||||
Assert.Equal("Home", real.GetProperty("name").GetString());
|
||||
Assert.Equal(24, real.GetProperty("maxPlayers").GetInt32());
|
||||
Assert.Equal(synthetic, real.GetProperty("dataBlobName").GetString());
|
||||
|
||||
var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||
Assert.True(details.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.Equal(synthetic, SubRoomZero(details).GetProperty("DataBlob").GetString());
|
||||
|
||||
var reset = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/reset");
|
||||
Assert.True(reset.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(string.Empty, SubRoomZero(await GetJsonAsync(gameClient, $"/rooms/{roomId}")).GetProperty("DataBlob").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Rec Royale's Frontier scene has no baked game rule; real Rec
|
||||
/// Room delivered the RecRoyaleSolos/Squads_GameRuleWrapperCircuit chip
|
||||
/// in the blob. The stub for those rooms must carry it (a fourth
|
||||
/// persistence view with the palette tag the client generates).</summary>
|
||||
[Theory]
|
||||
[InlineData("RecRoyaleSolos", "recroyalesolosgamerulecir")]
|
||||
[InlineData("RecRoyaleSquads", "recroyalesquadsgameruleci")]
|
||||
public async Task Rec_royale_stub_carries_its_game_rule_chip(string roomNamePrefix, string expectedTag)
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_680_000 + Random.Shared.Next(1, 99_999);
|
||||
var roomName = $"{roomNamePrefix}-{Guid.NewGuid():N}"[..24];
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = roomName,
|
||||
CreatorPlayerId = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "recroomoriginal",
|
||||
LocationReplicationId = "253fa009-6e65-4c90-91a1-7137a56a267f",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var adminClient = Client("admin", admin);
|
||||
using var cdnClient = Client("cdn");
|
||||
await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||
|
||||
var synthetic = RoomService.SyntheticDefaultRoomDataBlobName(roomId);
|
||||
using var blobResponse = await cdnClient.GetAsync($"/room/{synthetic}");
|
||||
Assert.True(blobResponse.IsSuccessStatusCode);
|
||||
var blobBytes = await blobResponse.Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal(4, CountTopLevelPersistenceViews(blobBytes)); // 3 roles + the rule chip
|
||||
Assert.Contains(expectedTag, System.Text.Encoding.UTF8.GetString(blobBytes));
|
||||
}
|
||||
|
||||
/// <summary>A real save from any client version can be attached to a baked
|
||||
/// original: activity id rewritten to the target room, output log /
|
||||
/// sub-room id / unknown fields dropped, version clamped, and in
|
||||
/// <c>basics</c> mode only the role + rule objects kept with circuits
|
||||
/// stripped. The room then loads that blob (with the CDN's role overlay).</summary>
|
||||
[Fact]
|
||||
public async Task Uploading_a_real_save_attaches_it_to_the_room_rewritten_for_2023()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_690_000 + Random.Shared.Next(1, 99_999);
|
||||
const string location = "253fa009-6e65-4c90-91a1-7137a56a267f";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Upload{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "recroomoriginal",
|
||||
LocationReplicationId = location,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// A "modern" save: version 138, someone else's activity id, an output
|
||||
// log, a sub-room id, a newer-only field 37, circuit payloads, and
|
||||
// three views — a game role (field 50), a game rule (field 41) and a
|
||||
// plain prop (field 15 only).
|
||||
var modern = BuildModernSave();
|
||||
|
||||
using var adminClient = Client("admin", admin);
|
||||
using var form = new MultipartFormDataContent();
|
||||
form.Add(new ByteArrayContent(modern), "file", "persisted_room_data.binpb");
|
||||
using var uploaded = await adminClient.PostAsync($"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/upload?mode=basics", form);
|
||||
var uploadBody = await uploaded.Content.ReadAsStringAsync();
|
||||
Assert.True(uploaded.IsSuccessStatusCode, $"upload -> {(int)uploaded.StatusCode}: {uploadBody}");
|
||||
var result = JsonDocument.Parse(uploadBody).RootElement;
|
||||
var blobName = result.GetProperty("dataBlobName").GetString()!;
|
||||
Assert.StartsWith($"room_{roomId}_upload_", blobName);
|
||||
var summary = result.GetProperty("summary");
|
||||
Assert.Equal(3, summary.GetProperty("viewsIn").GetInt32());
|
||||
Assert.Equal(2, summary.GetProperty("viewsKept").GetInt32());
|
||||
Assert.Equal(138, summary.GetProperty("versionIn").GetInt32());
|
||||
Assert.Equal(16, summary.GetProperty("versionOut").GetInt32());
|
||||
|
||||
// The game client is now pointed at the uploaded blob …
|
||||
using var gameClient = Client("rooms", player);
|
||||
var details = await GetJsonAsync(gameClient, $"/rooms/{roomId}");
|
||||
Assert.Equal(blobName, SubRoomZero(details).GetProperty("DataBlob").GetString());
|
||||
|
||||
// … and the CDN serves it rewritten: 2 views, clamped header, our
|
||||
// activity id, the role overlay applied, no field 37 / output log.
|
||||
using var cdnClient = Client("cdn");
|
||||
using var served = await cdnClient.GetAsync($"/room/{blobName}");
|
||||
Assert.True(served.IsSuccessStatusCode, $"cdn -> {(int)served.StatusCode}");
|
||||
var bytes = await served.Content.ReadAsByteArrayAsync();
|
||||
var parsed = PersistedRoomData.Parser.ParseFrom(bytes);
|
||||
Assert.Equal(RoomDataBlobService.Client2023MaxPersistedRoomVersion, (int)parsed.Version);
|
||||
Assert.NotEmpty(parsed.RoomRoleData.RoomRoles);
|
||||
Assert.Equal(2, CountTopLevelPersistenceViews(bytes));
|
||||
var text = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
Assert.Contains(location, text);
|
||||
Assert.DoesNotContain("someone-elses-activity", text);
|
||||
Assert.DoesNotContain("Serialized 3 persistence views", text);
|
||||
}
|
||||
|
||||
private static byte[] BuildModernSave()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
void Varint(int field, ulong v) { WriteVarint(ms, ((ulong)field << 3) | 0); WriteVarint(ms, v); }
|
||||
void Bytes(int field, byte[] payload) { WriteVarint(ms, ((ulong)field << 3) | 2); WriteVarint(ms, (ulong)payload.Length); ms.Write(payload); }
|
||||
static byte[] View(int markerField)
|
||||
{
|
||||
var v = new MemoryStream();
|
||||
WriteVarint(v, (1UL << 3) | 2); WriteVarint(v, 16); v.Write(Guid.NewGuid().ToByteArray());
|
||||
WriteVarint(v, ((ulong)markerField << 3) | 2); WriteVarint(v, 0);
|
||||
return v.ToArray();
|
||||
}
|
||||
Varint(1, 38);
|
||||
Bytes(2, View(50)); // game role
|
||||
Bytes(2, View(41)); // game rule wrapper
|
||||
Bytes(2, View(15)); // a prop (tagged tool only)
|
||||
Bytes(4, System.Text.Encoding.UTF8.GetBytes("someone-elses-activity-0000-000000000000"));
|
||||
Bytes(5, System.Text.Encoding.UTF8.GetBytes("Serialized 3 persistence views"));
|
||||
Varint(6, 3991471883933115623UL);
|
||||
Bytes(18, new byte[] { 0x0A, 0x02, 0x08, 0x01 });
|
||||
Bytes(28, new byte[] { 0x0A, 0x02, 0x08, 0x01 });
|
||||
Varint(30, 138);
|
||||
Varint(31, 1);
|
||||
Varint(37, 1);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteVarint(Stream s, ulong value)
|
||||
{
|
||||
while (value >= 0x80) { s.WriteByte((byte)(value | 0x80)); value >>= 7; }
|
||||
s.WriteByte((byte)value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enable_on_a_saved_subroom_keeps_the_saved_blob()
|
||||
{
|
||||
using var setup = Client("rooms");
|
||||
var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var roomId = 9_650_000 + Random.Shared.Next(1, 99_999);
|
||||
const string saved = "room_saved_by_a_player_v3.dat";
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
(await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Saved{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = 1,
|
||||
IsAGRoom = true,
|
||||
TagsCsv = "recroomoriginal",
|
||||
CurrentDataBlobName = saved,
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity
|
||||
{ RoomId = roomId, Name = "Home", OrderIndex = 0, DataBlobName = saved });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var adminClient = Client("admin", admin);
|
||||
var enabled = await PostJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms/0/blob/enable");
|
||||
Assert.False(enabled.GetProperty("changed").GetBoolean());
|
||||
Assert.Equal(saved, enabled.GetProperty("effectiveBlobName").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Count top-level <c>persistence_views</c> (field 2) entries by
|
||||
/// walking the wire format — our server proto doesn't type field 2.</summary>
|
||||
private static int CountTopLevelPersistenceViews(byte[] blob)
|
||||
{
|
||||
var pos = 0;
|
||||
var count = 0;
|
||||
while (pos < blob.Length)
|
||||
{
|
||||
var tag = ReadVarint(blob, ref pos);
|
||||
var field = (int)(tag >> 3);
|
||||
var wireType = (int)(tag & 7);
|
||||
switch (wireType)
|
||||
{
|
||||
case 0: ReadVarint(blob, ref pos); break;
|
||||
case 1: pos += 8; break;
|
||||
case 2:
|
||||
var len = (int)ReadVarint(blob, ref pos);
|
||||
if (field == 2) count++;
|
||||
pos += len;
|
||||
break;
|
||||
case 5: pos += 4; break;
|
||||
default: throw new InvalidDataException($"wire type {wireType}");
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static ulong ReadVarint(byte[] input, ref int pos)
|
||||
{
|
||||
ulong value = 0;
|
||||
var shift = 0;
|
||||
while (true)
|
||||
{
|
||||
var b = input[pos++];
|
||||
value |= (ulong)(b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) return value;
|
||||
shift += 7;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement SubRoomZero(JsonElement details) =>
|
||||
details.GetProperty("SubRooms").EnumerateArray()
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 0);
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.PostAsync(path, content: null);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -523,3 +523,45 @@ POST thread/{0}/snooze social-clubs-chat
|
||||
GET unity_assets/{0}/{1}/{2} inventions-media
|
||||
GET voice/config moderation-safety
|
||||
GET voice/requiresModeration moderation-safety
|
||||
# Requests the plain route-literal sweep missed. Each verified in the ISIL:
|
||||
# - leading-slash literals (RecNet.Runtime): LCOCDKINDMN_NestedType_CIPKPLFLIPC.txt:218,
|
||||
# PBOGNAPEBAI_NestedType_JKNCNOHGPHK.txt:251 (GET under 100 ids, POST at 100+),
|
||||
# NHILNDLLLHH.txt:329/801, OKOOJMAOKFE.txt:561, MNANGNGKKDA_NestedType_JAMKPNKPJPD.txt:179
|
||||
# - Assembly-CSharp CDN fetch: JFDPIJFDHAD.txt:1432 (/sigs/<id>, boot file-signature check)
|
||||
# - DELETE subscription/{0}: IKMMOCKDKAF.txt LPFOPPHGDHG (verb 4)
|
||||
# - account/me/{field}: composed at runtime by OPMAPIOEIFG.OBAGJBBIFGB("account/me/" + field);
|
||||
# verbs from each caller (username/displayname/bio/birthday/emoji/personalpronouns/identityflags/bannerimage PUT=3,
|
||||
# email/phone/confirmphone POST=2).
|
||||
GET api/playerReputation/v2/bulk identity-account
|
||||
POST api/playerReputation/v2/bulk identity-account
|
||||
GET api/players/v2/progression/bulk identity-account
|
||||
POST api/players/v2/progression/bulk identity-account
|
||||
GET strings config-telemetry
|
||||
GET config/{0} config-telemetry
|
||||
GET data/{0} config-telemetry
|
||||
GET sigs/{0} config-telemetry
|
||||
DELETE subscription/{0} commerce-subscription
|
||||
PUT account/me/username identity-account
|
||||
PUT account/me/displayname identity-account
|
||||
PUT account/me/bio identity-account
|
||||
PUT account/me/birthday identity-account
|
||||
POST account/me/email identity-account
|
||||
POST account/me/phone identity-account
|
||||
PUT account/me/emoji identity-account
|
||||
PUT account/me/personalpronouns identity-account
|
||||
PUT account/me/identityflags identity-account
|
||||
PUT account/me/bannerimage identity-account
|
||||
POST account/me/confirmphone identity-account
|
||||
# Routes composed from a base literal plus a format/segment (BPAIBFBKKCN.txt:51-53, HPEBLAHNBLC.txt:38-40,
|
||||
# and the LPPEFJMCJFN URL builder in LINKIECPDML.txt:677-737).
|
||||
GET api/itemWishlists/v1/wishlist/me avatar-inventory
|
||||
GET api/itemWishlists/v1/wishlist/{0} avatar-inventory
|
||||
PUT api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||
DELETE api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||
GET api/roomEarningsDistributions/v1/earningsDistribution/{0} progression-misc
|
||||
GET api/incentivizedreferrals/referrals progression-misc
|
||||
# Contact lookup (OPMAPIOEIFG.txt:11590-11620) and composed paths (FFFIMAGLKEG.txt:137, NOGJJIIENIL.txt:29).
|
||||
POST account/bulk/email identity-account
|
||||
POST account/bulk/phone identity-account
|
||||
POST api/avatar/v2/gifts/consume avatar-inventory
|
||||
GET video/{0} config-telemetry
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 532 and column 73.
|
@@ -57,17 +57,7 @@ public class AccountApiController(
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Account = new RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
},
|
||||
Account = AccountDtos.Public(player),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,22 +67,7 @@ public class AccountApiController(
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(new RecNetSelfAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = string.Empty,
|
||||
Birthday = new DateTime(2000, 1, 1),
|
||||
JuniorState = 0,
|
||||
ParentAccountId = null,
|
||||
});
|
||||
return Ok(AccountDtos.Self(player));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -50,7 +50,18 @@ public class AvatarGiftsController(
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
public sealed record GenerateGiftRequest(int? GiftContext, bool? IsGameGift, int? AlternateGiftContext, string? Message);
|
||||
/// <summary>Body keys are the client's AFGEDDANEKP param literals
|
||||
/// (GiftContext / IsGameGift / AlternateGiftContext / Message). A class
|
||||
/// with settable properties, not a positional record, because
|
||||
/// <see cref="Binding.FormOrJsonModelBinder"/> constructs it empty and
|
||||
/// fills it.</summary>
|
||||
public sealed class GenerateGiftRequest
|
||||
{
|
||||
public int? GiftContext { get; set; }
|
||||
public bool? IsGameGift { get; set; }
|
||||
public int? AlternateGiftContext { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — the end-of-
|
||||
/// activity gift box (chest). For a game/quest gift (IsGameGift) the
|
||||
@@ -64,8 +75,13 @@ public class AvatarGiftsController(
|
||||
// POST 404'd, breaking reward chests on that path). Same request body.
|
||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||
[HttpPost("api/avatar/v3/gifts/generate")]
|
||||
// The client adds each field with AFGEDDANEKP (FFFIMAGLKEG.txt:1930-2013,
|
||||
// POST = ordinal 2), which goes out form-urlencoded: a live trace showed
|
||||
// `GiftContext=12000&IsGameGift=True&Message=…` answered 415 while this
|
||||
// was [FromBody]. The form-or-JSON binder accepts either encoding.
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
|
||||
public async Task<IActionResult> Generate(
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] GenerateGiftRequest? req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var isGameGift = req?.IsGameGift ?? false;
|
||||
|
||||
@@ -4,16 +4,21 @@ using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V3;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
|
||||
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
|
||||
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
|
||||
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
|
||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Client wire
|
||||
/// type BEPMJICDNDD, whose converter IMMOJHBENFI registers exactly
|
||||
/// <c>Slot, PreviewImageName, OutfitSelections, OutfitSelectionsV2,
|
||||
/// FaceFeatures, SkinColor, HairColor, CustomAvatarItems</c>
|
||||
/// (RecNet.Runtime/IMMOJHBENFI.txt:587-766 reader, :781-910 writer).
|
||||
/// CustomAvatarItems is a list of BGNNOMBFMLH
|
||||
/// <c>{CustomAvatarItemId, BodyPart}</c>, the same element the avatar
|
||||
/// settings (<c>api/avatar/v2</c>) use.
|
||||
///
|
||||
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
|
||||
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
|
||||
@@ -38,12 +43,27 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
|
||||
/// Body is the SavedOutfit shape directly.</summary>
|
||||
/// Verb ordinal 2 = POST with a raw JSON body of one serialised
|
||||
/// BEPMJICDNDD (RecNet.Runtime/FFFIMAGLKEG.txt:4071-4095, NCILAADFJMM).
|
||||
///
|
||||
/// Parsed by hand rather than <c>[FromBody] SavedOutfitDto</c>: the old
|
||||
/// DTO silently dropped OutfitSelectionsV2 and CustomAvatarItems, and the
|
||||
/// 2023 client wears OutfitSelectionsV2 whenever it is non-empty
|
||||
/// (BEPMJICDNDD.NLGCNDMLACA, BEPMJICDNDD.txt:237-266, falls back to
|
||||
/// OutfitSelections only when V2 is empty). Loading a saved slot
|
||||
/// therefore restored a stale V1 selection (or nothing) and lost every
|
||||
/// custom shirt in it.</summary>
|
||||
[HttpPost("api/avatar/v3/saved/set")]
|
||||
[HttpPost("api/avatar/v2/saved/set")]
|
||||
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
|
||||
public async Task<IActionResult> Set()
|
||||
{
|
||||
string raw;
|
||||
using (var reader = new StreamReader(Request.Body))
|
||||
raw = await reader.ReadToEndAsync();
|
||||
|
||||
var body = ParseOutfit(raw);
|
||||
if (body is null) return BadRequest("missing body");
|
||||
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
@@ -59,6 +79,7 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
|
||||
list.Add(body);
|
||||
}
|
||||
list.Sort((a, b) => a.Slot.CompareTo(b.Slot));
|
||||
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
@@ -81,12 +102,91 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Read one outfit object, matching keys case-insensitively
|
||||
/// (the client reader accepts Pascal/camel/lower spellings, so any of
|
||||
/// them is a valid write). String fields tolerate null; a nested
|
||||
/// object/array is kept as its raw JSON text, mirroring how
|
||||
/// <c>avatar/v2/set</c> treats the same fields. Returns null for a body
|
||||
/// that is not a JSON object.</summary>
|
||||
internal static SavedOutfitDto? ParseOutfit(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
var dto = new SavedOutfitDto();
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
switch (prop.Name.ToLowerInvariant())
|
||||
{
|
||||
case "slot":
|
||||
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var slot)) dto.Slot = slot;
|
||||
else if (prop.Value.ValueKind == JsonValueKind.String && int.TryParse(prop.Value.GetString(), out slot)) dto.Slot = slot;
|
||||
break;
|
||||
case "previewimagename": dto.PreviewImageName = AsString(prop.Value); break;
|
||||
case "outfitselections": dto.OutfitSelections = AsString(prop.Value); break;
|
||||
case "outfitselectionsv2": dto.OutfitSelectionsV2 = AsString(prop.Value); break;
|
||||
case "facefeatures": dto.FaceFeatures = AsString(prop.Value); break;
|
||||
case "skincolor": dto.SkinColor = AsString(prop.Value); break;
|
||||
case "haircolor": dto.HairColor = AsString(prop.Value); break;
|
||||
case "customavataritems": dto.CustomAvatarItems = AsCustomItems(prop.Value); break;
|
||||
}
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string AsString(JsonElement v) => v.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => v.GetString() ?? string.Empty,
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => string.Empty,
|
||||
_ => v.GetRawText(),
|
||||
};
|
||||
|
||||
/// <summary>BGNNOMBFMLH list. Elements without a parseable guid are
|
||||
/// dropped instead of failing the whole save.</summary>
|
||||
private static List<CustomAvatarItemRefDto> AsCustomItems(JsonElement v)
|
||||
{
|
||||
var result = new List<CustomAvatarItemRefDto>();
|
||||
if (v.ValueKind != JsonValueKind.Array) return result;
|
||||
foreach (var el in v.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
Guid? id = null;
|
||||
byte bodyPart = 0;
|
||||
foreach (var p in el.EnumerateObject())
|
||||
{
|
||||
if (p.Name.Equals("CustomAvatarItemId", StringComparison.OrdinalIgnoreCase)
|
||||
&& p.Value.ValueKind == JsonValueKind.String
|
||||
&& Guid.TryParse(p.Value.GetString(), out var g))
|
||||
id = g;
|
||||
else if (p.Name.Equals("BodyPart", StringComparison.OrdinalIgnoreCase)
|
||||
&& p.Value.ValueKind == JsonValueKind.Number
|
||||
&& p.Value.TryGetByte(out var b))
|
||||
bodyPart = b;
|
||||
}
|
||||
if (id is { } guid) result.Add(new CustomAvatarItemRefDto { CustomAvatarItemId = guid, BodyPart = bodyPart });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<SavedOutfitDto> ParseList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||
var list = JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||
// Rows written before CustomAvatarItems existed carry no key;
|
||||
// keep the wire array non-null.
|
||||
foreach (var o in list) o.CustomAvatarItems ??= new();
|
||||
return list;
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
@@ -97,7 +197,14 @@ public sealed class SavedOutfitDto
|
||||
[JsonPropertyName("Slot")] public int Slot { get; set; }
|
||||
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
|
||||
/// <summary>The selection the 2023 client actually wears when non-empty
|
||||
/// (BEPMJICDNDD.NLGCNDMLACA). Rows saved before this field existed
|
||||
/// deserialize to "" and the client falls back to OutfitSelections.</summary>
|
||||
[JsonPropertyName("OutfitSelectionsV2")] public string OutfitSelectionsV2 { get; set; } = string.Empty;
|
||||
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
|
||||
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
|
||||
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
|
||||
/// <summary>Custom shirts worn in this slot. Always an array on the wire
|
||||
/// (the client's ctor allocates an empty list, BEPMJICDNDD.txt:321-360).</summary>
|
||||
[JsonPropertyName("CustomAvatarItems")] public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -59,7 +59,15 @@ namespace DorkNet.Server.Controllers.API.Avatar.V4;
|
||||
/// makes every store item appear owned. We instead intersect the
|
||||
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
|
||||
/// catalog so the watch sees only items the player actually owns.
|
||||
/// Anonymous calls return [].
|
||||
///
|
||||
/// DEFAULT-UNLOCKED items: the 2023 client REPLACES its unlocked set with
|
||||
/// each list it downloads (OutfitManager.GFEIDFKKNMO clears field +0x70,
|
||||
/// then refills it; IsAvatarItemUnlocked is a HashSet lookup on that
|
||||
/// field). It never merges in its own baked default list, so the server
|
||||
/// must include the free starter items (avatar_item_lookup.json
|
||||
/// <c>default_unlocked_variants</c>) in every response. Without them a
|
||||
/// default item such as Beard Close showed a price in the outfit menu.
|
||||
/// Anonymous calls (<c>defaultunlocked</c> before login) get the defaults.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AvatarItemsController(
|
||||
@@ -110,7 +118,7 @@ public class AvatarItemsController(
|
||||
return Ok(BuildEntireCatalogOwned(maxItems));
|
||||
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
if (pid is not long me) return Ok(BuildDefaultUnlocked(maxItems));
|
||||
|
||||
var avatar = await db.Avatars
|
||||
.Where(a => a.PlayerId == me)
|
||||
@@ -126,7 +134,7 @@ public class AvatarItemsController(
|
||||
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(BuildDefaultUnlocked(maxItems));
|
||||
|
||||
var catalog = _catalog.Value;
|
||||
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
|
||||
@@ -142,7 +150,10 @@ public class AvatarItemsController(
|
||||
.ThenByDescending(x => x.OriginalIndex)
|
||||
.Select(x => x.Entry);
|
||||
|
||||
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
|
||||
// Room is kept for the default-unlocked items so a large wardrobe
|
||||
// can't push them out (they'd show as buyable again).
|
||||
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count - catalog.DefaultUnlocked.Count);
|
||||
var emittedBase = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var perSlot = new Dictionary<int, int>();
|
||||
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
|
||||
foreach (var entry in ordered)
|
||||
@@ -162,6 +173,8 @@ public class AvatarItemsController(
|
||||
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue;
|
||||
|
||||
perSlot[slot] = count + 1;
|
||||
if (string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase))
|
||||
emittedBase.Add(entry.BaseGuid);
|
||||
// Preserve the bought color: a bare entry (FullDesc == BaseGuid)
|
||||
// emits the catalog DTO as-is ("{guid},,,"); a colored entry
|
||||
// overrides AvatarItemDesc with the stored swatch/mask desc so
|
||||
@@ -173,7 +186,12 @@ public class AvatarItemsController(
|
||||
{
|
||||
AvatarItemType = item.AvatarItemType,
|
||||
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
|
||||
AvatarItemId = item.AvatarItemId,
|
||||
// Hash the FULL desc, as the toggle-on path does: a
|
||||
// player can own the base item and several colorways of
|
||||
// it at once (entries dedupe by FullDesc), and reusing the
|
||||
// base id made them collide in the client's id-keyed
|
||||
// wardrobe.
|
||||
AvatarItemId = StableAvatarItemIdFull(ToWatchDesc(entry.FullDesc)),
|
||||
PlatformMask = item.PlatformMask,
|
||||
FriendlyName = item.FriendlyName,
|
||||
Tooltip = item.Tooltip,
|
||||
@@ -184,6 +202,12 @@ public class AvatarItemsController(
|
||||
if (result.Count >= maxItems) break;
|
||||
}
|
||||
|
||||
foreach (var guid in catalog.DefaultUnlocked)
|
||||
{
|
||||
if (result.Count >= maxItems) break;
|
||||
if (emittedBase.Add(guid)) result.Add(catalog.Items[guid]);
|
||||
}
|
||||
|
||||
foreach (var slug in ownedHairDyes)
|
||||
{
|
||||
if (result.Count >= maxItems) break;
|
||||
@@ -205,6 +229,14 @@ public class AvatarItemsController(
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>The free items every player owns
|
||||
/// (<c>default_unlocked_variants</c>), capped at <paramref name="maxItems"/>.</summary>
|
||||
private static List<UnlockedAvatarItemDto> BuildDefaultUnlocked(int maxItems)
|
||||
{
|
||||
var catalog = _catalog.Value;
|
||||
return catalog.DefaultUnlocked.Take(maxItems).Select(g => catalog.Items[g]).ToList();
|
||||
}
|
||||
|
||||
/// <summary>The full owned set when the "everyone owns all avatar
|
||||
/// items" toggle is on: every catalog item (already carrying the
|
||||
/// "{guid},,," desc) plus every permanent hair dye, capped at
|
||||
@@ -290,6 +322,10 @@ public class AvatarItemsController(
|
||||
/// data against the same safe list.</summary>
|
||||
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
|
||||
|
||||
/// <summary>Base GUIDs of the free items every player owns
|
||||
/// (<c>default_unlocked_variants</c> in avatar_item_lookup.json).</summary>
|
||||
public static IReadOnlyList<string> DefaultUnlockedGuids => _catalog.Value.DefaultUnlocked;
|
||||
|
||||
/// <summary>Master catalog of all renderable items, keyed by
|
||||
/// GUID. Used by callers that need item metadata (slot type,
|
||||
/// name, hair-color flags) outside of the per-player ownership
|
||||
@@ -327,21 +363,22 @@ public class AvatarItemsController(
|
||||
foreach (var guid in ParseInventory(invJson))
|
||||
owned.Add(guid);
|
||||
}
|
||||
// Free default items are owned by everyone, logged in or not.
|
||||
owned.UnionWith(_catalog.Value.DefaultUnlocked);
|
||||
|
||||
// The client parses this as List<IFNGBPJGINL> — the SAME element as
|
||||
// v4/items (FFFIMAGLKEG.txt:3222-3465, NBLIDNCEELM), read by
|
||||
// CLIANCNHNIE which registers TagList / AvatarItemId /
|
||||
// IsBaseAvatarItem too (CLIANCNHNIE.txt). The old 5-key subset left
|
||||
// every locked row at AvatarItemId 0 with a null TagList, so ship
|
||||
// the full catalog DTO.
|
||||
var catalog = _catalog.Value.Items;
|
||||
var rows = requested
|
||||
.Where(guid => !owned.Contains(guid))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
|
||||
.Where(item => item is not null)
|
||||
.Select(item => new
|
||||
{
|
||||
item!.AvatarItemType,
|
||||
item.AvatarItemDesc,
|
||||
item.FriendlyName,
|
||||
item.Tooltip,
|
||||
item.Rarity,
|
||||
})
|
||||
.Select(item => item!)
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
@@ -481,7 +518,8 @@ public class AvatarItemsController(
|
||||
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
|
||||
IReadOnlySet<string> SafeGuids,
|
||||
IReadOnlyDictionary<int, List<string>> BySlot,
|
||||
IReadOnlyDictionary<string, int> SlotByGuid);
|
||||
IReadOnlyDictionary<string, int> SlotByGuid,
|
||||
IReadOnlyList<string> DefaultUnlocked);
|
||||
|
||||
private static CatalogData LoadCatalog()
|
||||
{
|
||||
@@ -508,7 +546,8 @@ public class AvatarItemsController(
|
||||
new Dictionary<string, UnlockedAvatarItemDto>(),
|
||||
new HashSet<string>(),
|
||||
new Dictionary<int, List<string>>(),
|
||||
new Dictionary<string, int>());
|
||||
new Dictionary<string, int>(),
|
||||
Array.Empty<string>());
|
||||
|
||||
using var fs = System.IO.File.OpenRead(path);
|
||||
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
|
||||
@@ -555,15 +594,23 @@ public class AvatarItemsController(
|
||||
slotByGuid[row.Guid] = row.OutfitType;
|
||||
}
|
||||
|
||||
return new CatalogData(items, safe, bySlot, slotByGuid);
|
||||
// Default-unlocked entries are all plain base items (no swatch, mask
|
||||
// or decal) in the March dump; only keep ones the catalog can render.
|
||||
var defaults = dump.DefaultUnlockedVariants
|
||||
.Where(v => string.IsNullOrEmpty(v.Swatch) && string.IsNullOrEmpty(v.Mask) && string.IsNullOrEmpty(v.Decal))
|
||||
.Select(v => v.Item)
|
||||
.Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new CatalogData(items, safe, bySlot, slotByGuid, defaults);
|
||||
}
|
||||
|
||||
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned
|
||||
/// avatar item, derived from its base GUID (FNV-1a). The 2023 client
|
||||
/// keys the wardrobe by <c>AvatarItemId</c>, so it must be unique per
|
||||
/// item and stable across restarts. Colored variants reuse their base
|
||||
/// item's id (a player owns the base OR a variant, never both in the
|
||||
/// list), so no collision.</summary>
|
||||
/// item and stable across restarts. Colored variants must NOT use this — a player can own the base and
|
||||
/// several colorways together; see <see cref="StableAvatarItemIdFull"/>.</summary>
|
||||
public static int StableAvatarItemId(string? avatarItemDescOrGuid)
|
||||
{
|
||||
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
|
||||
@@ -618,6 +665,19 @@ public class AvatarItemsController(
|
||||
public List<string> SwatchGuids { get; set; } = new();
|
||||
[JsonPropertyName("decal_guids")]
|
||||
public List<string> DecalGuids { get; set; } = new();
|
||||
|
||||
// The client config's defaultUnlockedVariants: free items every
|
||||
// player owns.
|
||||
[JsonPropertyName("default_unlocked_variants")]
|
||||
public List<LookupVariant> DefaultUnlockedVariants { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class LookupVariant
|
||||
{
|
||||
[JsonPropertyName("item")] public string Item { get; set; } = string.Empty;
|
||||
[JsonPropertyName("mask")] public string Mask { get; set; } = string.Empty;
|
||||
[JsonPropertyName("swatch")] public string Swatch { get; set; } = string.Empty;
|
||||
[JsonPropertyName("decal")] public string Decal { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class LookupItem
|
||||
|
||||
@@ -153,22 +153,30 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase
|
||||
var me = Me;
|
||||
var lastRead = await LastReadAsync(me);
|
||||
|
||||
// Key + owner only: the payload column is irrelevant to counting.
|
||||
var keys = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(CommentPrefix))
|
||||
.Select(s => new { s.Key, s.PlayerId })
|
||||
.ToListAsync();
|
||||
|
||||
var counts = new Dictionary<long, uint>();
|
||||
foreach (var id in roomIds) counts[id] = 0u;
|
||||
|
||||
foreach (var k in keys)
|
||||
// One bounded query per requested room instead of materialising the
|
||||
// entire comment keyspace: the room filter and the "your own notes
|
||||
// are never unread" filter both run in SQL; only the numeric
|
||||
// comment-id suffix still needs parsing here. Key + owner only: the
|
||||
// payload column is irrelevant to counting.
|
||||
foreach (var id in roomIds)
|
||||
{
|
||||
if (!TrySplitCommentKey(k.Key, out var roomId, out var commentId)) continue;
|
||||
if (!counts.ContainsKey(roomId)) continue;
|
||||
if (k.PlayerId == me) continue; // your own notes are never unread
|
||||
if (commentId <= lastRead.GetValueOrDefault(roomId)) continue;
|
||||
counts[roomId]++;
|
||||
var prefix = $"{CommentPrefix}{id}:";
|
||||
var keys = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(prefix) && s.PlayerId != me)
|
||||
.Select(s => s.Key)
|
||||
.ToListAsync();
|
||||
|
||||
var floor = lastRead.GetValueOrDefault(id);
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!TrySplitCommentKey(key, out var roomId, out var commentId)) continue;
|
||||
if (roomId != id) continue;
|
||||
if (commentId <= floor) continue;
|
||||
counts[id]++;
|
||||
}
|
||||
}
|
||||
|
||||
var result = roomIds
|
||||
@@ -260,8 +268,11 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase
|
||||
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
|
||||
///
|
||||
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
|
||||
/// (OPELMBNJHNO.txt:1375) returns the status-only <c>LDGADANDBIO</c>, not a
|
||||
/// DTO — the body is never parsed, only the status code. Verb is <b>PUT</b>
|
||||
/// (OPELMBNJHNO.txt:1375) returns the untyped <c>LDGADANDBIO</c> promise, but
|
||||
/// it is built from the body parsed as a RecNetResult: the continuation
|
||||
/// <c><UpdateLatestReadComment>b__9_1(DJMHAFPGLLN)</c>
|
||||
/// (OPELMBNJHNO_NestedType___c.txt:333) null-derefs an empty body and
|
||||
/// rejects on <c>Success</c>=false — see <see cref="RecNetResults"/>. Verb is <b>PUT</b>
|
||||
/// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments
|
||||
/// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so
|
||||
/// the request carries no body at all.
|
||||
@@ -272,7 +283,7 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
await SetLastReadAsync(Me, roomId, commentId);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>Deletes a comment.
|
||||
|
||||
@@ -129,14 +129,19 @@ public class ConfigController(ConfigService configService) : ControllerBase
|
||||
/// Returns a broad default set so the picker is populated.</summary>
|
||||
[HttpGet("/emojiConfig/whitelistedEmojis")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult WhitelistedEmojis() => Ok(new[]
|
||||
public IActionResult WhitelistedEmojis() => Ok(WhitelistedEmojiList);
|
||||
|
||||
/// <summary>The emoji allow-list served above. Also the validation set
|
||||
/// for <c>PUT account/me/emoji</c>, so a player can only pick an emoji
|
||||
/// the client's own picker offered.</summary>
|
||||
public static readonly IReadOnlyList<string> WhitelistedEmojiList = new[]
|
||||
{
|
||||
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
|
||||
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
|
||||
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
|
||||
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
|
||||
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
|
||||
});
|
||||
};
|
||||
|
||||
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
|
||||
/// 2020.12 client deserializes this endpoint as a raw Boolean.
|
||||
|
||||
@@ -371,11 +371,22 @@ public class CustomAvatarItemsController(
|
||||
var allowed = player is not null
|
||||
&& !player.IsJunior
|
||||
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
|
||||
// Bare JSON boolean, like the two sibling gates below: the client's
|
||||
// issuing method is FGLDKEJLAKB<System.Boolean> GGMDLMLJDDK()
|
||||
// (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an
|
||||
// object body threw and the can-create gate blocked shirt creation.
|
||||
return Content(allowed ? "true" : "false", "application/json");
|
||||
// NOT a bare boolean, unlike the two sibling gates below. The issuing
|
||||
// method GGMDLMLJDDK (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route on
|
||||
// :6205) parses the body as DJMHAFPGLLN — {Boolean @0x10, String} —
|
||||
// (generic token 0x188477F70 at :6216; Func<DJMHAFPGLLN,…> at :6223)
|
||||
// and its success continuation HONEEPBHNLB reads the bool at +0x10
|
||||
// (:9155-9216).
|
||||
// DJMHAFPGLLN's converter LMCCNLLHLCJ registers exactly the keys
|
||||
// "Success" and "Error" (LMCCNLLHLCJ.txt:191-254). A bare `true`
|
||||
// failed that parse, so the client logged "Failed to query if local
|
||||
// account can create Custom Avatar Items: An error occurred" and
|
||||
// treated the account as unable to create shirts.
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["Success"] = allowed,
|
||||
["Error"] = allowed ? null : "Custom avatar item creation is not available for this account.",
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("isCreationEnabled")]
|
||||
@@ -386,22 +397,17 @@ public class CustomAvatarItemsController(
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
||||
|
||||
/// <summary>The client DTO (IHLMDLKOLNB) is an object with exactly one
|
||||
/// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere
|
||||
/// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are
|
||||
/// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT
|
||||
/// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft
|
||||
/// ignores the ones that don't match, and a total miss falls back to the
|
||||
/// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing.</summary>
|
||||
/// <summary>Bare JSON integer. The client's IHLMDLKOLNB pricing config is
|
||||
/// built CLIENT-side: HCBOHHEACPK sends through FDKKOPAPDGF<Int32>
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:6922 — the same generic token
|
||||
/// 0x18847B0B8 as the bare-Int32 itemOwnershipLimit call at :2981) and
|
||||
/// continues with <c><GetPricingConfig>b__69_0(Int32 minimumPrice)</c>
|
||||
/// (MPBLNLMCEDL_NestedType___c.txt:286). An object body failed the Int32
|
||||
/// parse, so the designer's public-price floor never loaded.</summary>
|
||||
[HttpGet("minPriceForPublicItem")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult MinPriceForPublicItem() => Ok(new
|
||||
{
|
||||
MinPrice = MinPublicItemPrice,
|
||||
Price = MinPublicItemPrice,
|
||||
MinimumPrice = MinPublicItemPrice,
|
||||
MinPriceForPublicItem = MinPublicItemPrice,
|
||||
});
|
||||
public IActionResult MinPriceForPublicItem()
|
||||
=> Content(MinPublicItemPrice.ToString(System.Globalization.CultureInfo.InvariantCulture), "application/json");
|
||||
|
||||
/// <summary>GET api/customAvatarItems/v1/design — hand back the caller's
|
||||
/// saved in-progress shirt design. Verb ordinal 0 = GET
|
||||
|
||||
@@ -584,6 +584,20 @@ public class ImagesController(
|
||||
// ReportEntity has no TargetPhotoId column, so the photo id + blob name
|
||||
// ride in the message text the same way club reports carry "[club {id}]"
|
||||
// — a moderator needs the actual image to act on the report.
|
||||
//
|
||||
// One open report per (reporter, photo): the call is fire-and-forget
|
||||
// and the client can't tell 200 from 404, so retries and re-opens are
|
||||
// likely — same guard as the custom-avatar-item report path.
|
||||
var marker = $"[photo {photo.Id} ";
|
||||
var duplicate = await db.Reports.AnyAsync(r =>
|
||||
r.ReporterPlayerId == reporter && r.ResolvedAt == null && r.Message.StartsWith(marker));
|
||||
if (duplicate)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[images] duplicate report of photo {PhotoId} by {Reporter} ignored", photo.Id, reporter);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
var message = $"[photo {photo.Id} {photo.BlobName}] reported from the in-game photo viewer";
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
|
||||
+52
@@ -34,6 +34,58 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>The caller's referred players, paged.
|
||||
///
|
||||
/// <c>FGLDKEJLAKB<GPJIMAHMAIO> EGEEFJHAGAM(Int32? take, String continuationToken)</c>
|
||||
/// (RecNet.Runtime/LINKIECPDML.txt:337) builds this through the URL builder
|
||||
/// LPPEFJMCJFN ("api/incentivizedreferrals/" + "referrals", :677-701, with
|
||||
/// <c>take</c> and <c>continuationToken</c> query params at :716/:737) — which
|
||||
/// is why the plain route-literal sweep never saw it — and issues a GET
|
||||
/// (:164-170). Reply formatter KOEEACLMAFE.txt:203-238:
|
||||
/// <c>{Referrals: [...], ContinuationToken: string}</c>; each entry
|
||||
/// (ADAFNGNADFH.txt:279-346) is <c>{InviteeAccountId: Int32, CreatedAt:
|
||||
/// DateTime, VerifiedAt: DateTime?}</c>.
|
||||
///
|
||||
/// A referral is recorded when a brand-new player redeems the caller's
|
||||
/// referral code (LinkController.CreditReferralAsync), and that redemption
|
||||
/// is what counts it, so it is verified at the moment it is created. The
|
||||
/// continuation token is the next offset; null once the list is exhausted.</summary>
|
||||
[HttpGet("referrals")]
|
||||
public async Task<IActionResult> Referrals([FromQuery] int? take, [FromQuery] string? continuationToken)
|
||||
{
|
||||
var size = Math.Clamp(take ?? 20, 1, 100);
|
||||
var offset = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
|
||||
var prefix = $"referral:credited:{Me}:";
|
||||
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(prefix))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.Skip(offset)
|
||||
.Take(size + 1)
|
||||
.Select(s => new { s.PlayerId, s.Value })
|
||||
.ToListAsync();
|
||||
|
||||
var page = rows.Take(size).Select(r =>
|
||||
{
|
||||
var at = DateTime.TryParse(r.Value, null,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind, out var credited)
|
||||
? credited
|
||||
: DateTime.UnixEpoch;
|
||||
return new
|
||||
{
|
||||
InviteeAccountId = (int)r.PlayerId,
|
||||
CreatedAt = at,
|
||||
VerifiedAt = (DateTime?)at,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Referrals = page,
|
||||
ContinuationToken = rows.Count > size ? (offset + size).ToString() : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Client contract: the claim response deserializes as a top-level
|
||||
// List<RewardSelection> where each entry is
|
||||
// { rewardSelectionId: long, rewardType: string } (RecNet.Runtime
|
||||
|
||||
@@ -208,8 +208,10 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
||||
/// <c>DELETE api/keepsakes/{guid}</c> - <c>NCCLEJPIABA.JBKENCNIEPA(System.Guid)</c> builds
|
||||
/// <c>String.Format("{0}/{1}", "api/keepsakes", guid)</c> and passes verb 4 (DELETE) to the
|
||||
/// BNDIAONDFFF ctor (NCCLEJPIABA.txt:1544, 1687-1698 - <c>Move rdx, 4</c>). Its return type is
|
||||
/// the non-generic <c>LDGADANDBIO</c> promise, so no response body is deserialised; a bare 200
|
||||
/// is enough. Client-side the tool refuses unless the caller is at least a room co-owner
|
||||
/// the non-generic <c>LDGADANDBIO</c> promise, but that promise comes from
|
||||
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult) — the body IS parsed, as a
|
||||
/// <c>{success,error}</c> RecNetResult, and an empty 200 null-derefs into a failed delete.
|
||||
/// See <see cref="RecNetResults"/>. Client-side the tool refuses unless the caller is at least a room co-owner
|
||||
/// ("Must be at least a room co-owner to modify keepsakes.",
|
||||
/// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side.
|
||||
/// </summary>
|
||||
@@ -235,7 +237,7 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
||||
db.Keepsakes.RemoveRange(orphanedCollections
|
||||
.Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -401,8 +401,10 @@ public class PhotosController(
|
||||
itemIds.AddRange(injectedIds.Where(pinned.Contains));
|
||||
}
|
||||
|
||||
// Utc kind explicitly: the ticks came from a UtcNow-derived CreatedAt,
|
||||
// and Npgsql refuses to write an Unspecified DateTime to timestamptz.
|
||||
DateTime? cutoffAt = resumed && cursorTicks > 0 && cursorTicks <= DateTime.MaxValue.Ticks
|
||||
? new DateTime(cursorTicks)
|
||||
? new DateTime(cursorTicks, DateTimeKind.Utc)
|
||||
: (DateTime?)null;
|
||||
var cutoffId = resumed ? cursorId : 0L;
|
||||
if (phase < 0) phase = 0;
|
||||
|
||||
@@ -1145,10 +1145,17 @@ public class PlayerEventsController(
|
||||
extras.SupportMultiInstanceRoomChat =
|
||||
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
|
||||
"SupportMultiInstanceRoomChat") ?? false;
|
||||
extras.DefaultBroadcastPermissions =
|
||||
await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0;
|
||||
extras.CanRequestBroadcastPermissions =
|
||||
await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0;
|
||||
// These two are boxed enums (typeof(AKFCIMLMAKA) at
|
||||
// CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:117-165), and a boxed non-string
|
||||
// field goes through Object.ToString (BNDIAONDFFF.txt:947, ISIL 107-110),
|
||||
// so they arrive as member NAMES. int.TryParse failed on every one and
|
||||
// the settings were always stored as 0.
|
||||
extras.DefaultBroadcastPermissions = ParseBroadcastPermissions(
|
||||
await ReadStringFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions"))
|
||||
?? extras.DefaultBroadcastPermissions;
|
||||
extras.CanRequestBroadcastPermissions = ParseBroadcastPermissions(
|
||||
await ReadStringFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions"))
|
||||
?? extras.CanRequestBroadcastPermissions;
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
@@ -1437,6 +1444,33 @@ public class PlayerEventsController(
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Broadcast-permission mode as a number. Members per the
|
||||
/// 2023.06.21 dump (enum KJJFEBEMPKM, mapped by
|
||||
/// EventsConfig.BroadcastPermissionsModeFriendlyNameMapping): None = 0,
|
||||
/// RoomOwners = 256, All = Int32.MaxValue. Accepts the number itself (JSON
|
||||
/// bodies send numbers), a member name, or a comma-joined flags string.
|
||||
/// Null when the value is absent or unrecognised, so the stored setting is
|
||||
/// kept rather than silently zeroed.</summary>
|
||||
public static int? ParseBroadcastPermissions(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) return n;
|
||||
var total = 0;
|
||||
foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
int? value = part.ToLowerInvariant() switch
|
||||
{
|
||||
"none" => 0,
|
||||
"roomowners" => 256,
|
||||
"all" => int.MaxValue,
|
||||
_ => null,
|
||||
};
|
||||
if (value is null) return null;
|
||||
total |= value.Value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
|
||||
@@ -48,14 +48,18 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
.Where(p => p.Id == me)
|
||||
.Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam))
|
||||
.FirstOrDefaultAsync();
|
||||
// Both calls are read as DJMHAFPGLLN = {Success, Error} (the formatter,
|
||||
// RecNet.Runtime/LMCCNLLHLCJ.txt:191-218, knows no "Message" key). A
|
||||
// failure without Error reached Promise.Rejected(null) and threw
|
||||
// "Error message cannot be null" instead of showing the reason.
|
||||
if (moderator != true)
|
||||
return Ok(new { Success = false, Message = "not_authorized" });
|
||||
return Ok(new { Success = false, Error = "not_authorized", Message = "not_authorized" });
|
||||
|
||||
var target = warnedPlayerId
|
||||
?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0);
|
||||
if (target <= 0) return Ok(new { Success = false, Message = "missing_player" });
|
||||
if (target <= 0) return Ok(new { Success = false, Error = "missing_player", Message = "missing_player" });
|
||||
if (!await db.Players.AnyAsync(p => p.Id == target))
|
||||
return Ok(new { Success = false, Message = "player_not_found" });
|
||||
return Ok(new { Success = false, Error = "player_not_found", Message = "player_not_found" });
|
||||
|
||||
// Same "warning:{id}|message|category|createdAt|acknowledged" layout the
|
||||
// GET list handler parses in ToWarningWire.
|
||||
@@ -75,7 +79,7 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
"false"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty, WarningId = id });
|
||||
return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = id });
|
||||
}
|
||||
|
||||
/// <summary>The 2023-03-21 client's acknowledge call takes NO arguments —
|
||||
@@ -104,19 +108,19 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged);
|
||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
||||
if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty });
|
||||
}
|
||||
else
|
||||
{
|
||||
row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
|
||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
||||
if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty });
|
||||
}
|
||||
|
||||
var parts = row.Value.Split('|');
|
||||
row.Value = string.Join('|', parts.Take(3).Append("true"));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
|
||||
return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
|
||||
}
|
||||
|
||||
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
|
||||
|
||||
@@ -226,6 +226,11 @@ public class ProgressionEventsController(
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
// The sentinel key needs the generated gift id, so this takes two
|
||||
// saves — but they must commit together: a crash between them would
|
||||
// leave a gift with no sentinel, and the next Collect would mint the
|
||||
// reward a second time.
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -237,6 +242,7 @@ public class ProgressionEventsController(
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
|
||||
}
|
||||
|
||||
@@ -284,7 +284,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
||||
offer.IsDeleted = true;
|
||||
offer.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToOfferWire(offer, currency));
|
||||
// MIHKAJFMIPE.OHCONDGELJB hands this body to BFDGEKKNAPB.PEFKBAEAOPD
|
||||
// (ExpandRecNetResult): it is a {success,error} RecNetResult, not the
|
||||
// offer. The offer wire has no Success key, so it parsed as a failure
|
||||
// with a null Error and the delete toast threw instead of closing.
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>Both verbs are mapped on purpose: the client picks GET or POST at
|
||||
|
||||
@@ -1642,7 +1642,21 @@ public class RoomsModerationController(
|
||||
|
||||
var newBlob = CloneBlobName(cloneId, orderIndex);
|
||||
var (dstBucket, dstKey) = BlobRouter.Route(newBlob);
|
||||
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));
|
||||
|
||||
@@ -177,7 +177,10 @@ public class SubscriptionsController(
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
// The client reads this body as DJMHAFPGLLN, the {success,error}
|
||||
// RecNetResult (FGLDKEJLAKB<DJMHAFPGLLN> at IKMMOCKDKAF.txt LPFOPPHGDHG,
|
||||
// instr 098) — answer in that shape rather than an empty 200.
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Models.Auth;
|
||||
@@ -60,7 +61,7 @@ public class AccountsController(
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
return Ok(AccountDtos.Self(player));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -193,7 +194,7 @@ public class AccountsController(
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(accountId);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
return Ok(AccountDtos.Public(player));
|
||||
}
|
||||
|
||||
// Accounts.GetAccountByUsername
|
||||
@@ -202,7 +203,7 @@ public class AccountsController(
|
||||
{
|
||||
var player = await playerService.GetByUsernameAsync(username);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
return Ok(AccountDtos.Public(player));
|
||||
}
|
||||
|
||||
// Accounts.CreateAccount — actual URL observed in server logs:
|
||||
@@ -262,7 +263,7 @@ public class AccountsController(
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName),
|
||||
Account = AccountDtos.Public(player),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,7 +286,7 @@ public class AccountsController(
|
||||
var clamped = Math.Clamp(take, 1, 50);
|
||||
var matches = await playerService.SearchAsync(term.Trim(), clamped);
|
||||
return Ok(matches
|
||||
.Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName))
|
||||
.Select(p => AccountDtos.Public(p))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
@@ -301,6 +302,12 @@ public class AccountsController(
|
||||
[HttpPost("/account/v1/emails")]
|
||||
[HttpPost("/account/v1/phones")]
|
||||
[HttpPost("/account/v1/byplatformid")]
|
||||
// 2023 contact lookup: OPMAPIOEIFG.HHAAAPKCLMN (RecNet.Runtime/OPMAPIOEIFG.txt:11864)
|
||||
// POSTs "account/bulk/" + "email" or "phone" (:11590-11620, verb 2) with the
|
||||
// values as repeated form fields of the same name, and reads the same account
|
||||
// list (List<BLOJAFNJPJI>) as account/bulk. Both suffixes 404'd.
|
||||
[HttpPost("/account/bulk/email")]
|
||||
[HttpPost("/account/bulk/phone")]
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
// The watch calls this route family with payloads keyed by
|
||||
@@ -400,7 +407,7 @@ public class AccountsController(
|
||||
{
|
||||
var p = await playerService.GetByIdAsync(id);
|
||||
if (p is not null)
|
||||
accounts.Add(BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName));
|
||||
accounts.Add(AccountDtos.Public(p));
|
||||
}
|
||||
return Ok(accounts);
|
||||
}
|
||||
@@ -432,8 +439,11 @@ public class AccountsController(
|
||||
}
|
||||
|
||||
// Phone-confirm is the one mutation we don't persist — there's no
|
||||
// SMS provider in the loop. Acknowledge silently.
|
||||
// SMS provider in the loop. Acknowledge silently. The 2023 client
|
||||
// composes the route as "account/me/" + "confirmphone" with POST
|
||||
// (OPMAPIOEIFG.OBAGJBBIFGB) and reads the RecNetResult body.
|
||||
[HttpPost("/account/v1/confirmphone")]
|
||||
[HttpPost("/account/me/confirmphone")]
|
||||
public IActionResult ConfirmPhone() =>
|
||||
Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
|
||||
@@ -628,6 +638,126 @@ public class AccountsController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── 2023 profile edits ────────────────────────────────────────────
|
||||
// All four go through OPMAPIOEIFG.OBAGJBBIFGB, which builds
|
||||
// "account/me/" + suffix (RecNet.Runtime OPMAPIOEIFG.txt:8579) and
|
||||
// reports "Failed to modify <suffix>" on any non-2xx (:8609). Every call
|
||||
// site passes verb 3 = PUT. The form field name is NOT the suffix — each
|
||||
// comes from the lambda that fills the request:
|
||||
// emoji → "displayEmoji" (OPMAPIOEIFG_NestedType_KPGNAEPGLAC.txt:57)
|
||||
// personalpronouns → "pronounFlags" (…_LCCEHLBIOPK.txt:67, boxed Int32)
|
||||
// identityflags → "identityFlags" (…_HHDFMJPLFHG.txt:67, boxed Int32)
|
||||
// bannerimage → "imageName" (…_NJKHNGEPODH.txt:57)
|
||||
// The response body is never parsed (LDGADANDBIO handle), so the new
|
||||
// value reaches the editor's UI and other players only through the
|
||||
// AccountUpdate / SelfAccountUpdate push in NotificationService.
|
||||
// None of these routes existed before, so every edit 404'd.
|
||||
|
||||
/// <summary>PUT <c>/account/me/emoji</c> — form <c>displayEmoji</c>.
|
||||
/// Empty clears the emoji; anything else must be on the
|
||||
/// <c>emojiConfig/whitelistedEmojis</c> list the picker was filled
|
||||
/// from, and is stored in that list's canonical spelling.</summary>
|
||||
[HttpPut("/account/me/emoji")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeEmoji([FromForm(Name = "displayEmoji")] string? displayEmoji)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
|
||||
var submitted = displayEmoji?.Trim() ?? string.Empty;
|
||||
string stored;
|
||||
if (submitted.Length == 0)
|
||||
{
|
||||
stored = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
var canonical = ResolveWhitelistedEmoji(submitted);
|
||||
if (canonical is null)
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "emoji_not_allowed" });
|
||||
stored = canonical;
|
||||
}
|
||||
|
||||
player.DisplayEmoji = stored;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>Whitelist match that ignores U+FE0F variation selectors, so
|
||||
/// "❤" and "❤️" resolve to the same entry. Returns the list's spelling
|
||||
/// (the client highlights the current pick by string equality against
|
||||
/// the list it fetched).</summary>
|
||||
internal static string? ResolveWhitelistedEmoji(string value)
|
||||
{
|
||||
static string Strip(string s) => s.Replace("️", string.Empty, StringComparison.Ordinal);
|
||||
var needle = Strip(value);
|
||||
foreach (var e in API.Config.V1.ConfigController.WhitelistedEmojiList)
|
||||
if (string.Equals(Strip(e), needle, StringComparison.Ordinal)) return e;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>/account/me/personalpronouns</c> — form
|
||||
/// <c>pronounFlags</c> (pronoun flags enum as Int32).</summary>
|
||||
[HttpPut("/account/me/personalpronouns")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangePersonalPronouns([FromForm(Name = "pronounFlags")] string? pronounFlags)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
if (!TryParseFlags(pronounFlags, out var flags))
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "invalid_pronouns" });
|
||||
|
||||
player.PersonalPronouns = flags;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>/account/me/identityflags</c> — form
|
||||
/// <c>identityFlags</c> (identity flags enum as Int32).</summary>
|
||||
[HttpPut("/account/me/identityflags")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeIdentityFlags([FromForm(Name = "identityFlags")] string? identityFlags)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
if (!TryParseFlags(identityFlags, out var flags))
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "invalid_identity_flags" });
|
||||
|
||||
player.IdentityFlags = flags;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>Flags arrive as the boxed enum's Int32 ToString(). Negative
|
||||
/// values are not valid bitmasks for either enum.</summary>
|
||||
private static bool TryParseFlags(string? raw, out int flags)
|
||||
=> int.TryParse(raw?.Trim(), System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out flags)
|
||||
&& flags >= 0;
|
||||
|
||||
/// <summary>PUT <c>/account/me/bannerimage</c> — form <c>imageName</c>,
|
||||
/// the storage name of an image the player already uploaded (the
|
||||
/// portfolio-banner picker offers the player's own photos). Empty clears
|
||||
/// the banner.</summary>
|
||||
[HttpPut("/account/me/bannerimage")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeBannerImage([FromForm(Name = "imageName")] string? imageName)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
var name = imageName?.Trim() ?? string.Empty;
|
||||
if (name.Length > 256)
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "invalid_image" });
|
||||
|
||||
player.BannerImageName = name.Length == 0 ? null : name;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
// ParentalControl.Deserialize at RVA 0x14543A0 — required keys:
|
||||
// accountId (int, GetKey)
|
||||
// disallowInAppPurchases (bool, GetKey)
|
||||
@@ -679,63 +809,56 @@ public class AccountsController(
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(10);
|
||||
await SetPlayerSettingAsync(player.Id, "remote_login_token", $"{token}|{expiresAt:O}");
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Token = token,
|
||||
LoginToken = token,
|
||||
ExpiresAt = expiresAt,
|
||||
});
|
||||
await SetPlayerSettingAsync(player.Id, RemoteLoginTokenKey, $"{token}|{expiresAt:O}");
|
||||
// FGLDKEJLAKB<System.String> LGKLFFPACGL() (RecNet.Runtime/
|
||||
// LBNJFPOLCDL.txt:8382) deserialises the reply as a BARE JSON string.
|
||||
// The old {Success, Token, ...} object hit the string reader's '"'
|
||||
// check and every "open on the web" link failed with "Failed to
|
||||
// CreateLoginToken". The token is redeemed by connect/token
|
||||
// grant_type=login_token (AuthController.Token).
|
||||
return Content(System.Text.Json.JsonSerializer.Serialize(token), "application/json");
|
||||
}
|
||||
|
||||
/// <summary>PlayerSettings key of the one-shot token minted above.
|
||||
/// Value is <c>{token}|{expiresAtUtc:O}</c>.</summary>
|
||||
public const string RemoteLoginTokenKey = "remote_login_token";
|
||||
|
||||
/// <summary>In-game approval of another device's login code.
|
||||
///
|
||||
/// <c>LDGADANDBIO BJMMDKNPCLD(String)</c> (RecNet.Runtime/LBNJFPOLCDL.txt:7896)
|
||||
/// POSTs the single form field <c>code</c> and reads the reply through
|
||||
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> as a <c>{success,error}</c> RecNetResult
|
||||
/// (failure toast "Failed to authorize login"). The code is the
|
||||
/// <c>user_code</c> that <c>connect/deviceauthorization</c> handed the other
|
||||
/// device, which meanwhile polls <c>connect/token</c> with
|
||||
/// <c>grant_type=urn:ietf:params:oauth:grant-type:device_code</c>
|
||||
/// (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451). Approving binds the
|
||||
/// pending authorization to the CALLER, and that device's next poll is
|
||||
/// issued the caller's tokens (<see cref="DeviceAuthorizations"/>).
|
||||
///
|
||||
/// This used to look the code up among createlogintoken tokens — which a
|
||||
/// user_code never matches, so every approval 401'd — and answered with a
|
||||
/// token pair, which is not a RecNetResult either.</summary>
|
||||
[HttpPost("/account/me/remoteauth")]
|
||||
[HttpGet("/account/me/remoteauth")]
|
||||
public async Task<IActionResult> RemoteAuth()
|
||||
{
|
||||
var token = Request.Query["token"].FirstOrDefault()
|
||||
?? Request.Query["loginToken"].FirstOrDefault()
|
||||
?? Request.Query["code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(token) && Request.HasFormContentType)
|
||||
var approver = await GetCurrentPlayerAsync();
|
||||
if (approver is null) return Unauthorized();
|
||||
|
||||
var code = Request.Query["code"].FirstOrDefault()
|
||||
?? Request.Query["user_code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(code) && Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
token = form["token"].FirstOrDefault()
|
||||
?? form["loginToken"].FirstOrDefault()
|
||||
?? form["code"].FirstOrDefault();
|
||||
code = form["code"].FirstOrDefault() ?? form["user_code"].FirstOrDefault();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return RecNetResults.Fail("missing_code");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
var pair = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = pair.AccessToken,
|
||||
RefreshToken = pair.RefreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
var prefix = token.Trim() + "|";
|
||||
var row = await db.PlayerSettings
|
||||
.Where(s => s.Key == "remote_login_token" && s.Value.StartsWith(prefix))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (row is null) return Unauthorized();
|
||||
|
||||
var rawExpiry = row.Value[prefix.Length..];
|
||||
if (!DateTime.TryParse(rawExpiry, out var expiresAt) || expiresAt < DateTime.UtcNow)
|
||||
return Unauthorized();
|
||||
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
var (access, refresh) = authService.GenerateTokenPair(row.PlayerId);
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access,
|
||||
RefreshToken = refresh,
|
||||
});
|
||||
return await DeviceAuthorizations.ApproveAsync(db, code, approver.Id, DateTime.UtcNow)
|
||||
? RecNetResults.Ok()
|
||||
: RecNetResults.Fail("invalid_or_expired_code");
|
||||
}
|
||||
|
||||
[HttpPost("/account/recoverpassword")]
|
||||
@@ -808,8 +931,12 @@ public class AccountsController(
|
||||
/// Unknown accounts get an empty string — "no platform identity" is the
|
||||
/// honest answer and keeps the promise resolved rather than rejected.
|
||||
/// </summary>
|
||||
// LastPlatformId is a third-party identifier (Steam/Oculus id); without
|
||||
// an auth gate this was an anonymous enumeration oracle over every
|
||||
// account. The 2023 client only ever calls it with a token.
|
||||
[HttpGet("/platformid/{accountId:long}")]
|
||||
[HttpGet("/account/v1/{accountId:long}/platformid")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetPlatformId(long accountId)
|
||||
{
|
||||
var platformId = await db.Players
|
||||
@@ -948,8 +1075,15 @@ public class AccountsController(
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var visible = await ReadRecentHistoryVisibleBodyAsync();
|
||||
await SetPlayerSettingAsync(me, RecentHistoryVisibleKey, visible ? "true" : "false");
|
||||
// OPMAPIOEIFG.PNEDPCJCKHI passes this body to BFDGEKKNAPB.PEFKBAEAOPD
|
||||
// (ExpandRecNetResult) — it is read as {Success, Error}. Without a
|
||||
// Success key the toggle parsed as a failure with a null Error, which
|
||||
// throws "Error message cannot be null" instead of saving quietly.
|
||||
// The settings keys stay for any reader of the old shape.
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
AccountId = (int)me,
|
||||
IsRecentHistoryVisible = visible,
|
||||
});
|
||||
@@ -990,35 +1124,4 @@ public class AccountsController(
|
||||
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
|
||||
// unknown accounts URLs now 404 so we notice them in logs and wire
|
||||
// them properly rather than silently masking missing endpoints.
|
||||
|
||||
private static RecNetAccount BuildAccount(long id, string username, string? displayName, string? profileImage = null)
|
||||
=> new()
|
||||
{
|
||||
AccountId = (int)id,
|
||||
RawUsername = username,
|
||||
Username = username,
|
||||
DisplayName = displayName ?? username,
|
||||
ProfileImage = profileImage ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
};
|
||||
|
||||
private static RecNetSelfAccount BuildSelfAccount(long id, string username, string? displayName, string? profileImage = null)
|
||||
=> new()
|
||||
{
|
||||
AccountId = (int)id,
|
||||
RawUsername = username,
|
||||
Username = username,
|
||||
DisplayName = displayName ?? username,
|
||||
ProfileImage = profileImage ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
Email = string.Empty,
|
||||
Phone = string.Empty,
|
||||
Birthday = new DateTime(2000, 1, 1),
|
||||
JuniorState = 0,
|
||||
ParentAccountId = null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Hubs;
|
||||
using DorkNet.Server.Services;
|
||||
using RecRoom.Protobuf;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Admin;
|
||||
|
||||
@@ -974,7 +975,8 @@ public class AdminController(
|
||||
double? HotScore,
|
||||
int? State,
|
||||
string? ImageName,
|
||||
int? MaxCapacity);
|
||||
int? MaxCapacity,
|
||||
bool? IsAGRoom);
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
|
||||
/// update of room properties. Only the non-null fields in the
|
||||
@@ -1004,6 +1006,20 @@ public class AdminController(
|
||||
room.MaxCapacity = Math.Clamp(cap, 1, 80);
|
||||
changes.Add($"maxCapacity={room.MaxCapacity}");
|
||||
}
|
||||
if (body.IsAGRoom is bool ag)
|
||||
{
|
||||
// IsAGRoom is what the client reads as IsRRO. True: load the
|
||||
// baked scene from the client's own assets AND consult the
|
||||
// client-baked AGRoomRuntimeConfig table (which, per original,
|
||||
// decides e.g. whether creation is allowed — Rec Center yes, Rec
|
||||
// Royale no). False: treat the room as a custom room (roles and
|
||||
// blob decide everything). Flipping a CLONE of a baked original
|
||||
// to false is the server-side experiment for "editable Rec
|
||||
// Royale"; whether the client still loads the baked scene for a
|
||||
// non-original is what that experiment answers.
|
||||
room.IsAGRoom = ag;
|
||||
changes.Add($"isAGRoom={ag}");
|
||||
}
|
||||
|
||||
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
|
||||
|
||||
@@ -1117,6 +1133,36 @@ public class AdminController(
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
|
||||
var room = await db.Rooms.AsNoTracking().FirstAsync(r => r.Id == id);
|
||||
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
|
||||
|
||||
// Seeded baked originals (Rec Royale, …) have NO RoomScenes rows at
|
||||
// all; the game-client details builder synthesises a "Home" entry
|
||||
// scene for them (RoomsController.BuildSubRoomWire with a null scene)
|
||||
// and the mutation handlers create the row on first write. Mirror
|
||||
// that here so the SPA has something to act on — the blob/enable
|
||||
// action below creates the real row.
|
||||
if (scenes.Count == 0)
|
||||
{
|
||||
return Ok(new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = "0",
|
||||
subRoomId = 0,
|
||||
name = "Home",
|
||||
maxPlayers = room.MaxCapacity,
|
||||
canMatchmakeInto = true,
|
||||
isSandbox = false,
|
||||
dataBlobName = string.Empty,
|
||||
effectiveBlobName = EffectiveSubRoomBlobName(room, null, bakedOriginal),
|
||||
isBakedOriginal = bakedOriginal,
|
||||
isVirtual = true,
|
||||
dataModifiedAt = room.UpdatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(scenes.Select(s => new
|
||||
{
|
||||
// Ids go out as strings: the admin SPA is JavaScript and silently
|
||||
@@ -1128,10 +1174,260 @@ public class AdminController(
|
||||
canMatchmakeInto = s.CanMatchmakeInto,
|
||||
isSandbox = s.IsSandbox,
|
||||
dataBlobName = s.DataBlobName,
|
||||
// What the client will actually be told to download for this
|
||||
// sub-room (same rule as RoomsController.BuildSubRoomWire): the
|
||||
// scene's own blob, else the room's, else a synthetic default —
|
||||
// except baked Rec Room Originals, which get NO blob until an
|
||||
// admin enables one (see EnableSubRoomBlob).
|
||||
effectiveBlobName = EffectiveSubRoomBlobName(room, s, bakedOriginal),
|
||||
isBakedOriginal = bakedOriginal,
|
||||
dataModifiedAt = s.DataModifiedAt,
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
private static string EffectiveSubRoomBlobName(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scene?.DataBlobName)) return scene!.DataBlobName;
|
||||
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
|
||||
if (bakedOriginal) return string.Empty;
|
||||
return RoomService.SyntheticDefaultRoomDataBlobName(room.Id);
|
||||
}
|
||||
|
||||
/// <summary>Blob name handed to a baked RRO sub-room when an admin enables
|
||||
/// editing: the synthetic default for the entry scene (the same name the
|
||||
/// CDN already recognises as "serve the RRO-editable blob on S3 miss"),
|
||||
/// and the clone-style <c>_sub{n}</c> form for the others.</summary>
|
||||
private static string SyntheticSubRoomBlobName(long roomId, int subRoomId) =>
|
||||
subRoomId == 0
|
||||
? RoomService.SyntheticDefaultRoomDataBlobName(roomId)
|
||||
: $"room_{roomId}_dorknet_v8_sub{subRoomId}.dat";
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable</c>
|
||||
/// — give a baked Rec Room Original sub-room a data blob so the client
|
||||
/// runs its room-data path there (Maker Pen, CV2 chips, room saves).
|
||||
///
|
||||
/// Why this exists: baked RRO rooms (Rec Royale, Paintball, …) ship with
|
||||
/// an empty <see cref="RoomSceneEntity.DataBlobName"/>, and every details
|
||||
/// builder + matchmaking resolver turns that into an empty
|
||||
/// <c>DataBlob</c>, which the client treats as "no room data at all" —
|
||||
/// no persistence views, no room-role overlay, no Maker Pen, no room
|
||||
/// mood engine. Real Rec Room served these rooms a blob (Rec Royale's
|
||||
/// game-rule chip <c>RecRoyaleSolos_GameRuleWrapperCircuit</c> lives in
|
||||
/// it, not in the scene). Stamping the synthetic default name is enough:
|
||||
/// every resolver returns it verbatim, and <c>CdnController</c> already
|
||||
/// answers that name for AG rooms with the RRO-editable blob (permissive
|
||||
/// room roles, no objects) when nothing has been saved yet. The first
|
||||
/// in-game save then replaces the name with a real upload.
|
||||
///
|
||||
/// No-op when the sub-room already has a blob. Idempotent.</summary>
|
||||
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/enable")]
|
||||
public async Task<ActionResult> EnableSubRoomBlob(long id, int subRoomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
|
||||
if (scene is null && subRoomId != 0) return NotFound();
|
||||
|
||||
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
|
||||
if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName))
|
||||
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: false));
|
||||
|
||||
var blobName = SyntheticSubRoomBlobName(id, subRoomId);
|
||||
var now = DateTime.UtcNow;
|
||||
if (scene is null)
|
||||
{
|
||||
// Seeded baked originals carry no RoomScenes row; create the
|
||||
// entry scene with the same defaults the game-side mutation
|
||||
// path uses (RoomsController.GetOrCreateSceneForMutationAsync).
|
||||
scene = new RoomSceneEntity
|
||||
{
|
||||
RoomId = room.Id,
|
||||
OrderIndex = 0,
|
||||
Name = "Home",
|
||||
RoomSceneLocationId = room.LocationReplicationId,
|
||||
MaxPlayers = room.MaxCapacity,
|
||||
IsSandbox = false,
|
||||
CanMatchmakeInto = true,
|
||||
};
|
||||
db.RoomScenes.Add(scene);
|
||||
}
|
||||
scene.DataBlobName = blobName;
|
||||
scene.DataModifiedAt = now;
|
||||
if (subRoomId == 0 && string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
|
||||
{
|
||||
room.CurrentDataBlobName = blobName;
|
||||
room.UpdatedAt = now;
|
||||
}
|
||||
|
||||
await LogAsync("enable_subroom_blob", "room", id, $"subRoom={subRoomId} blob={blobName}");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed: true));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset</c>
|
||||
/// — clear the sub-room's data blob reference. For a baked Rec Room
|
||||
/// Original that puts the room back to "scene only, no blob" (the escape
|
||||
/// hatch if a save left the room unplayable); for any other room the
|
||||
/// resolvers fall back to the synthetic default, i.e. an empty room.
|
||||
/// Saved blobs stay in <c>RoomDataBlobs</c>; only the pointers move, so
|
||||
/// the in-game restore list can bring an old save back.</summary>
|
||||
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/reset")]
|
||||
public async Task<ActionResult> ResetSubRoomBlob(long id, int subRoomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
|
||||
if (scene is null && subRoomId != 0) return NotFound();
|
||||
|
||||
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
|
||||
var previousScene = scene?.DataBlobName ?? string.Empty;
|
||||
var previousRoom = room.CurrentDataBlobName;
|
||||
var changed = false;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (scene is not null && !string.IsNullOrWhiteSpace(scene.DataBlobName))
|
||||
{
|
||||
scene.DataBlobName = string.Empty;
|
||||
scene.DataModifiedAt = now;
|
||||
changed = true;
|
||||
}
|
||||
if (subRoomId == 0 && !string.IsNullOrWhiteSpace(room.CurrentDataBlobName))
|
||||
{
|
||||
room.CurrentDataBlobName = string.Empty;
|
||||
room.UpdatedAt = now;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
await LogAsync("reset_subroom_blob", "room", id,
|
||||
$"subRoom={subRoomId} scene '{previousScene}' -> '' room '{previousRoom}' -> '{room.CurrentDataBlobName}'");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(SubRoomBlobWire(room, scene, bakedOriginal, changed));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload</c>
|
||||
/// — multipart <c>file</c> = a real <c>PersistedRoomData</c> save (any
|
||||
/// client version; <c>.binpb</c> / <c>.room</c> / <c>.dat</c>), optional
|
||||
/// form/query <c>mode</c> = <c>full</c> (default) or <c>basics</c>.
|
||||
/// Rewrites it for this room (activity id, 2023 version clamp, drops
|
||||
/// output log / sub-room id / unknown fields; <c>basics</c> also keeps
|
||||
/// only the game-role and game-rule objects and drops the circuit
|
||||
/// payloads — see <see cref="RoomDataBlobService.PrepareUploadedRoomBlob"/>),
|
||||
/// stores it under a fresh <c>room_{id}_upload_*.dat</c> name, records
|
||||
/// the RoomDataBlobs row and points the sub-room (and, for sub-room 0,
|
||||
/// the room) at it. The CDN's RRO role overlay and version clamp then
|
||||
/// apply on serve exactly as for Rec Center's saved blob.</summary>
|
||||
[HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/blob/upload")]
|
||||
[RequestSizeLimit(64 * 1024 * 1024)]
|
||||
public async Task<ActionResult> UploadSubRoomBlob(long id, int subRoomId, IFormFile? file, [FromQuery] string? mode)
|
||||
{
|
||||
if (file is null || file.Length == 0) return BadRequest("missing file");
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (room is null) return NotFound();
|
||||
var scene = await db.RoomScenes.FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId);
|
||||
if (scene is null && subRoomId != 0) return NotFound();
|
||||
|
||||
byte[] raw;
|
||||
using (var ms = new MemoryStream((int)file.Length))
|
||||
{
|
||||
await file.CopyToAsync(ms);
|
||||
raw = ms.ToArray();
|
||||
}
|
||||
try { _ = PersistedRoomData.Parser.ParseFrom(raw); }
|
||||
catch (Exception ex) { return BadRequest($"not a PersistedRoomData blob: {ex.Message}"); }
|
||||
|
||||
var basics = string.Equals(mode ?? Request.Form["mode"].ToString(), "basics", StringComparison.OrdinalIgnoreCase);
|
||||
var (bytes, summary) = RoomDataBlobService.PrepareUploadedRoomBlob(raw, room.LocationReplicationId, basics);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var blobName = $"room_{id}_upload_{now:yyyyMMddHHmmss}.dat";
|
||||
var (bucket, key) = BlobRouter.Route(blobName);
|
||||
try
|
||||
{
|
||||
await storage.PutAsync(bucket, key, bytes, "application/octet-stream");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
adminLogger.LogError(ex, "[admin] blob upload S3 PUT failed room={Room} blob={Blob}", id, blobName);
|
||||
return StatusCode(502, new { error = "storage_put_failed", detail = ex.Message });
|
||||
}
|
||||
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
||||
{
|
||||
RoomId = id,
|
||||
BlobName = blobName,
|
||||
SubRoomId = subRoomId,
|
||||
UploadedByPlayerId = CurrentAdminId,
|
||||
UploadedAt = now,
|
||||
});
|
||||
|
||||
if (scene is null)
|
||||
{
|
||||
scene = new RoomSceneEntity
|
||||
{
|
||||
RoomId = room.Id,
|
||||
OrderIndex = 0,
|
||||
Name = "Home",
|
||||
RoomSceneLocationId = room.LocationReplicationId,
|
||||
MaxPlayers = room.MaxCapacity,
|
||||
IsSandbox = false,
|
||||
CanMatchmakeInto = true,
|
||||
};
|
||||
db.RoomScenes.Add(scene);
|
||||
}
|
||||
scene.DataBlobName = blobName;
|
||||
scene.DataModifiedAt = now;
|
||||
if (subRoomId == 0)
|
||||
{
|
||||
room.CurrentDataBlobName = blobName;
|
||||
room.UpdatedAt = now;
|
||||
}
|
||||
|
||||
await LogAsync("upload_subroom_blob", "room", id,
|
||||
$"subRoom={subRoomId} blob={blobName} mode={(basics ? "basics" : "full")} bytes={bytes.Length} views={summary.ViewsKept}/{summary.ViewsIn} roles={summary.RoleViews} rules={summary.GameRuleViews} version={summary.VersionIn}->{summary.VersionOut}");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var bakedOriginal = RoomService.IsBakedOriginalRoom(room);
|
||||
return Ok(new
|
||||
{
|
||||
id = scene.Id.ToString(),
|
||||
subRoomId = scene.OrderIndex,
|
||||
name = scene.Name,
|
||||
dataBlobName = scene.DataBlobName,
|
||||
roomCurrentDataBlobName = room.CurrentDataBlobName,
|
||||
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
|
||||
isBakedOriginal = bakedOriginal,
|
||||
bytes = bytes.Length,
|
||||
// Explicit camelCase: the API's naming policy is "as declared",
|
||||
// and the rest of this payload is camelCase.
|
||||
summary = new
|
||||
{
|
||||
viewsIn = summary.ViewsIn,
|
||||
viewsKept = summary.ViewsKept,
|
||||
roleViews = summary.RoleViews,
|
||||
gameRuleViews = summary.GameRuleViews,
|
||||
versionIn = summary.VersionIn,
|
||||
versionOut = summary.VersionOut,
|
||||
circuitsStripped = summary.CircuitsStripped,
|
||||
activityId = summary.ActivityId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static object SubRoomBlobWire(RoomEntity room, RoomSceneEntity? scene, bool bakedOriginal, bool changed) => new
|
||||
{
|
||||
id = (scene?.Id ?? 0).ToString(),
|
||||
subRoomId = scene?.OrderIndex ?? 0,
|
||||
name = scene?.Name ?? "Home",
|
||||
dataBlobName = scene?.DataBlobName ?? string.Empty,
|
||||
roomCurrentDataBlobName = room.CurrentDataBlobName,
|
||||
effectiveBlobName = EffectiveSubRoomBlobName(room, scene, bakedOriginal),
|
||||
isBakedOriginal = bakedOriginal,
|
||||
changed,
|
||||
};
|
||||
|
||||
public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers);
|
||||
|
||||
/// <summary>PUT <c>api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers</c>
|
||||
@@ -1158,10 +1454,12 @@ public class AdminController(
|
||||
var previous = scene.MaxPlayers;
|
||||
scene.MaxPlayers = Math.Clamp(body.MaxPlayers, 1, 80);
|
||||
scene.DataModifiedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// LogAsync only stages the AdminActionEntity; save afterwards so the
|
||||
// capacity change and its audit record commit together.
|
||||
await LogAsync("update_subroom_maxplayers", "room", id,
|
||||
$"subRoom={subRoomId} maxPlayers {previous} -> {scene.MaxPlayers}");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -1957,6 +2255,22 @@ public class AdminController(
|
||||
return Ok(settings);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/settings/circuits-limits</c> — the
|
||||
/// CircuitsV2 creation limits served as the <c>UGC.CircuitsV2.Limit.*</c>
|
||||
/// gameconfig keys, including the per-frame RPC and byte budgets.</summary>
|
||||
[HttpGet("settings/circuits-limits")]
|
||||
public async Task<ActionResult> GetCircuitsV2Limits()
|
||||
=> Ok(await serverSettings.GetCircuitsV2LimitsAsync());
|
||||
|
||||
[HttpPost("settings/circuits-limits")]
|
||||
public async Task<ActionResult> SetCircuitsV2Limits([FromBody] CircuitsV2LimitSettings body)
|
||||
{
|
||||
var settings = await serverSettings.SetCircuitsV2LimitsAsync(body);
|
||||
await LogAsync("gameconfigs_updated", "system", 0, "circuitsv2-limits");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(settings);
|
||||
}
|
||||
|
||||
// ── Signup codes ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
|
||||
|
||||
@@ -101,7 +101,9 @@ public class AuthController(
|
||||
[FromForm] int platform = 0,
|
||||
[FromForm] string? platformId = null,
|
||||
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
|
||||
[FromForm] long? account_id = null)
|
||||
[FromForm] long? account_id = null,
|
||||
[FromForm] string? device_code = null,
|
||||
[FromForm] string? login_token = null)
|
||||
{
|
||||
// The client mixes camelCase and snake_case parameter naming
|
||||
// depending on grant type — accept both and merge.
|
||||
@@ -139,6 +141,55 @@ public class AuthController(
|
||||
return TokenOk(acc1, refr1);
|
||||
}
|
||||
|
||||
// Device-authorization poll (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451).
|
||||
// The client keeps polling on HTTP 400 with error authorization_pending
|
||||
// or slow_down (LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116); any other
|
||||
// error ends the attempt. This grant used to fall through to the
|
||||
// device-id fallback, which signed the polling device straight in as
|
||||
// whatever account its device id mapped to — no approval involved.
|
||||
if (string.Equals(grant_type, DeviceCodeGrantType, StringComparison.Ordinal))
|
||||
{
|
||||
var (status, approverId) = await DeviceAuthorizations.RedeemAsync(db, device_code, DateTime.UtcNow);
|
||||
switch (status)
|
||||
{
|
||||
case DeviceAuthorizations.RedeemStatus.Pending:
|
||||
return BadRequest(new { error = "authorization_pending" });
|
||||
case DeviceAuthorizations.RedeemStatus.Expired:
|
||||
return BadRequest(new { error = "expired_token" });
|
||||
case DeviceAuthorizations.RedeemStatus.Invalid:
|
||||
return BadRequest(new { error = "invalid_grant" });
|
||||
}
|
||||
var approver = await playerService.GetByIdAsync(approverId);
|
||||
if (approver is null) return BadRequest(new { error = "invalid_grant" });
|
||||
await TryAwardLoginXpAsync(approver.Id);
|
||||
var (accD, refrD) = authService.GenerateTokenPair(approver.Id);
|
||||
return TokenOk(accD, refrD);
|
||||
}
|
||||
|
||||
// One-shot token from account/me/createlogintoken, redeemed with the
|
||||
// account it was minted for (LBNJFPOLCDL.txt:7593-7606: grant_type,
|
||||
// account_id, login_token). Also used to fall through to the device-id
|
||||
// fallback, ignoring both account_id and the token.
|
||||
if (string.Equals(grant_type, "login_token", StringComparison.Ordinal))
|
||||
{
|
||||
if (account_id is not long tokenAccountId || string.IsNullOrWhiteSpace(login_token))
|
||||
return BadRequest(new { error = "invalid_request", error_description = "invalid login_token" });
|
||||
var prefix = login_token.Trim() + "|";
|
||||
var row = await db.PlayerSettings.FirstOrDefaultAsync(s =>
|
||||
s.PlayerId == tokenAccountId
|
||||
&& s.Key == Accounts.AccountsController.RemoteLoginTokenKey
|
||||
&& s.Value.StartsWith(prefix));
|
||||
if (row is null
|
||||
|| !DateTime.TryParse(row.Value[prefix.Length..], null,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind, out var tokenExpiry)
|
||||
|| tokenExpiry < DateTime.UtcNow)
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "invalid login_token" });
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
var (accL, refrL) = authService.GenerateTokenPair(tokenAccountId);
|
||||
return TokenOk(accL, refrL);
|
||||
}
|
||||
|
||||
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refresh_token))
|
||||
@@ -364,26 +415,20 @@ public class AuthController(
|
||||
[HttpGet("/connect/deviceauthorization")]
|
||||
public async Task<IActionResult> DeviceAuthorization()
|
||||
{
|
||||
var deviceCode = Guid.NewGuid().ToString("N");
|
||||
var userCode = deviceCode[..8].ToUpperInvariant();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = RelationshipQueries.SystemAccountId,
|
||||
Key = $"deviceauthorization:{deviceCode}",
|
||||
Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var created = await DeviceAuthorizations.CreateAsync(db, DateTime.UtcNow);
|
||||
return Ok(new
|
||||
{
|
||||
device_code = deviceCode,
|
||||
user_code = userCode,
|
||||
device_code = created.DeviceCode,
|
||||
user_code = created.UserCode,
|
||||
verification_uri = $"https://{domain.Sub("auth")}/device",
|
||||
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}",
|
||||
expires_in = 900,
|
||||
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={created.UserCode}",
|
||||
expires_in = created.ExpiresInSeconds,
|
||||
interval = 5,
|
||||
});
|
||||
}
|
||||
|
||||
public const string DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
||||
{
|
||||
Platform = platform,
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Cdn;
|
||||
@@ -557,11 +558,18 @@ public class CdnController(
|
||||
return new FileContentResult(TransparentPng, "image/png");
|
||||
}
|
||||
|
||||
if (await ShouldOverlayRroRoomRoleDataAsync(fileName))
|
||||
var rroRoom = await ResolveRroRoomAsync(fileName);
|
||||
if (rroRoom is not null)
|
||||
{
|
||||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable role blob",
|
||||
Request.Host.Host, fileName);
|
||||
var rroBlob = roomDataBlob.GetRroEditableBlob();
|
||||
// Per-room stub: a real save's skeleton at the 2023 version
|
||||
// ceiling with the room's own activity_id. The old generic stub
|
||||
// (version 19, roles only) was fetched and silently rejected by
|
||||
// the client, which is why an enabled baked room still had no
|
||||
// Maker Pen. See RoomDataBlobService.BuildRroEditableBlob.
|
||||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable blob (room {Room}, activity {Activity})",
|
||||
Request.Host.Host, fileName, rroRoom.Id, rroRoom.LocationReplicationId);
|
||||
var rroBlob = roomDataBlob.GetRroEditableBlob(rroRoom.LocationReplicationId, rroRoom.Name);
|
||||
rroBlob = ClampForClientUnlessDisabled(rroBlob, fileName);
|
||||
Response.Headers.CacheControl = "public, max-age=60";
|
||||
signatures.AddContentSignature(Response, rroBlob);
|
||||
return new FileContentResult(rroBlob, "application/octet-stream");
|
||||
@@ -569,14 +577,34 @@ public class CdnController(
|
||||
|
||||
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
|
||||
Request.Host.Host, fileName);
|
||||
var fallbackBlob = roomDataBlob.GetDefaultBlob();
|
||||
var fallbackBlob = ClampForClientUnlessDisabled(roomDataBlob.GetDefaultBlob(), fileName);
|
||||
signatures.AddContentSignature(Response, fallbackBlob);
|
||||
return new FileContentResult(fallbackBlob, "application/octet-stream");
|
||||
}
|
||||
|
||||
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName)
|
||||
/// <summary>Same 2023 version clamp the S3-hit path applies, for the
|
||||
/// synthesised blobs on the miss path — a stub past the client's ceiling
|
||||
/// is rejected exactly like a modern export would be.</summary>
|
||||
private byte[] ClampForClientUnlessDisabled(byte[] bytes, string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
||||
if (!IsRoomDataName(fileName)) return bytes;
|
||||
if (serverSettings.IsRoomBlobVersionClampDisabledAsync().GetAwaiter().GetResult()) return bytes;
|
||||
var (clamped, changed) = RoomDataBlobService.ClampVersionsFor2023(bytes);
|
||||
if (changed)
|
||||
logger.LogInformation("[cdn] clamped synthesised blob versions to 2023 maxima file={File}", fileName);
|
||||
return clamped;
|
||||
}
|
||||
|
||||
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName) =>
|
||||
await ResolveRroRoomAsync(fileName) is not null;
|
||||
|
||||
/// <summary>The AG/RRO room a blob name belongs to, or null when the name
|
||||
/// is not an RRO room's (current blob, synthetic <c>room_{id}_*</c> form,
|
||||
/// or a sub-room scene blob). Drives both the role overlay on S3 hits and
|
||||
/// the per-room stub on misses.</summary>
|
||||
private async Task<RoomEntity?> ResolveRroRoomAsync(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return null;
|
||||
|
||||
// MakerPen across ALL Rec Room Originals: overlay the permissive
|
||||
// room-role data (which grants CanUseMakerPen etc.) onto any AG/RRO
|
||||
@@ -587,9 +615,9 @@ public class CdnController(
|
||||
// blob's RoomRoleData field (RoomDataBlobService.OverlayRroEditable-
|
||||
// RoleData) — the game-rule circuit data and GameRoleData are left
|
||||
// intact — so quest rooms keep working, exactly as Stunt Runner does.
|
||||
if (await db.Rooms.AsNoTracking().AnyAsync(r =>
|
||||
r.IsAGRoom && r.CurrentDataBlobName == fileName))
|
||||
return true;
|
||||
var byCurrentBlob = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r =>
|
||||
r.IsAGRoom && r.CurrentDataBlobName == fileName);
|
||||
if (byCurrentBlob is not null) return byCurrentBlob;
|
||||
|
||||
// Synthetic/default room-data blob names for rooms with no saved blob:
|
||||
// the CURRENT form is room_{id}_dorknet_v8.dat
|
||||
@@ -603,18 +631,19 @@ public class CdnController(
|
||||
{
|
||||
var rest = fileName["room_".Length..];
|
||||
var underscore = rest.IndexOf('_');
|
||||
if (underscore > 0 &&
|
||||
long.TryParse(rest[..underscore], out var roomId) &&
|
||||
await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom))
|
||||
return true;
|
||||
if (underscore > 0 && long.TryParse(rest[..underscore], out var roomId))
|
||||
{
|
||||
var byId = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId && r.IsAGRoom);
|
||||
if (byId is not null) return byId;
|
||||
}
|
||||
}
|
||||
|
||||
return await (
|
||||
from scene in db.RoomScenes.AsNoTracking()
|
||||
join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id
|
||||
where room.IsAGRoom && scene.DataBlobName == fileName
|
||||
select scene.Id
|
||||
).AnyAsync();
|
||||
select room
|
||||
).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -91,11 +91,13 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
/// 2023 client sends the target club in a form-urlencoded
|
||||
/// <c>clubId</c> field (boxed <c>Nullable<Int64></c> pushed through
|
||||
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> at <c>IKMMOCKDKAF.txt:16923-16927</c>)
|
||||
/// and parses NO response DTO — the issuing method is
|
||||
/// <c>LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>)</c>, the status-only
|
||||
/// promise type, so an empty 200 is the contract. Verb ordinal 3 (PUT)
|
||||
/// is the HasValue branch of the same method at :16911; the null branch
|
||||
/// is the DELETE below (:16838).</summary>
|
||||
/// — the issuing method is <c>LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>)</c>.
|
||||
/// Despite the untyped promise the body IS parsed: both branches hand
|
||||
/// the response to <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (:16858, :16943), the
|
||||
/// <c>{success,error}</c> RecNetResult reader, so an empty 200 surfaced as
|
||||
/// a failure (<see cref="RecNetResults"/>). Verb ordinal 3 (PUT) is the
|
||||
/// HasValue branch of the same method at :16911; the null branch is the
|
||||
/// DELETE below (:16838).</summary>
|
||||
[HttpPut("/club/home/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> HomeMeSet()
|
||||
@@ -116,11 +118,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
clubId = fromQuery;
|
||||
if (clubId <= 0) return BadRequest(new { error = "missing_club_id" });
|
||||
if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>/club/home/me</c> — "Remove Home Clubhouse Room".
|
||||
/// No body, no response DTO (<c>IKMMOCKDKAF.txt:16830-16841</c>: route
|
||||
/// No request body; RecNetResult reply (<c>IKMMOCKDKAF.txt:16830-16858</c>: route
|
||||
/// literal, host 13, verb ordinal 4, error string "Failed to Remove Home
|
||||
/// Clubhouse Room"). Idempotent so a double-tap is harmless.</summary>
|
||||
[HttpDelete("/club/home/me")]
|
||||
@@ -128,7 +130,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
public async Task<IActionResult> HomeMeClear()
|
||||
{
|
||||
await clubs.ClearHomeClubAsync(Me);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
[HttpGet("/club/mine/member")]
|
||||
@@ -347,7 +349,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
{
|
||||
var pid = Me;
|
||||
await clubs.MarkAnnouncementReadAsync(pid, announcementId);
|
||||
return Ok(new { Read = true });
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
// ── Single-club reads ─────────────────────────────────────────────
|
||||
@@ -369,8 +371,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
/// the club NAME used only for the client-side confirmation copy and is
|
||||
/// never transmitted; the request is a bare DELETE with no body
|
||||
/// (<c>IKMMOCKDKAF.txt:17107-17120</c>, verb ordinal 4, host 13, error
|
||||
/// string "Failed to Delete Club"). <c>LDGADANDBIO</c> is the status-only
|
||||
/// promise so an empty 200 is the whole contract.
|
||||
/// string "Failed to Delete Club"). The promise is built by
|
||||
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult, :17137), so the
|
||||
/// body is a <c>{success,error}</c> RecNetResult — an empty 200 made the
|
||||
/// client report "Failed to Delete Club" after the club was gone. See
|
||||
/// <see cref="RecNetResults"/>.
|
||||
///
|
||||
/// Owner-only, and a soft delete — see
|
||||
/// <see cref="ClubService.DisbandAsync"/>.</summary>
|
||||
@@ -382,7 +387,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
{
|
||||
var disbanded = await clubs.DisbandAsync(clubId, Me);
|
||||
if (disbanded is null) return NotFound();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return Forbid(); }
|
||||
}
|
||||
@@ -1144,7 +1149,15 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
/// is reachable — see <see cref="ToWireMemberRequest"/>.</summary>
|
||||
private static int RequestStatusFor(ClubMembershipEntity row) => 1;
|
||||
|
||||
// ── Member mutations (void / IPromise) ───────────────────────────
|
||||
// ── Member mutations ─────────────────────────────────────────────
|
||||
//
|
||||
// Every one of these goes through the client's shared club-write helper
|
||||
// IKMMOCKDKAF.JNFEEDLCGHH ("Failed to " + action), which hands the
|
||||
// response to BFDGEKKNAPB.PEFKBAEAOPD (IKMMOCKDKAF.txt:26696) — the
|
||||
// RecNetResult reader. They are not status-only: an empty 200 parses to
|
||||
// null and the client shows "Failed to request to join club" (etc.) after
|
||||
// the server has applied the change. Success bodies are
|
||||
// RecNetResults.Ok().
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/requesttojoin</c> — caller
|
||||
/// asks to join. For Open clubs we promote straight to Member; for
|
||||
@@ -1169,7 +1182,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
};
|
||||
if (perms == -1) return Forbid();
|
||||
await clubs.UpsertMembershipAsync(clubId, Me, perms);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/directJoin</c> — same as
|
||||
@@ -1183,7 +1196,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var club = await clubs.GetByIdAsync(clubId);
|
||||
if (club is null) return NotFound();
|
||||
await clubs.UpsertMembershipAsync(clubId, Me, 0);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/leave</c> — caller leaves
|
||||
@@ -1198,7 +1211,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (club.CreatorPlayerId == Me)
|
||||
return BadRequest(new { error = "owner_must_transfer_first" });
|
||||
await clubs.RemoveMembershipAsync(clubId, Me);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/invite</c> — caller invites
|
||||
@@ -1220,7 +1233,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, 128); // Pending
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/invitemembers</c> — bulk
|
||||
@@ -1241,7 +1254,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
|
||||
foreach (var pid in ids)
|
||||
await clubs.UpsertMembershipAsync(clubId, pid, 128);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/acceptinvite</c> — caller
|
||||
@@ -1254,7 +1267,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (row is null) return NotFound();
|
||||
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_invite" });
|
||||
await clubs.UpsertMembershipAsync(clubId, Me, 0); // Member
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/declineinvite</c> — caller
|
||||
@@ -1264,7 +1277,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
public async Task<IActionResult> MemberDeclineInvite(long clubId)
|
||||
{
|
||||
await clubs.RemoveMembershipAsync(clubId, Me);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
public sealed class MemberTargetRequest
|
||||
@@ -1288,7 +1301,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (row is null) return NotFound();
|
||||
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_request" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, 0);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
public sealed class BulkTargetRequest
|
||||
@@ -1309,7 +1322,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (row is null || (row.Permissions & 128) == 0) continue;
|
||||
await clubs.UpsertMembershipAsync(clubId, pid, 0);
|
||||
}
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/denyrequest")]
|
||||
@@ -1320,7 +1333,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||
await clubs.RemoveMembershipAsync(clubId, target);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/denyrequests")]
|
||||
@@ -1331,7 +1344,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
|
||||
foreach (var pid in ids)
|
||||
await clubs.RemoveMembershipAsync(clubId, pid);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/remove")]
|
||||
@@ -1346,7 +1359,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (target == club.CreatorPlayerId)
|
||||
return BadRequest(new { error = "cannot_remove_owner" });
|
||||
await clubs.RemoveMembershipAsync(clubId, target);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/ban</c> — kick + prevent
|
||||
@@ -1364,7 +1377,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (target == club.CreatorPlayerId)
|
||||
return BadRequest(new { error = "cannot_ban_owner" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, 256); // Ban marker
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/unban")]
|
||||
@@ -1375,7 +1388,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||
await clubs.RemoveMembershipAsync(clubId, target);
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/changetype</c> — promote /
|
||||
@@ -1396,7 +1409,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (target == club.CreatorPlayerId)
|
||||
return BadRequest(new { error = "cannot_change_owner_role" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, ClubService.PermsFromMembershipType(newType));
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
// ── members/bulk + clubreporting ─────────────────────────────────
|
||||
@@ -1513,7 +1526,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
public async Task<IActionResult> AnnouncementDelete(long clubId, long announcementId)
|
||||
{
|
||||
if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>/announcements/club/{clubId}/{announcementId}</c> — EDIT
|
||||
@@ -1529,7 +1542,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var (title, body, imageName) = await ReadAnnouncementFieldsAsync();
|
||||
if (!await clubs.UpdateAnnouncementAsync(clubId, announcementId, Me, title, body, imageName))
|
||||
return Forbid();
|
||||
return Ok();
|
||||
return RecNetResults.Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/announcements/club/{clubId}</c> — CREATE an
|
||||
|
||||
@@ -223,7 +223,11 @@ public class LinkController(DorkNetDbContext db, ILogger<LinkController> logger)
|
||||
|
||||
payload.Uses++;
|
||||
// AutoRenewHours present ⇒ the code renews rather than ages out.
|
||||
if (req.ValidHours is > 0) payload.ExpiresAt = DateTime.UtcNow.AddHours(req.ValidHours.Value);
|
||||
// Clamped: this route is anonymous and the value is client-supplied,
|
||||
// so an unbounded renewal would let anyone holding the code extend
|
||||
// its life indefinitely past what the creator chose.
|
||||
if (req.ValidHours is > 0)
|
||||
payload.ExpiresAt = DateTime.UtcNow.AddHours(Math.Min(req.ValidHours.Value, 720));
|
||||
|
||||
var encoded = JsonSerializer.Serialize(payload);
|
||||
if (encoded.Length <= ValueLimit) row.Value = encoded;
|
||||
|
||||
@@ -41,6 +41,7 @@ public class GoToController(
|
||||
NotificationService notifications,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
ServerSettingsService serverSettings,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
ILogger<GoToController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("/goto/room/{roomName}")]
|
||||
@@ -1294,15 +1295,14 @@ public class GoToController(
|
||||
|
||||
private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room)
|
||||
{
|
||||
// Honors the global-friends toggle: with it on, every other player is
|
||||
// a "friend" and gets the presence ping (so the whole small server
|
||||
// sees each other's room moves).
|
||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, playerId);
|
||||
// Honors the global-friends toggle (every other player is a "friend"
|
||||
// when it is on). Goes through the shared fan-out so the frame
|
||||
// carries the player's real status visibility and the same
|
||||
// online/room state every other presence transition reports.
|
||||
logger.LogInformation(
|
||||
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)",
|
||||
playerId, room.RoomId, room.Name, friendIds.Count);
|
||||
if (friendIds.Count == 0) return;
|
||||
await notifications.PlayerEnteredRoom(friendIds, playerId, room);
|
||||
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName}",
|
||||
playerId, room.RoomId, room.Name);
|
||||
await presenceFanout.PublishCurrentAsync(playerId);
|
||||
}
|
||||
|
||||
private async Task<MatchmakingResponseDto> BuildResponseAsync(string roomName)
|
||||
|
||||
@@ -22,6 +22,7 @@ public class MatchController(
|
||||
PlayerPresenceService presence,
|
||||
PrivateInstanceService privateInstances,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
RoomService rooms,
|
||||
DorkNetDbContext db,
|
||||
ILogger<MatchController> logger) : ControllerBase
|
||||
@@ -67,6 +68,12 @@ public class MatchController(
|
||||
completed.Value.TargetRoom.Name,
|
||||
completed.Value.TargetRoom.RoomInstanceId,
|
||||
completed.Value.DeferPresenceCommit);
|
||||
// A deferred join (matchmake/dorm, private-instance hop)
|
||||
// commits HERE, not at matchmake time, so this is the only
|
||||
// point friends can learn about the move. Immediate commits
|
||||
// were already announced by /goto.
|
||||
if (completed.Value.DeferPresenceCommit)
|
||||
await presenceFanout.PublishCurrentAsync(pid);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -88,36 +95,48 @@ public class MatchController(
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>matchmake/none</c> — "leave room". The 2023 client's
|
||||
/// only caller is TryGoToOfflineRoom
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:12677-13279): it has already
|
||||
/// switched its own cached presence to the local offline instance
|
||||
/// (RoomInstanceId -1) before posting this, and it discards the reply
|
||||
/// (<c><TryGoToOfflineRoom>b__119_0</c> returns a constant).
|
||||
///
|
||||
/// So the player is now online in no room, and that is what presence must
|
||||
/// say. This used to re-commit the previous room (or the dorm) instead:
|
||||
/// the next heartbeat then answered with a room while the client held the
|
||||
/// offline instance, the client logged "presence heartbeat response
|
||||
/// indicates local presence is out-of-sync", cancelled its offline load
|
||||
/// ("OnPlayerPresenceUpdate is cancelling room load for -1") and chased the
|
||||
/// server's room — and friends kept seeing the room the player had left.
|
||||
///
|
||||
/// A join still pending is abandoned too: the client only goes offline
|
||||
/// after giving up on it (fallback chain after a Photon timeout), and
|
||||
/// echoing its target from the heartbeat would pull the player back.</summary>
|
||||
[HttpPost("/matchmake/none")]
|
||||
public async Task<IActionResult> MatchmakeNone()
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Unauthorized();
|
||||
|
||||
var previous = presence.GetRoom(pid);
|
||||
var pending = joinTimeouts.GetPending(pid);
|
||||
if (pending is { DeferPresenceCommit: true })
|
||||
{
|
||||
presence.MarkActive(pid);
|
||||
logger.LogInformation(
|
||||
"[matchmake-none] player={Player} has deferred pending room={Room} instance={Instance}; not committing presence",
|
||||
pid,
|
||||
pending.Value.TargetRoom.RoomId,
|
||||
pending.Value.TargetRoom.RoomInstanceId);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 0,
|
||||
RoomInstance = pending.Value.TargetRoom,
|
||||
});
|
||||
}
|
||||
|
||||
var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid);
|
||||
presence.SetRoom(pid, current);
|
||||
joinTimeouts.Cancel(pid, "matchmake-none");
|
||||
presence.SetNoRoom(pid);
|
||||
presence.MarkActive(pid);
|
||||
logger.LogInformation("[matchmake-none] player={Player}", pid);
|
||||
presence.EndBootDormGrace(pid);
|
||||
logger.LogInformation(
|
||||
"[matchmake-none] player={Player} left room={Room} instance={Instance} abandonedPending={Pending}; online with no room",
|
||||
pid, previous?.RoomId, previous?.RoomInstanceId, pending?.TargetRoom.RoomInstanceId);
|
||||
|
||||
await presenceFanout.PublishCurrentAsync(pid);
|
||||
|
||||
// The client ignores this body; keep a well-formed instance in it so
|
||||
// the shared MatchmakingResponse reader never sees a null object.
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 0,
|
||||
RoomInstance = current,
|
||||
RoomInstance = previous ?? pending?.TargetRoom ?? await BuildDormRoomInstanceAsync(pid),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -396,14 +415,19 @@ public class MatchController(
|
||||
.Select(p => new { p.Id, p.OwnerPlayerId })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// forceChange only lets a host reclaim a code from one of their OWN
|
||||
// instances; another player's code is never stolen. On refusal the
|
||||
// client just re-reads whatever code we echo back, so we fall
|
||||
// through to the read path instead of erroring.
|
||||
var blocked = clash is not null
|
||||
&& (clash.OwnerPlayerId != pid || forceChange != true);
|
||||
|
||||
var row = await db.PrivateInstances.FirstOrDefaultAsync(p => p.Id == instanceId);
|
||||
|
||||
// Only the instance's owner may set its code (same guard as
|
||||
// MarkPrivate), otherwise any player could plant or relocate a
|
||||
// code onto someone else's instance. forceChange only lets a host
|
||||
// reclaim a code from one of their OWN instances; another
|
||||
// player's code is never stolen. On refusal the client just
|
||||
// re-reads whatever code we echo back, so we fall through to the
|
||||
// read path instead of erroring.
|
||||
var blocked = row is null || row.OwnerPlayerId != pid
|
||||
|| (clash is not null
|
||||
&& (clash.OwnerPlayerId != pid || forceChange != true));
|
||||
|
||||
if (!blocked && row is not null)
|
||||
{
|
||||
if (clash is not null)
|
||||
|
||||
@@ -24,6 +24,7 @@ public class MatchPlayerController(
|
||||
RoomService rooms,
|
||||
PrivateInstanceService privateInstances,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
IConfiguration config,
|
||||
DorkNetDbContext db,
|
||||
ILogger<MatchPlayerController> log) : ControllerBase
|
||||
@@ -87,13 +88,20 @@ public class MatchPlayerController(
|
||||
/// <summary>
|
||||
/// POST /player/logout — fires when the client gives up on a session
|
||||
/// (e.g. Photon connection couldn't be established, client aborts).
|
||||
/// Clear the cached presence so the next /goto starts fresh.
|
||||
/// Clear the cached presence so the next /goto starts fresh, and tell
|
||||
/// friends right away — otherwise they keep seeing the player in their
|
||||
/// last room until a bulk poll happens to notice.
|
||||
/// </summary>
|
||||
[HttpPost("/player/logout")]
|
||||
public IActionResult PlayerLogout()
|
||||
public async Task<IActionResult> PlayerLogout()
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (playerId != 0) presence.Clear(playerId);
|
||||
if (playerId != 0)
|
||||
{
|
||||
joinTimeouts.Cancel(playerId, "logout");
|
||||
presence.Clear(playerId);
|
||||
await presenceFanout.PublishOfflineAsync(playerId);
|
||||
}
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
@@ -178,7 +186,15 @@ public class MatchPlayerController(
|
||||
public async Task<ActionResult<PresenceDto>> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value)
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (value is int v) await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
|
||||
if (value is int v)
|
||||
{
|
||||
await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
|
||||
// Friends only. The client does not apply this response to its own
|
||||
// cached presence, and a PresenceUpdate about the local player runs
|
||||
// the same apply-and-rejoin path as an out-of-sync heartbeat
|
||||
// (Matchmaking.EGPABEDPEJO -> FLAOOBNJAAD, Matchmaking.txt:6917), so no self push.
|
||||
await presenceFanout.PublishCurrentAsync(playerId);
|
||||
}
|
||||
var room = await GetPresenceRoomForResponseAsync(playerId);
|
||||
return Ok(new PresenceDto
|
||||
{
|
||||
@@ -212,7 +228,7 @@ public class MatchPlayerController(
|
||||
// back in the response and was then dropped, so the setting reverted on the
|
||||
// next heartbeat and never survived a restart. They live in PlayerSettings
|
||||
// (no schema change needed) and are read back wherever presence is built.
|
||||
private const string PrefStatusVisibility = "pref:statusVisibility";
|
||||
private const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
|
||||
private const string PrefVrMovementMode = "pref:vrMovementMode";
|
||||
private const string PrefAvoidJuniors = "pref:avoidJuniors";
|
||||
|
||||
@@ -395,6 +411,21 @@ public class MatchPlayerController(
|
||||
|
||||
if (room is null)
|
||||
{
|
||||
// The player went to the offline room (matchmake/none). Their
|
||||
// client now holds RoomInstanceId -1, which the heartbeat compare
|
||||
// treats as "no room" (HCHDEHIGEBE.GIICPDHJOOH, sign bit of the id;
|
||||
// RecNet.Runtime/RecNet/Matchmaking.txt:18415-18437) and is only in
|
||||
// sync with a null roomInstance. The dorm fallback below would
|
||||
// read as out-of-sync and drag the player out of the offline room.
|
||||
if (presence.IsInNoRoom(playerId))
|
||||
{
|
||||
presence.TouchNoRoom(playerId);
|
||||
log.LogInformation(
|
||||
"[player-heartbeat] player={PlayerId} online in no room (left via matchmake/none); returning null roomInstance",
|
||||
playerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (presence.IsBootDormGraceActive(playerId))
|
||||
{
|
||||
log.LogInformation(
|
||||
@@ -426,6 +457,7 @@ public class MatchPlayerController(
|
||||
playerId, fallback.RoomId, fallback.DataBlob);
|
||||
presence.SetRoom(playerId, fallback);
|
||||
presence.MarkActive(playerId);
|
||||
await presenceFanout.PublishCurrentAsync(playerId);
|
||||
return fallback;
|
||||
}
|
||||
log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Match;
|
||||
@@ -36,10 +38,19 @@ namespace DorkNet.Server.Controllers.Match;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PlayerPresenceController(
|
||||
PlayerPresenceService presence) : ControllerBase
|
||||
PlayerPresenceService presence,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
DorkNetDbContext db,
|
||||
ServerSettingsService settings) : ControllerBase
|
||||
{
|
||||
/// <summary>PlayerSettings key written by <c>PUT /player/statusvisibility</c>
|
||||
/// (MatchPlayerController.PrefStatusVisibility — same literal; the
|
||||
/// round-trip is pinned by a regression test).</summary>
|
||||
public const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
|
||||
|
||||
[HttpGet("/player")]
|
||||
public ActionResult<List<PlayerPresenceDto>> GetPresences()
|
||||
public async Task<ActionResult<List<PlayerPresenceDto>>> GetPresences()
|
||||
{
|
||||
// The watch sends two URL flavours for this endpoint:
|
||||
// 1. Bulk: <c>/player?{id1}&{id2}&{id3}</c> — bare keys,
|
||||
@@ -81,31 +92,39 @@ public class PlayerPresenceController(
|
||||
// mid-coroutine. Only redact for OTHER players where the
|
||||
// privacy concern actually applies.
|
||||
var callerId = this.CurrentPlayerId();
|
||||
var callerRoom = callerId is long cid ? presence.GetRoom(cid) : null;
|
||||
var callerRoom = callerId is long cid ? SelfRoom(cid) : null;
|
||||
var visibility = await LoadVisibilityAsync(ids);
|
||||
var hiddenFromCaller = await ResolveHiddenAsync(callerId, ids, visibility);
|
||||
var result = ids.Select(id =>
|
||||
{
|
||||
var room = presence.GetRoom(id);
|
||||
// Other players need both a room presence and an active
|
||||
// live signal. Use the recent-heartbeat marker as the
|
||||
// truth source: SignalR can drop while the player is still
|
||||
// heartbeating, or linger after the game is gone. Presence
|
||||
// keys intentionally live for minutes to survive long room
|
||||
// loads, so using them alone leaves friends stuck online
|
||||
// after a graceful disconnect.
|
||||
var isSelf = callerId == id;
|
||||
var active = isSelf || presence.IsRecentlyActive(id);
|
||||
var isOnline = room is not null && active;
|
||||
// The caller's own row must say exactly what their heartbeat
|
||||
// says (pending-join target, or null in the offline room): the
|
||||
// client compares its cached self presence field by field
|
||||
// (HCHDEHIGEBE.DCIJHIPJHOB) and treats any difference as
|
||||
// out-of-sync.
|
||||
var room = isSelf ? callerRoom : presence.GetRoom(id);
|
||||
// Online = a live signal (recent heartbeat, or a SignalR
|
||||
// connection), NOT "has a room". Presence keys live for minutes
|
||||
// to survive long room loads, so a room alone would leave friends
|
||||
// stuck online after the game is gone; and a player in the offline
|
||||
// room after matchmake/none has no room but is very much online
|
||||
// (the 2023 DTO carries isOnline on its own; see
|
||||
// NotificationService.BuildPresenceUpdatePayload).
|
||||
// A player who chose "appear offline" (or friends/favorites
|
||||
// only, when the caller doesn't qualify) is reported exactly
|
||||
// like a genuinely offline player: no room, isOnline=false.
|
||||
var isOnline = (isSelf || presenceFanout.IsOnline(id)) && !hiddenFromCaller.Contains(id);
|
||||
if (!isOnline) room = null;
|
||||
return new PlayerPresenceDto
|
||||
{
|
||||
PlayerId = (int)id,
|
||||
// StatusVisibility is the player's *preference* for who may
|
||||
// see them online. The enum is Everyone=0 / FriendsOnly=1 /
|
||||
// Nobody=2 — so the previous value 1 (mislabeled "Visible")
|
||||
// actually meant FriendsOnly, and anyone who wasn't your
|
||||
// friend saw you as OFFLINE. On a private server everyone
|
||||
// should see everyone, so report Everyone (0). IsOnline=false
|
||||
// still gates the "Online" badge for offline players.
|
||||
StatusVisibility = 0, // Everyone
|
||||
// The player's stored online-status visibility. Self MUST
|
||||
// see its own stored value: the profile dialog's current
|
||||
// selection is read from the cached self presence, and a
|
||||
// hard-coded 0 here snapped it back to Public after every
|
||||
// presence refresh.
|
||||
StatusVisibility = visibility.GetValueOrDefault(id, PresenceVisibility.Public),
|
||||
DeviceClass = 0, // Desktop / unknown — not VR
|
||||
VrMovementMode = 0,
|
||||
// Full instance details are safe, and necessary, when the
|
||||
@@ -124,6 +143,58 @@ public class PlayerPresenceController(
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>The caller's own room as their heartbeat reports it.</summary>
|
||||
private RoomInstanceDto? SelfRoom(long playerId)
|
||||
{
|
||||
if (joinTimeouts.GetPending(playerId) is { DeferPresenceCommit: true } pending)
|
||||
return pending.TargetRoom;
|
||||
if (presence.IsInNoRoom(playerId)) return null;
|
||||
return presence.GetRoom(playerId);
|
||||
}
|
||||
|
||||
private Task<Dictionary<long, int>> LoadVisibilityAsync(HashSet<long> ids) =>
|
||||
PresenceBroadcastService.GetStatusVisibilitiesAsync(db, ids);
|
||||
|
||||
/// <summary>Ids whose online status the caller may not see. The caller
|
||||
/// always sees itself; anonymous callers only see Public players.</summary>
|
||||
private async Task<HashSet<long>> ResolveHiddenAsync(
|
||||
long? callerId, HashSet<long> ids, Dictionary<long, int> visibility)
|
||||
{
|
||||
var hidden = new HashSet<long>();
|
||||
var restricted = visibility
|
||||
.Where(kv => kv.Value != PresenceVisibility.Public && kv.Key != callerId)
|
||||
.ToList();
|
||||
if (restricted.Count == 0) return hidden;
|
||||
|
||||
if (callerId is not long me)
|
||||
{
|
||||
foreach (var kv in restricted) hidden.Add(kv.Key);
|
||||
return hidden;
|
||||
}
|
||||
|
||||
HashSet<long>? friends = null;
|
||||
HashSet<long>? favoritedMe = null;
|
||||
foreach (var (target, vis) in restricted)
|
||||
{
|
||||
if (vis == PresenceVisibility.Offline) { hidden.Add(target); continue; }
|
||||
|
||||
friends ??= (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, me)).ToHashSet();
|
||||
if (!friends.Contains(target)) { hidden.Add(target); continue; }
|
||||
|
||||
if (vis == PresenceVisibility.FavoriteFriendsOnly)
|
||||
{
|
||||
// Favorited lives on the favoriter's own row
|
||||
// (RequesterId = favoriter), as RelationshipsController.BuildMerged reads it.
|
||||
favoritedMe ??= (await db.Relationships
|
||||
.Where(r => r.TargetId == me && r.Favorited && ids.Contains(r.RequesterId))
|
||||
.Select(r => r.RequesterId)
|
||||
.ToListAsync()).ToHashSet();
|
||||
if (!favoritedMe.Contains(target)) hidden.Add(target);
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
private static bool ShouldExposeFullRoom(
|
||||
long? callerId,
|
||||
RoomInstanceDto? callerRoom,
|
||||
@@ -151,8 +222,8 @@ public class PlayerPresenceDto
|
||||
[JsonPropertyName("playerId")]
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
/// <summary>PlayerStatusVisibility enum (int):
|
||||
/// 0 = Hidden / Offline-appearing, 1 = Visible, 2 = FriendsOnly.</summary>
|
||||
/// <summary>Online-status visibility enum (int, 2023 client):
|
||||
/// 0 = Public, 1 = FriendsOnly, 2 = FavoriteFriendsOnly, 3 = Offline.</summary>
|
||||
[JsonPropertyName("statusVisibility")]
|
||||
public int StatusVisibility { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using DorkNet.Models.Auth;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Bodies for the 2023 client's <c>RecNetResult</c> writes.
|
||||
///
|
||||
/// A lot of the client's fire-and-forget writes look status-only from their
|
||||
/// signature (they return <c>LDGADANDBIO</c>, the untyped promise), but they
|
||||
/// are not: they go through <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
|
||||
/// (<c>RecNet.Runtime/BFDGEKKNAPB.txt:353</c>, "ExpandRecNetResult"), which
|
||||
/// deserialises the body as <c>DJMHAFPGLLN</c> = <c>{Success, Error}</c>
|
||||
/// (formatter <c>RecNet.Runtime/LMCCNLLHLCJ.txt:191-218</c> registers
|
||||
/// <c>Success</c>/<c>success</c> and <c>Error</c>/<c>error</c>) and then runs
|
||||
/// the <c><ExpandRecNetResult>b__2_0</c> lambda
|
||||
/// (<c>RecNet.Runtime/BFDGEKKNAPB_NestedType___c.txt:61</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item>body null (an empty 200 or <c>null</c> — <c>COLNKALCBOK.txt</c>
|
||||
/// JIKCMNMKNPH returns default for both) → NullReferenceException, the write
|
||||
/// shows "Failed to …" although the server applied it;</item>
|
||||
/// <item><c>Success</c> missing/false → <c>Promise.Rejected(Error)</c>; with no
|
||||
/// <c>Error</c> key that throws <c>ArgumentException: Error message cannot be
|
||||
/// null</c> (seen in Player.log as an unobserved task exception).</item>
|
||||
/// </list>
|
||||
/// So every such endpoint must answer <c>{"success":true,"error":""}</c> on
|
||||
/// success, and carry a non-null <c>error</c> on a 200 failure.
|
||||
/// </summary>
|
||||
public static class RecNetResults
|
||||
{
|
||||
public static IActionResult Ok() =>
|
||||
new OkObjectResult(new RecNetResult { Success = true, Error = string.Empty });
|
||||
|
||||
public static IActionResult Fail(string error) =>
|
||||
new OkObjectResult(new RecNetResult { Success = false, Error = error });
|
||||
}
|
||||
@@ -65,6 +65,12 @@ public class StorageController(
|
||||
|
||||
[HttpPost("/upload", Order = -2000)]
|
||||
[Consumes("multipart/form-data")]
|
||||
// Kestrel's default 30,000,000-byte body cap would 413 the room save of
|
||||
// a big circuits room now that the served CircuitsV2 chip limit is 30000
|
||||
// (docs/recroom-2023-circuitsv2-limits.md). 100 MB matches the admin
|
||||
// import endpoints and Cloudflare's own per-request ceiling.
|
||||
[RequestSizeLimit(100_000_000)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 100_000_000)]
|
||||
public async Task<IActionResult> Upload()
|
||||
{
|
||||
// Form parsing — ASP.NET Core deserialises the multipart body
|
||||
|
||||
@@ -15,4 +15,21 @@ public class StringsController : ControllerBase
|
||||
[HttpGet("/strings/v1/{locale}")]
|
||||
public ActionResult<Dictionary<string, string>> GetLocale(string locale)
|
||||
=> Ok(new Dictionary<string, string>());
|
||||
|
||||
/// <summary>The 2023 client's server-string download (RecNet.Runtime/NHILNDLLLHH.txt).
|
||||
/// Two GETs share the path <c>/strings</c> on the strings host:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>?locale=&scopePrefix=client.&eTag=</c> (FMLBOAOBKPM, :531) →
|
||||
/// <c>Dictionary<string, Dictionary<long,string>></c>, scope → id → text;</item>
|
||||
/// <item><c>?locale=&scope=client.{name}&eTag=</c> (BNEELKAKCEN, :3) →
|
||||
/// <c>Dictionary<long,string></c>.</item>
|
||||
/// </list>
|
||||
/// The localisation service stores the first answer
|
||||
/// (RecRoom.Localization.Runtime/IBAJDNIMADI.txt BICKHBAJPIG) and its scope lookup
|
||||
/// (NHLKEJLGGIE) null-derefs when the download never succeeded but returns an empty
|
||||
/// table for a scope the server did not send. DorkNet keeps no server-side string
|
||||
/// overrides, so the truthful answer is an empty JSON object for either query —
|
||||
/// the lookups then fall back to the bundled text. The path used to 404.</summary>
|
||||
[HttpGet("/strings")]
|
||||
public IActionResult GetServerStrings() => Content("{}", "application/json");
|
||||
}
|
||||
|
||||
@@ -116,6 +116,29 @@ public class PlayerEntity
|
||||
[MaxLength(32)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
// ── 2023 profile fields ──────────────────────────────────────────────
|
||||
// Written by PUT account/me/{emoji,bannerimage,personalpronouns,
|
||||
// identityflags} and read back on every account DTO (account/me,
|
||||
// account/{id}, account/bulk, search, AccountUpdate push) under the
|
||||
// keys displayEmoji / bannerImage / personalPronouns / identityFlags.
|
||||
|
||||
/// <summary>Emoji shown beside the display name ("" = none).</summary>
|
||||
[MaxLength(32)]
|
||||
public string DisplayEmoji { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Profile banner image name (a storage image the player
|
||||
/// already uploaded, e.g. one of their photos). Null = no banner.</summary>
|
||||
[MaxLength(256)]
|
||||
public string? BannerImageName { get; set; }
|
||||
|
||||
/// <summary>Pronoun bitmask (client flags enum: SheHer=1, HeHim=2,
|
||||
/// TheyThem=4, ZeHir=8, ZeZir=16, XeXem=32).</summary>
|
||||
public int PersonalPronouns { get; set; } = 0;
|
||||
|
||||
/// <summary>Identity-flag bitmask (client flags enum: LGBTQIA=1,
|
||||
/// Transgender=2, Bisexual=4, …).</summary>
|
||||
public int IdentityFlags { get; set; } = 0;
|
||||
|
||||
// Navigation
|
||||
public AvatarEntity? Avatar { get; set; }
|
||||
public List<PlayerSettingEntity> Settings { get; set; } = [];
|
||||
|
||||
@@ -108,5 +108,14 @@ public class ServerSettingsEntity
|
||||
/// admins curate the base-room list from the SPA without a redeploy.</summary>
|
||||
public string BaseRoomNamesJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Admin-managed CircuitsV2 creation limits, served to the
|
||||
/// client as the <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. The
|
||||
/// network entry carries the per-frame RPC and byte budgets a circuit
|
||||
/// may spend; DorkNet ships them effectively unlimited, where retail
|
||||
/// shipped 5 RPCs/frame and 1024 bytes/60 frames. Stored as JSON so
|
||||
/// new limit keys don't need a table migration.
|
||||
/// See <see cref="Services.CircuitsV2LimitSettings"/>.</summary>
|
||||
public string CircuitsV2LimitsJson { get; set; } = string.Empty;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`,
|
||||
|
||||
| Verb | URL | Status | Handler |
|
||||
|---|---|---|---|
|
||||
| GET | `api/gameconfigs/v1/all` | STUB | `GameConfigurationsController.GameConfigsAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Returns `[]`. |
|
||||
| GET | `api/gameconfigs/v1/all` | REAL | `GameConfigurationsController.GetAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Serves the admin-managed key/value set from `ServerSettingsService.GetGameConfigurationsAsync`: Rec Center doors, the typed keys confirmed in the 2020.12 decomp, the `UGC.CircuitsV2.Limit.*` family (see `docs/recroom-2023-circuitsv2-limits.md`), and `PlayMenuCarouselOrdering_1` (the 2023 play-menu carousel priority map; without it the client logs "Could not find GameConfig for PlayMenuCarouselOrdering_1" and keeps prefab order). |
|
||||
|
||||
### `api/groups/`
|
||||
|
||||
@@ -391,6 +391,18 @@ These all live on `match.rec.net` (locally: `match.localhost`).
|
||||
| POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it |
|
||||
| POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case |
|
||||
|
||||
2023 additions on the auth / accounts / strings hosts:
|
||||
|
||||
| Verb | URL | Status | Handler |
|
||||
|---|---|---|---|
|
||||
| POST | `connect/token` `grant_type=urn:ietf:params:oauth:grant-type:device_code` | REAL | `Auth.AuthController.Token` via `DeviceAuthorizations.RedeemAsync` — 400 `authorization_pending` until approved, then the approver's token pair; single use |
|
||||
| POST | `connect/token` `grant_type=login_token` | REAL | `Auth.AuthController.Token` — redeems an `account/me/createlogintoken` token for its own `account_id`; single use |
|
||||
| POST | `account/me/createlogintoken` | REAL | `Accounts.AccountsController.CreateLoginToken` — bare JSON string (the client reads `FGLDKEJLAKB<String>`) |
|
||||
| POST | `account/me/remoteauth` | REAL | `Accounts.AccountsController.RemoteAuth` — approves a device `user_code` for the caller; `{success,error}` RecNetResult |
|
||||
| POST | `account/bulk/email`, `account/bulk/phone` | REAL | `Accounts.AccountsController.Bulk` — contact lookup, repeated `email` / `phone` form fields, account list |
|
||||
| GET | `strings?locale&scopePrefix|scope&eTag` | REAL | `Strings.StringsController.GetServerStrings` — empty table (no server-side string overrides) instead of a 404 |
|
||||
| GET | `api/incentivizedreferrals/referrals?take&continuationToken` | REAL | `IncentivizedReferralsController.Referrals` — `{Referrals:[{InviteeAccountId,CreatedAt,VerifiedAt}],ContinuationToken}` |
|
||||
|
||||
### CDN host (`cdn.rec.net` / `storage` / `data`)
|
||||
|
||||
Served by `Controllers/Cdn/CdnController` (catch-all that wins routing on every host).
|
||||
|
||||
@@ -42,8 +42,7 @@ public class NotifyHub(
|
||||
OnlinePresenceService presence,
|
||||
PlayerPresenceService playerRooms,
|
||||
NotificationService notifications,
|
||||
DorkNetDbContext db,
|
||||
ServerSettingsService serverSettings,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
ILogger<NotifyHub> logger) : Hub
|
||||
{
|
||||
/// <summary>SignalR group name for a given player id. Use this
|
||||
@@ -108,18 +107,11 @@ public class NotifyHub(
|
||||
// friend's list for the rest of the session even though they were
|
||||
// still playing.
|
||||
//
|
||||
// Only push when a room is already known. The presence payload
|
||||
// derives isOnline from roomInstance being non-null, so a push with
|
||||
// no room reads as OFFLINE — worse than staying quiet. On a fresh
|
||||
// boot there is no room yet and the /goto that follows sends the
|
||||
// real presence anyway; this branch is for reconnects, where the
|
||||
// cached room is exactly what friends need to see again.
|
||||
if (cameOnline && playerRooms.GetRoom(id) is { } room)
|
||||
{
|
||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, id);
|
||||
if (friendIds.Count > 0)
|
||||
await notifications.PlayerCameOnline(friendIds, id, room);
|
||||
}
|
||||
// isOnline is sent explicitly now, so this no longer needs a
|
||||
// known room: a player who connects before matchmaking, or who is
|
||||
// sitting in the offline room, is online with roomInstance null.
|
||||
if (cameOnline)
|
||||
await presenceFanout.PublishCurrentAsync(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -139,9 +131,17 @@ public class NotifyHub(
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupForPlayer(id));
|
||||
if (wentOffline)
|
||||
{
|
||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, id);
|
||||
if (friendIds.Count > 0)
|
||||
await notifications.PlayerWentOffline(friendIds, id);
|
||||
// The socket can drop under a live session (the server or a
|
||||
// proxy closes it; the client logs "RecNet HubConnection error:
|
||||
// ... TCP Stream closed unexpectedly by the remote server" and
|
||||
// reconnects by itself). While the player is still
|
||||
// heartbeating, wait one activity window before calling them
|
||||
// offline; a real exit either logs out (immediate offline push)
|
||||
// or stops heartbeating and is caught by the deferred check.
|
||||
if (playerRooms.IsRecentlyActive(id))
|
||||
presenceFanout.ScheduleOfflineCheck(id);
|
||||
else
|
||||
await presenceFanout.PublishOfflineAsync(id);
|
||||
}
|
||||
logger.LogInformation(
|
||||
"[hub] disconnected player={PlayerId} conn={ConnectionId} wentOffline={WentOffline} error={Error}",
|
||||
|
||||
@@ -11,19 +11,25 @@ namespace DorkNet.Server.Migrations
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Defaults must match the entity contract (TestCaseEntity inits
|
||||
// CommentsJson to "[]", ClubEntity inits ClubChatEnabled to true):
|
||||
// existing rows get the column default, and "" is not valid JSON
|
||||
// while false silently muted every pre-existing club's chat. The
|
||||
// ClubChatDefaultRepair migration fixes databases that applied
|
||||
// this migration before the defaults were corrected.
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CommentsJson",
|
||||
table: "TestCases",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
defaultValue: "[]");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ClubChatEnabled",
|
||||
table: "Clubs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MinLevel",
|
||||
|
||||
+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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3658 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
[DbContext(typeof(DorkNetDbContext))]
|
||||
[Migration("20260802190504_CircuitsV2Limits")]
|
||||
partial class CircuitsV2Limits
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AdminPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AdminPlayerId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("TargetType", "TargetId");
|
||||
|
||||
b.ToTable("AdminActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomAvatarItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquippedItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FaceFeatures")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("HairColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InventoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelections")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelectionsV2")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SavedOutfitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SkinColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Avatars");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReadAt");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.ToTable("BugReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBuiltIn")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CharadesWordLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ModerationState")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey", "SentAt");
|
||||
|
||||
b.ToTable("ChatMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LastReadMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SnoozeUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ThreadKey", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreadMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CheeredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPhotoId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TargetPhotoId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("TargetRoomId");
|
||||
|
||||
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Cheers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "Slot")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAdditionalImages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AuthorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "CreatedAt");
|
||||
|
||||
b.ToTable("ClubAnnouncements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AnnouncementId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("AnnouncementId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAnnouncementReads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CategoryTagId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryTagId");
|
||||
|
||||
b.HasIndex("ClubId", "CategoryTagId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubCategoryAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("ClubCategoryTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ClubChatEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ClubType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ClubhouseRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRRO")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Joinability")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Clubs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ClubId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ApproveMember")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BanUnban")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CreateEvent")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditDetails")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditPermissionSettings")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MembershipType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PostAnnouncement")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "MembershipType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SubscribedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId");
|
||||
|
||||
b.HasIndex("PlayerId", "ClubId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AssignedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CohortKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Variant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CohortKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CohortAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Json")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CommunityBoardRows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxRedemptions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RedemptionCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RewardAmount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardCurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RewardItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Coupons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CouponId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CouponId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CouponRedemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CurrencyType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssetName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("BaseAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PurchaseCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsFeatured");
|
||||
|
||||
b.HasIndex("IsPublic");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CustomAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomAvatarItemId");
|
||||
|
||||
b.HasIndex("PlayerId", "CustomAvatarItemId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItemOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
|
||||
{
|
||||
b.Property<long>("PlayerId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("PlayerId");
|
||||
|
||||
b.ToTable("DormStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("GrantedItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Offer1ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer2ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer3ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SelectedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SelectedGiftDropId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "SelectedAt");
|
||||
|
||||
b.ToTable("GameRewardSelections");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActivityLevelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlayerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Region")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Region");
|
||||
|
||||
b.ToTable("GameSessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AvatarItemDescOrHairDyeDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("AvatarItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConsumableItemDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Consumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ConsumedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Currency")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EquipmentModificationGuid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquipmentPrefabName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftRarity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsGifted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsValid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageMaterial")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageVariant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SourceStoreItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsCurrentPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Xp")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "Consumed");
|
||||
|
||||
b.ToTable("GiftPackages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("CreationRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CreatorPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentVersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("FirstPublishedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GeneralPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAgInvention")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("NumPlayersHaveUsedInRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Permission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SpawnCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CheerCount");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.ToTable("Inventions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("InstantiationCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("InventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LightsCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InventionId", "VersionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InventionVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("BannedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("BannedByAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Cidr")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Cidr");
|
||||
|
||||
b.ToTable("IpBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ItemKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemKey", "ItemType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ItemWishlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EarnedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "EventKey");
|
||||
|
||||
b.ToTable("Keepsakes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
|
||||
{
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Channel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LowerIsBetter")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ValueFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("RoomId", "Channel");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("LeaderboardChannelMeta");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StatChannel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Value")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "StatChannel")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId", "StatChannel", "Value");
|
||||
|
||||
b.ToTable("LeaderboardStats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Context")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlatformMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("LoadingScreenTips");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "SentAt");
|
||||
|
||||
b.HasIndex("SenderPlayerId", "SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAll")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAnnouncements")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowEventInvite")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowFriendRequest")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowMessage")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NotificationPrefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ClearedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ObjectiveProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PendingDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TaggedPlayerIdsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploaderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ViewCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("UploaderPlayerId");
|
||||
|
||||
b.ToTable("Photos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlatformIgnores");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeviceId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PlayerDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Elo")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GameMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Losses")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MatchesPlayed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Wins")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GameMode", "Elo");
|
||||
|
||||
b.HasIndex("PlayerId", "GameMode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerElo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CanReceiveInvites")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedFromDeviceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsCommunityTeam")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeveloper")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsJunior")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LastPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastPlatformId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProfileImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Reputation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("XP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Capacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("StartsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("StartsAt");
|
||||
|
||||
b.ToTable("PlayerEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("EventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Response")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("EventId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerEventResponses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemSlug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerInventory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCurated")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsCurated");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Playlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Cheered")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlaylistId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlaylistInteractions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlaylistId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("PlaylistId", "OrderIndex");
|
||||
|
||||
b.ToTable("PlaylistRooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DataBlob")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("OwnerPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRegion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhotonRoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId");
|
||||
|
||||
b.ToTable("PrivateInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
|
||||
{
|
||||
b.Property<long>("PrivateInstanceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("InvitedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LatestInviteMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PrivateInstanceId", "PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PrivateInstanceInvitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PushTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Ignored")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Muted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RequesterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RequesterId", "TargetId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ResolutionNote")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ResolverAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetEventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.HasIndex("ResolvedAt");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.ToTable("Reports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BannedPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "BannedPlayerId");
|
||||
|
||||
b.ToTable("RoomBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomBookmarks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("CurrencyId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomConsumables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ConcurrencyCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomConsumableId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomConsumableId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomConsumableId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomConsumableOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomCurrencyId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomCurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DailyLimit")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomCurrencies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Amount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.ToTable("RoomCurrencyPurchaseOffers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Bytes")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ReferencedFilenamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploadedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomDataBlobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowNewUsers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CloningAllowed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomRoomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DisableMicAutoMute")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("HiddenFromBrowse")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("HotScore")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAGRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDormRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsStudioRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LoadScreensJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LocationReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayerCalculationMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<string>("PromoExternalContentJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PromoImagesJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RoomWarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioSessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsMobile")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsVRLow")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("HotScore");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Rooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(174)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.ToTable("RoomKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PaidPrice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("PurchasedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomKeyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomKeyId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomKeyPurchases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Accepted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("GrantedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("GrantedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "Role")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CanMatchmakeInto")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DataModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSandbox")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomSceneLocationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StudioAssetBundleNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("StudioSubRoomDataSaveId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioUnityAssetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.HasIndex("RoomId", "OrderIndex")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomScenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("FirstVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "LastVisitAt");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomVisits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChestsOpened")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CompletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CustomGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("HealthPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsInAir")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ShieldPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WalkGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompletedAt");
|
||||
|
||||
b.ToTable("RoyaleMatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("MatchId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("MatchId", "PlayerId");
|
||||
|
||||
b.ToTable("RoyaleMatchPlayers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CurrentLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NextLevelAcornReward")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("NextLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RankIdx")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RankName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalXP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoyalePlayerProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllAvatarItemsOwned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BaseRoomNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CharadesSlotBindingsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CircuitsV2LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiscoveredGameConfigsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("GlobalFriendsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlayMenuTagsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("ProfanityFilterDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecCenterDoorsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("RoomBlobVersionClampDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SignupsDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WeeklyChallengeRewardJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("WeeklyChallengesCompletedRequired")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("WeeklyChallengesJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Descriptor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("RedeemedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SignupCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("AvailableUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsLimitedTime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Storefront")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Storefront");
|
||||
|
||||
b.HasIndex("IsActive", "Category");
|
||||
|
||||
b.ToTable("StoreItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SubscriberPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Pk")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssignedPlayerIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AssignedPlayerNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraBugUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinNumAssignedPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<uint?>("TestPassId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Pk");
|
||||
|
||||
b.HasIndex("Id")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("TestPassId");
|
||||
|
||||
b.ToTable("TestCases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
|
||||
{
|
||||
b.Property<uint>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("EndDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("WasManuallyClosed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TestPasses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("UgcPurchasables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithOne("Avatar")
|
||||
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithMany("Settings")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("RequesterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Requester");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Navigation("Avatar");
|
||||
|
||||
b.Navigation("Relationships");
|
||||
|
||||
b.Navigation("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CircuitsV2Limits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CircuitsV2LimitsJson",
|
||||
table: "ServerSettings",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CircuitsV2LimitsJson",
|
||||
table: "ServerSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3673
@@ -0,0 +1,3673 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
[DbContext(typeof(DorkNetDbContext))]
|
||||
[Migration("20261015110121_PlayerProfile2023Fields")]
|
||||
partial class PlayerProfile2023Fields
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AdminPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AdminPlayerId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("TargetType", "TargetId");
|
||||
|
||||
b.ToTable("AdminActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomAvatarItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquippedItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FaceFeatures")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("HairColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InventoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelections")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelectionsV2")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SavedOutfitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SkinColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Avatars");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReadAt");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.ToTable("BugReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBuiltIn")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CharadesWordLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ModerationState")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey", "SentAt");
|
||||
|
||||
b.ToTable("ChatMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LastReadMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SnoozeUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ThreadKey", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreadMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CheeredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPhotoId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TargetPhotoId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("TargetRoomId");
|
||||
|
||||
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Cheers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "Slot")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAdditionalImages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AuthorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "CreatedAt");
|
||||
|
||||
b.ToTable("ClubAnnouncements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AnnouncementId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("AnnouncementId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAnnouncementReads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CategoryTagId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryTagId");
|
||||
|
||||
b.HasIndex("ClubId", "CategoryTagId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubCategoryAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("ClubCategoryTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ClubChatEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ClubType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ClubhouseRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRRO")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Joinability")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Clubs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ClubId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ApproveMember")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BanUnban")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CreateEvent")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditDetails")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditPermissionSettings")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MembershipType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PostAnnouncement")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "MembershipType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SubscribedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId");
|
||||
|
||||
b.HasIndex("PlayerId", "ClubId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AssignedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CohortKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Variant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CohortKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CohortAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Json")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CommunityBoardRows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxRedemptions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RedemptionCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RewardAmount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardCurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RewardItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Coupons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CouponId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CouponId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CouponRedemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CurrencyType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssetName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("BaseAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PurchaseCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsFeatured");
|
||||
|
||||
b.HasIndex("IsPublic");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CustomAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomAvatarItemId");
|
||||
|
||||
b.HasIndex("PlayerId", "CustomAvatarItemId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItemOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
|
||||
{
|
||||
b.Property<long>("PlayerId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("PlayerId");
|
||||
|
||||
b.ToTable("DormStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("GrantedItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Offer1ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer2ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer3ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SelectedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SelectedGiftDropId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "SelectedAt");
|
||||
|
||||
b.ToTable("GameRewardSelections");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActivityLevelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlayerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Region")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Region");
|
||||
|
||||
b.ToTable("GameSessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AvatarItemDescOrHairDyeDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("AvatarItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConsumableItemDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Consumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ConsumedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Currency")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EquipmentModificationGuid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquipmentPrefabName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftRarity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsGifted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsValid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageMaterial")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageVariant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SourceStoreItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsCurrentPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Xp")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "Consumed");
|
||||
|
||||
b.ToTable("GiftPackages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("CreationRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CreatorPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentVersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("FirstPublishedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GeneralPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAgInvention")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("NumPlayersHaveUsedInRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Permission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SpawnCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CheerCount");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.ToTable("Inventions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("InstantiationCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("InventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LightsCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InventionId", "VersionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InventionVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("BannedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("BannedByAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Cidr")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Cidr");
|
||||
|
||||
b.ToTable("IpBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ItemKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemKey", "ItemType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ItemWishlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EarnedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "EventKey");
|
||||
|
||||
b.ToTable("Keepsakes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
|
||||
{
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Channel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LowerIsBetter")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ValueFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("RoomId", "Channel");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("LeaderboardChannelMeta");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StatChannel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Value")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "StatChannel")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId", "StatChannel", "Value");
|
||||
|
||||
b.ToTable("LeaderboardStats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Context")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlatformMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("LoadingScreenTips");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "SentAt");
|
||||
|
||||
b.HasIndex("SenderPlayerId", "SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAll")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAnnouncements")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowEventInvite")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowFriendRequest")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowMessage")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NotificationPrefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ClearedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ObjectiveProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PendingDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TaggedPlayerIdsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploaderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ViewCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("UploaderPlayerId");
|
||||
|
||||
b.ToTable("Photos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlatformIgnores");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeviceId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PlayerDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Elo")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GameMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Losses")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MatchesPlayed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Wins")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GameMode", "Elo");
|
||||
|
||||
b.HasIndex("PlayerId", "GameMode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerElo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BannerImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CanReceiveInvites")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedFromDeviceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayEmoji")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdentityFlags")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsCommunityTeam")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeveloper")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsJunior")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LastPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastPlatformId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PersonalPronouns")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProfileImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Reputation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("XP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Capacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("StartsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("StartsAt");
|
||||
|
||||
b.ToTable("PlayerEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("EventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Response")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("EventId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerEventResponses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemSlug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerInventory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCurated")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsCurated");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Playlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Cheered")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlaylistId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlaylistInteractions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlaylistId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("PlaylistId", "OrderIndex");
|
||||
|
||||
b.ToTable("PlaylistRooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DataBlob")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("OwnerPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRegion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhotonRoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId");
|
||||
|
||||
b.ToTable("PrivateInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
|
||||
{
|
||||
b.Property<long>("PrivateInstanceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("InvitedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LatestInviteMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PrivateInstanceId", "PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PrivateInstanceInvitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PushTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Ignored")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Muted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RequesterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RequesterId", "TargetId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ResolutionNote")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ResolverAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetEventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.HasIndex("ResolvedAt");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.ToTable("Reports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BannedPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "BannedPlayerId");
|
||||
|
||||
b.ToTable("RoomBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomBookmarks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("CurrencyId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomConsumables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ConcurrencyCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomConsumableId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomConsumableId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomConsumableId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomConsumableOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomCurrencyId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomCurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DailyLimit")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomCurrencies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Amount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.ToTable("RoomCurrencyPurchaseOffers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Bytes")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ReferencedFilenamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploadedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomDataBlobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowNewUsers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CloningAllowed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomRoomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DisableMicAutoMute")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("HiddenFromBrowse")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("HotScore")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAGRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDormRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsStudioRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LoadScreensJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LocationReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayerCalculationMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<string>("PromoExternalContentJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PromoImagesJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RoomWarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioSessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsMobile")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsVRLow")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("HotScore");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Rooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(174)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.ToTable("RoomKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PaidPrice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("PurchasedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomKeyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomKeyId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomKeyPurchases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Accepted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("GrantedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("GrantedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "Role")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CanMatchmakeInto")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DataModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSandbox")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomSceneLocationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StudioAssetBundleNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("StudioSubRoomDataSaveId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioUnityAssetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.HasIndex("RoomId", "OrderIndex")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomScenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("FirstVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "LastVisitAt");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomVisits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChestsOpened")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CompletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CustomGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("HealthPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsInAir")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ShieldPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WalkGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompletedAt");
|
||||
|
||||
b.ToTable("RoyaleMatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("MatchId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("MatchId", "PlayerId");
|
||||
|
||||
b.ToTable("RoyaleMatchPlayers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CurrentLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NextLevelAcornReward")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("NextLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RankIdx")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RankName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalXP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoyalePlayerProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllAvatarItemsOwned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BaseRoomNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CharadesSlotBindingsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CircuitsV2LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiscoveredGameConfigsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("GlobalFriendsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlayMenuTagsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("ProfanityFilterDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecCenterDoorsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("RoomBlobVersionClampDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SignupsDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WeeklyChallengeRewardJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("WeeklyChallengesCompletedRequired")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("WeeklyChallengesJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Descriptor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("RedeemedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SignupCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("AvailableUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsLimitedTime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Storefront")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Storefront");
|
||||
|
||||
b.HasIndex("IsActive", "Category");
|
||||
|
||||
b.ToTable("StoreItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SubscriberPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Pk")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssignedPlayerIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AssignedPlayerNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraBugUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinNumAssignedPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<uint?>("TestPassId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Pk");
|
||||
|
||||
b.HasIndex("Id")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("TestPassId");
|
||||
|
||||
b.ToTable("TestCases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
|
||||
{
|
||||
b.Property<uint>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("EndDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("WasManuallyClosed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TestPasses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("UgcPurchasables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithOne("Avatar")
|
||||
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithMany("Settings")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("RequesterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Requester");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Navigation("Avatar");
|
||||
|
||||
b.Navigation("Relationships");
|
||||
|
||||
b.Navigation("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PlayerProfile2023Fields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BannerImageName",
|
||||
table: "Players",
|
||||
type: "TEXT",
|
||||
maxLength: 256,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DisplayEmoji",
|
||||
table: "Players",
|
||||
type: "TEXT",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "IdentityFlags",
|
||||
table: "Players",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PersonalPronouns",
|
||||
table: "Players",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BannerImageName",
|
||||
table: "Players");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DisplayEmoji",
|
||||
table: "Players");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IdentityFlags",
|
||||
table: "Players");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PersonalPronouns",
|
||||
table: "Players");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1819,6 +1819,10 @@ namespace DorkNet.Server.Migrations
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BannerImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
@@ -1842,6 +1846,11 @@ namespace DorkNet.Server.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayEmoji")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
@@ -1851,6 +1860,9 @@ namespace DorkNet.Server.Migrations
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdentityFlags")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -1892,6 +1904,9 @@ namespace DorkNet.Server.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PersonalPronouns")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -3234,6 +3249,10 @@ namespace DorkNet.Server.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CircuitsV2LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiscoveredGameConfigsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single projection from <see cref="PlayerEntity"/> to the account wire
|
||||
/// DTOs. Every account read (account/me, account/{id}, account/bulk,
|
||||
/// account/search, account/create) and the AccountUpdate /
|
||||
/// SelfAccountUpdate pushes go through here so a profile field written by
|
||||
/// one endpoint is visible on all of them — previously each call site built
|
||||
/// its own DTO and the 2023 profile fields were never emitted anywhere.
|
||||
/// </summary>
|
||||
public static class AccountDtos
|
||||
{
|
||||
public static RecNetAccount Public(PlayerEntity p) => Fill(new RecNetAccount(), p);
|
||||
|
||||
public static RecNetSelfAccount Self(PlayerEntity p)
|
||||
{
|
||||
var a = Fill(new RecNetSelfAccount(), p);
|
||||
a.Email = p.Email ?? string.Empty;
|
||||
a.Phone = p.Phone ?? string.Empty;
|
||||
a.Birthday = p.Birthday ?? new DateTime(2000, 1, 1);
|
||||
a.JuniorState = p.IsJunior ? 1 : 0;
|
||||
a.ParentAccountId = null;
|
||||
return a;
|
||||
}
|
||||
|
||||
private static T Fill<T>(T a, PlayerEntity p) where T : RecNetAccount
|
||||
{
|
||||
a.AccountId = (int)p.Id;
|
||||
a.RawUsername = p.Username;
|
||||
a.Username = p.Username;
|
||||
a.DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? p.Username : p.DisplayName;
|
||||
a.ProfileImage = p.ProfileImageName ?? string.Empty;
|
||||
a.TreatAsJunior = p.IsJunior;
|
||||
a.HasBirthday = true;
|
||||
a.Platforms = 1;
|
||||
a.CreatedAt = p.CreatedAt;
|
||||
a.DisplayEmoji = p.DisplayEmoji ?? string.Empty;
|
||||
a.BannerImage = p.BannerImageName ?? string.Empty;
|
||||
a.PersonalPronouns = p.PersonalPronouns;
|
||||
a.IdentityFlags = p.IdentityFlags;
|
||||
return a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// OAuth device-authorization grant as the 2023 client drives it.
|
||||
///
|
||||
/// 1. The device that wants to sign in calls <c>connect/deviceauthorization</c>
|
||||
/// and shows the player the <c>user_code</c> (reader
|
||||
/// <c>RecNet.Runtime/AMPFGJPKHOD.txt:399-598</c>: device_code, user_code,
|
||||
/// verification_uri(_complete), expires_in, interval).
|
||||
/// 2. It polls <c>connect/token</c> with
|
||||
/// <c>grant_type=urn:ietf:params:oauth:grant-type:device_code</c> and
|
||||
/// <c>device_code</c> (<c>LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451</c>).
|
||||
/// A 400 whose <c>error</c> is <c>authorization_pending</c> or
|
||||
/// <c>slow_down</c> means "keep polling"
|
||||
/// (<c>LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116</c>); anything else
|
||||
/// ends the attempt.
|
||||
/// 3. A signed-in client approves the code with
|
||||
/// <c>account/me/remoteauth</c> (form field <c>code</c>).
|
||||
///
|
||||
/// State lives in one PlayerSettings row per authorization, owned by the system
|
||||
/// account: key <c>deviceauthorization:{device_code}</c>, value
|
||||
/// <c>{user_code}|{expiresAtUtc:O}</c>, plus <c>|{approverAccountId}</c> once
|
||||
/// approved. Redeeming deletes the row, so a device code is single-use.
|
||||
/// </summary>
|
||||
public static class DeviceAuthorizations
|
||||
{
|
||||
public const string KeyPrefix = "deviceauthorization:";
|
||||
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15);
|
||||
|
||||
public enum RedeemStatus { Invalid, Expired, Pending, Approved }
|
||||
|
||||
public sealed record Created(string DeviceCode, string UserCode, int ExpiresInSeconds);
|
||||
|
||||
public static async Task<Created> CreateAsync(DorkNetDbContext db, DateTime nowUtc)
|
||||
{
|
||||
var deviceCode = Guid.NewGuid().ToString("N");
|
||||
var userCode = deviceCode[..8].ToUpperInvariant();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = RelationshipQueries.SystemAccountId,
|
||||
Key = KeyPrefix + deviceCode,
|
||||
Value = $"{userCode}|{(nowUtc + Lifetime):O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return new Created(deviceCode, userCode, (int)Lifetime.TotalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>Bind the pending authorization whose user code is
|
||||
/// <paramref name="userCode"/> to <paramref name="approverId"/>. False when
|
||||
/// no live, unapproved authorization carries that code.</summary>
|
||||
public static async Task<bool> ApproveAsync(
|
||||
DorkNetDbContext db, string userCode, long approverId, DateTime nowUtc)
|
||||
{
|
||||
var code = NormaliseUserCode(userCode);
|
||||
if (code.Length == 0) return false;
|
||||
|
||||
var prefix = code + "|";
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(KeyPrefix) && s.Value.StartsWith(prefix))
|
||||
.ToListAsync();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var parts = row.Value.Split('|');
|
||||
if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc)
|
||||
continue;
|
||||
if (parts.Length >= 3 && parts[2].Length > 0) continue; // already approved
|
||||
row.Value = $"{parts[0]}|{parts[1]}|{approverId}";
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>One poll from the device. Approved authorizations are consumed
|
||||
/// and yield the approving account.</summary>
|
||||
public static async Task<(RedeemStatus Status, long AccountId)> RedeemAsync(
|
||||
DorkNetDbContext db, string? deviceCode, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceCode)) return (RedeemStatus.Invalid, 0);
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.Key == KeyPrefix + deviceCode.Trim());
|
||||
if (row is null) return (RedeemStatus.Invalid, 0);
|
||||
|
||||
var parts = row.Value.Split('|');
|
||||
if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc)
|
||||
{
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
return (RedeemStatus.Expired, 0);
|
||||
}
|
||||
|
||||
if (parts.Length < 3 || !long.TryParse(parts[2], out var approver))
|
||||
return (RedeemStatus.Pending, 0);
|
||||
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
return (RedeemStatus.Approved, approver);
|
||||
}
|
||||
|
||||
private static string NormaliseUserCode(string? raw) =>
|
||||
new string((raw ?? string.Empty).Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant();
|
||||
|
||||
private static bool TryParseExpiry(string raw, out DateTime expiresUtc) =>
|
||||
DateTime.TryParse(raw, null, System.Globalization.DateTimeStyles.RoundtripKind, out expiresUtc);
|
||||
}
|
||||
@@ -10,9 +10,27 @@ namespace DorkNet.Server.Services;
|
||||
public sealed class JoinTimeoutService(
|
||||
PlayerPresenceService presence,
|
||||
NotificationService notifications,
|
||||
PresenceBroadcastService fanout,
|
||||
ILogger<JoinTimeoutService> logger)
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
|
||||
/// <summary>
|
||||
/// How long a join may stay unreported before the server gives up on it.
|
||||
///
|
||||
/// This used to be 30 seconds, which is shorter than a real 2023 room
|
||||
/// load: the client reports its join result only after the room's assets
|
||||
/// have downloaded and the player has spawned, and a large maker room
|
||||
/// regularly takes longer than that (in the field: matchmake/room at
|
||||
/// 09:16:35, Photon joined at 09:16:41, the load still running at 09:17:33).
|
||||
/// The watchdog then cleared presence mid-load, the next heartbeat found
|
||||
/// no room and answered with the dorm, and the client logged "presence
|
||||
/// heartbeat response indicates local presence is out-of-sync" and
|
||||
/// cancelled the load ("OnPlayerPresenceUpdate is cancelling room load").
|
||||
///
|
||||
/// The 2023 client reports its own failures (reportjoinresult with
|
||||
/// FailedTo*) and runs its fallback chain, so this is only a backstop for
|
||||
/// a client that silently stopped mid-join.
|
||||
/// </summary>
|
||||
public static TimeSpan Timeout { get; } = TimeSpan.FromMinutes(3);
|
||||
private readonly ConcurrentDictionary<long, PendingJoin> _pending = new();
|
||||
|
||||
public void MarkPending(long playerId, RoomInstanceDto? room, bool deferPresenceCommit = false)
|
||||
@@ -40,6 +58,10 @@ public sealed class JoinTimeoutService(
|
||||
_ = WatchAsync(playerId, pending);
|
||||
}
|
||||
|
||||
/// <summary>Drop the player's pending join without touching presence —
|
||||
/// the client abandoned it (matchmake/none, logout).</summary>
|
||||
public void Cancel(long playerId, string reason) => ClearForPlayer(playerId, reason);
|
||||
|
||||
public (RoomInstanceDto TargetRoom, bool DeferPresenceCommit)? GetPending(long playerId)
|
||||
{
|
||||
if (!_pending.TryGetValue(playerId, out var pending)) return null;
|
||||
@@ -75,11 +97,15 @@ public sealed class JoinTimeoutService(
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
presence.Clear(playerId);
|
||||
// Room only: the player is still online and about to run the
|
||||
// client's fallback chain. A full Clear also dropped the activity
|
||||
// marker, so friends saw them go offline after every failed join.
|
||||
presence.ClearRoom(playerId);
|
||||
logger.LogWarning(
|
||||
"[join-watchdog] cleared failed join player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId} outcome={Outcome}",
|
||||
playerId, failedRoom.RoomId, failedRoom.Name, instanceId, outcome);
|
||||
return Task.CompletedTask;
|
||||
// Whatever room friends were last told about is gone; correct them.
|
||||
return fanout.PublishCurrentAsync(playerId);
|
||||
}
|
||||
|
||||
private async Task WatchAsync(long playerId, PendingJoin pending)
|
||||
@@ -110,8 +136,9 @@ public sealed class JoinTimeoutService(
|
||||
return;
|
||||
}
|
||||
|
||||
presence.Clear(playerId);
|
||||
presence.ClearRoom(playerId);
|
||||
await notifications.KickPlayerAsync(playerId, "Room join timed out. Returning to dorm.");
|
||||
await fanout.PublishCurrentAsync(playerId);
|
||||
logger.LogWarning(
|
||||
"[join-watchdog] timed out player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId}",
|
||||
playerId, pending.TargetRoom.RoomId, pending.TargetRoom.Name, pending.InstanceId);
|
||||
|
||||
@@ -170,45 +170,25 @@ public class NotificationService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Push fresh account payloads to the affected player's
|
||||
/// watch. The 2020 client caches Account/SelfAccount objects in memory;
|
||||
/// without this, username and display-name edits persist server-side
|
||||
/// but the current session keeps rendering the stale cached names until
|
||||
/// a full relog rebuilds the account cache.</summary>
|
||||
/// <summary>Push fresh account payloads after a profile mutation.
|
||||
/// The owner gets <c>SelfAccountUpdate</c> (private self DTO) and every
|
||||
/// connected client gets <c>AccountUpdate</c> (public DTO). The 2023
|
||||
/// client subscribes to both strings (RecNet.Runtime
|
||||
/// OPMAPIOEIFG.txt:267 "AccountUpdate" → Action<Account>, :279
|
||||
/// "SelfAccountUpdate" → Action<SelfAccount>) and refreshes its
|
||||
/// cached account from the payload — the profile-edit PUTs return no
|
||||
/// account, so this push is the ONLY way the new emoji / banner /
|
||||
/// pronouns / identity flags reach the editor's own UI and anyone else
|
||||
/// already looking at the profile. Sending AccountUpdate to the owner
|
||||
/// alone (the previous behaviour) left every other player rendering the
|
||||
/// stale cached account until relog.</summary>
|
||||
public Task AccountChanged(PlayerEntity player)
|
||||
{
|
||||
var account = new RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = string.IsNullOrWhiteSpace(player.DisplayName) ? player.Username : player.DisplayName,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = player.IsJunior,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
CreatedAt = player.CreatedAt,
|
||||
};
|
||||
var selfAccount = new RecNetSelfAccount
|
||||
{
|
||||
AccountId = account.AccountId,
|
||||
RawUsername = account.RawUsername,
|
||||
Username = account.Username,
|
||||
DisplayName = account.DisplayName,
|
||||
ProfileImage = account.ProfileImage,
|
||||
TreatAsJunior = account.TreatAsJunior,
|
||||
HasBirthday = account.HasBirthday,
|
||||
Platforms = account.Platforms,
|
||||
CreatedAt = account.CreatedAt,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = player.Phone ?? string.Empty,
|
||||
Birthday = player.Birthday ?? new DateTime(2000, 1, 1),
|
||||
JuniorState = player.IsJunior ? 1 : 0,
|
||||
ParentAccountId = null,
|
||||
};
|
||||
var account = AccountDtos.Public(player);
|
||||
var selfAccount = AccountDtos.Self(player);
|
||||
|
||||
return Task.WhenAll(
|
||||
NotifyAsync(player.Id, PushNotificationId.SubscriptionUpdateProfile, account),
|
||||
BroadcastAsync(PushNotificationId.SubscriptionUpdateProfile, account),
|
||||
NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
|
||||
}
|
||||
|
||||
@@ -271,17 +251,49 @@ public class NotificationService(
|
||||
/// the runtime-type path can pick the wrong contract under certain
|
||||
/// option combos and emit an empty <c>{}</c> body — which silently
|
||||
/// drops the presence update at the watch.</summary>
|
||||
public async Task PlayerPresenceChanged(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room)
|
||||
public async Task PlayerPresenceChanged(
|
||||
IEnumerable<long> friendIds,
|
||||
long playerId,
|
||||
RoomInstanceDto? room,
|
||||
bool? isOnline = null,
|
||||
int statusVisibility = 0)
|
||||
{
|
||||
var groups = friendIds.Select(NotifyHub.GroupForPlayer).ToArray();
|
||||
if (groups.Length == 0) return;
|
||||
|
||||
var redacted = RoomInstanceDto.Redact(room);
|
||||
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
|
||||
|
||||
logger.LogDebug(
|
||||
"[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}",
|
||||
playerId, groups.Length, payload);
|
||||
|
||||
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
|
||||
}
|
||||
|
||||
/// <summary>The PresenceUpdate envelope for one player, as a JSON string.
|
||||
///
|
||||
/// <paramref name="isOnline"/> is independent of the room: the 2023
|
||||
/// presence DTO stores it in its own field (LCEHINCCHOB +0x50,
|
||||
/// RecNet.Runtime/LCEHINCCHOB.txt:635-653) and DNKHCBFPLJG treats
|
||||
/// "online with no room" as a real state — a player in the offline dorm
|
||||
/// after matchmake/none. Deriving it from the room, as this used to, sent
|
||||
/// such players to their friends as OFFLINE. When omitted it still falls
|
||||
/// back to "has a room" for callers that only know about room moves.
|
||||
/// An offline frame never carries a room.</summary>
|
||||
public static string BuildPresenceUpdatePayload(
|
||||
long playerId,
|
||||
RoomInstanceDto? room,
|
||||
bool? isOnline = null,
|
||||
int statusVisibility = 0)
|
||||
{
|
||||
var online = isOnline ?? room is not null;
|
||||
var redacted = online ? RoomInstanceDto.Redact(room) : null;
|
||||
var msg = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
["playerId"] = (int)playerId,
|
||||
// Everyone=0 (was 1=FriendsOnly, which hid you from non-friends).
|
||||
["statusVisibility"] = 0,
|
||||
// The player's stored /player/statusvisibility choice (default
|
||||
// 0 = Everyone), the same value their own heartbeat reports.
|
||||
["statusVisibility"] = statusVisibility,
|
||||
["deviceClass"] = 0,
|
||||
["vrMovementMode"] = 0,
|
||||
["roomInstance"] = redacted is null
|
||||
@@ -307,7 +319,7 @@ public class NotificationService(
|
||||
["EncryptVoiceChat"] = redacted.EncryptVoiceChat,
|
||||
},
|
||||
// 2020.12 PlayerPresence.Deserialize requires these.
|
||||
["isOnline"] = redacted is not null,
|
||||
["isOnline"] = online,
|
||||
// 2023.03.21 client build version; must match the joining
|
||||
// client's own version or it reports a version mismatch. MUST be
|
||||
// a STRING ("20230317"): the client's presence DTO reads
|
||||
@@ -325,13 +337,7 @@ public class NotificationService(
|
||||
["Id"] = WireName(PushNotificationId.SubscriptionUpdatePresence),
|
||||
["Msg"] = msg,
|
||||
};
|
||||
var payload = envelope.ToJsonString();
|
||||
|
||||
logger.LogDebug(
|
||||
"[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}",
|
||||
playerId, groups.Length, payload);
|
||||
|
||||
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
|
||||
return envelope.ToJsonString();
|
||||
}
|
||||
|
||||
public Task PlayerCameOnline(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room) =>
|
||||
|
||||
@@ -144,6 +144,19 @@ public static class PlayerAccountPurge
|
||||
.SetProperty(x => x.DeletedAt, now)
|
||||
.SetProperty(x => x.IsPublic, false));
|
||||
|
||||
// Custom avatar items: the purged player's ownership rows go away;
|
||||
// items they CREATED are reassigned to the system account like
|
||||
// Inventions, because other players may own and be wearing them —
|
||||
// deleting the item would break those avatars.
|
||||
deleted += await db.CustomAvatarItemOwnership
|
||||
.Where(x => x.PlayerId == playerId)
|
||||
.ExecuteDeleteAsync();
|
||||
reassigned += await db.CustomAvatarItems
|
||||
.Where(x => x.CreatorPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
|
||||
.SetProperty(x => x.UpdatedAt, now));
|
||||
|
||||
reassigned += await db.SignupCodes
|
||||
.Where(x => x.RedeemedByPlayerId == playerId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null));
|
||||
|
||||
@@ -48,6 +48,7 @@ public class PlayerPresenceService
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localRoomExpires = new();
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localActiveExpires = new();
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localBootDormGraceExpires = new();
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localNoRoomExpires = new();
|
||||
private readonly IConnectionMultiplexer? _redis;
|
||||
private readonly ILogger<PlayerPresenceService>? _log;
|
||||
|
||||
@@ -101,7 +102,9 @@ public class PlayerPresenceService
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(room, JsonOpts);
|
||||
mux.GetDatabase().StringSet(Key(playerId), json, PresenceTtl);
|
||||
var db = mux.GetDatabase();
|
||||
db.StringSet(Key(playerId), json, PresenceTtl);
|
||||
db.KeyDelete(NoRoomKey(playerId));
|
||||
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
|
||||
playerId, room?.Name ?? "<null>", json.Length);
|
||||
}
|
||||
@@ -117,6 +120,7 @@ public class PlayerPresenceService
|
||||
{
|
||||
_local[playerId] = room;
|
||||
_localRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||||
_log?.LogDebug("[presence] set local player={PlayerId} room={Room}",
|
||||
playerId, room?.Name ?? "<null>");
|
||||
}
|
||||
@@ -333,6 +337,7 @@ public class PlayerPresenceService
|
||||
db.KeyDelete(Key(playerId));
|
||||
db.KeyDelete(ActiveKey(playerId));
|
||||
db.KeyDelete(BootDormGraceKey(playerId));
|
||||
db.KeyDelete(NoRoomKey(playerId));
|
||||
db.SetRemove(ActiveSetKey, playerId);
|
||||
}
|
||||
else
|
||||
@@ -341,9 +346,119 @@ public class PlayerPresenceService
|
||||
_localRoomExpires.TryRemove(playerId, out _);
|
||||
_localActiveExpires.TryRemove(playerId, out _);
|
||||
_localBootDormGraceExpires.TryRemove(playerId, out _);
|
||||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forget the player's room but keep everything that says they
|
||||
/// are still here (activity marker, login lock, boot grace). Used when a
|
||||
/// join fails or times out: the player is still online and about to run
|
||||
/// the client's fallback chain, so friends must not see them go offline,
|
||||
/// they just must not see a room the player never reached.</summary>
|
||||
public void ClearRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
mux.GetDatabase().KeyDelete(Key(playerId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis room DEL failed for player={PlayerId}", playerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_local.TryRemove(playerId, out _);
|
||||
_localRoomExpires.TryRemove(playerId, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record that the player deliberately left online matchmaking: they are
|
||||
/// online, but in no room instance.
|
||||
///
|
||||
/// The 2023 client does this in TryGoToOfflineRoom
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:12677, the only caller of
|
||||
/// <c>matchmake/none</c> at :13279): it swaps its cached presence to the
|
||||
/// local offline instance (RoomInstanceId -1) and posts matchmake/none,
|
||||
/// ignoring the reply (<c><TryGoToOfflineRoom>b__119_0</c> returns a
|
||||
/// constant). From then on the heartbeat handler
|
||||
/// (Matchmaking.txt:18103, compare at :18415-18467) treats a negative RoomInstanceId like no
|
||||
/// room (HCHDEHIGEBE.GIICPDHJOOH = <c>RoomInstanceId < 0</c>) and is
|
||||
/// only in sync while the server answers <c>roomInstance: null</c>. Any
|
||||
/// room here — the old one, or a synthesised dorm — reads as out-of-sync
|
||||
/// and the client cancels its offline load to chase the server's room.
|
||||
/// The marker keeps the heartbeat's dorm fallback from firing for this
|
||||
/// player until they matchmake somewhere again.
|
||||
/// </summary>
|
||||
public void SetNoRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
var db = mux.GetDatabase();
|
||||
db.KeyDelete(Key(playerId));
|
||||
db.StringSet(NoRoomKey(playerId), "1", PresenceTtl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis no-room SET failed for player={PlayerId}", playerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_local.TryRemove(playerId, out _);
|
||||
_localRoomExpires.TryRemove(playerId, out _);
|
||||
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
}
|
||||
|
||||
/// <summary>True while the player is online with no room after
|
||||
/// <see cref="SetNoRoom"/>. Cleared by the next <see cref="SetRoom"/> or
|
||||
/// <see cref="Clear"/>.</summary>
|
||||
public bool IsInNoRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
return mux.GetDatabase().KeyExists(NoRoomKey(playerId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis no-room GET failed for player={PlayerId}", playerId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_localNoRoomExpires.TryGetValue(playerId, out var expires)) return false;
|
||||
if (expires > DateTimeOffset.UtcNow) return true;
|
||||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Keep the no-room marker alive while the player keeps
|
||||
/// heartbeating from the offline room.</summary>
|
||||
public void TouchNoRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
mux.GetDatabase().KeyExpire(NoRoomKey(playerId), PresenceTtl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis no-room EXPIRE failed for player={PlayerId}", playerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_localNoRoomExpires.ContainsKey(playerId))
|
||||
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
}
|
||||
|
||||
/// <summary>List the currently-active instances of a given room,
|
||||
/// grouped by PhotonRoomId, with the player ids currently presenced
|
||||
/// in each. Drives the watch's RoomInstanceBrowser
|
||||
@@ -386,6 +501,7 @@ public class PlayerPresenceService
|
||||
private static string Key(long playerId) => $"presence:{playerId}";
|
||||
private static string ActiveKey(long playerId) => $"presence-active:{playerId}";
|
||||
private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}";
|
||||
private static string NoRoomKey(long playerId) => $"presence-noroom:{playerId}";
|
||||
private const string ActiveSetKey = "presence-active";
|
||||
|
||||
// ── LoginLock single-session enforcement ─────────────────────────
|
||||
|
||||
@@ -423,6 +423,38 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
outfitsFixed, inventoryFixed, colorsFixed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True for a platform id that identifies nobody: blank, "0" (no platform
|
||||
/// session) or "1". A Quest client patched without its own Meta app
|
||||
/// (DorkNet.Quest, no-Meta mode) has no platform session, so it reports
|
||||
/// the same placeholder user id "1" from every headset. Treating that as a
|
||||
/// real id would list every Quest player's account in every other Quest
|
||||
/// player's remembered-logins prompt and, with no device id, merge them
|
||||
/// into one synthetic account. Placeholders are never stored on an
|
||||
/// account, never matched by the cached-login lookup and never used to
|
||||
/// synthesise a device id — those players are keyed by deviceId alone.
|
||||
/// </summary>
|
||||
public static bool IsPlaceholderPlatformId(string? platformId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platformId)) return true;
|
||||
var trimmed = platformId.Trim();
|
||||
return trimmed == "0" || trimmed == "1";
|
||||
}
|
||||
|
||||
/// <summary>Lookup-only counterpart to
|
||||
/// <see cref="GetOrCreateByDeviceAsync"/>. Returns the existing player
|
||||
/// row for the given deviceId, or null if none exists — never creates
|
||||
/// a new account. Callers that want to enforce the admin "signups
|
||||
/// disabled" toggle use this to gate the device-id login fallback
|
||||
/// without accidentally minting an account.</summary>
|
||||
public async Task<PlayerEntity?> GetByDeviceAsync(string? deviceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return null;
|
||||
return await db.Players
|
||||
.Include(p => p.Avatar)
|
||||
.FirstOrDefaultAsync(p => p.DeviceId == deviceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up an existing account by deviceId, or create a new one if no
|
||||
/// account is bound to that device yet. This is the canonical
|
||||
@@ -440,26 +472,13 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
/// - When deviceId is empty (rare paths), fall back to a generated
|
||||
/// synthetic id so we never create accidentally-shared accounts.
|
||||
/// </summary>
|
||||
/// <summary>Lookup-only counterpart to
|
||||
/// <see cref="GetOrCreateByDeviceAsync"/>. Returns the existing player
|
||||
/// row for the given deviceId, or null if none exists — never creates
|
||||
/// a new account. Callers that want to enforce the admin "signups
|
||||
/// disabled" toggle use this to gate the device-id login fallback
|
||||
/// without accidentally minting an account.</summary>
|
||||
public async Task<PlayerEntity?> GetByDeviceAsync(string? deviceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return null;
|
||||
return await db.Players
|
||||
.Include(p => p.Avatar)
|
||||
.FirstOrDefaultAsync(p => p.DeviceId == deviceId);
|
||||
}
|
||||
|
||||
public async Task<PlayerEntity> GetOrCreateByDeviceAsync(
|
||||
string? deviceId,
|
||||
int platform = 0,
|
||||
string? platformId = null,
|
||||
string? displayName = null)
|
||||
{
|
||||
if (IsPlaceholderPlatformId(platformId)) platformId = null;
|
||||
if (string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
// Synthesize a stable id from the platformId or generate one so
|
||||
@@ -559,6 +578,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
string? platformId = null,
|
||||
string? displayName = null)
|
||||
{
|
||||
if (IsPlaceholderPlatformId(platformId)) platformId = null;
|
||||
// Synthesise deviceId / platformId for empty inputs so the
|
||||
// audit trail still lands somewhere meaningful.
|
||||
var effectiveDeviceId = string.IsNullOrWhiteSpace(deviceId)
|
||||
@@ -646,7 +666,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
if (player is null) return;
|
||||
player.LastSeenAt = DateTime.UtcNow;
|
||||
if (platform != 0) player.LastPlatform = platform;
|
||||
if (!string.IsNullOrWhiteSpace(platformId)) player.LastPlatformId = platformId;
|
||||
if (!IsPlaceholderPlatformId(platformId)) player.LastPlatformId = platformId!;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -663,7 +683,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
/// </summary>
|
||||
public async Task<List<PlayerEntity>> GetCachedLoginsAsync(int platform, string? platformId, int take = 10)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platformId))
|
||||
if (IsPlaceholderPlatformId(platformId))
|
||||
return [];
|
||||
return await db.Players
|
||||
.Where(p => p.LastPlatform == platform && p.LastPlatformId == platformId)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Online-status visibility enum (2023 client, dump.cs IPLLHDINPBL). Picked in
|
||||
/// the profile's "Set Online Status Visibility" dialog (Assembly-CSharp
|
||||
/// RRUI/Data/BaseAccountModel.txt, ShowChangeOnlineStatusVisibilityDialog) and
|
||||
/// sent as PUT player/statusvisibility form statusVisibility (RecNet.Runtime
|
||||
/// RecNet/Matchmaking.txt:17147-17161).
|
||||
/// </summary>
|
||||
public static class PresenceVisibility
|
||||
{
|
||||
public const int Public = 0;
|
||||
public const int FriendsOnly = 1;
|
||||
public const int FavoriteFriendsOnly = 2;
|
||||
public const int Offline = 3;
|
||||
|
||||
public static bool IsValid(int value) => value is >= Public and <= Offline;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells a player's friends when that player's presence changes, from one
|
||||
/// place, so every transition sends the same frame built from the same state.
|
||||
///
|
||||
/// Before this existed only the immediate <c>/goto</c> commit pushed anything.
|
||||
/// Deferred commits (matchmake/dorm, private-instance hops) committed in
|
||||
/// reportjoinresult without a push, matchmake/none and logout changed presence
|
||||
/// silently, and SignalR connect/disconnect pushed with <c>isOnline</c> derived
|
||||
/// from "has a room" — so a friend's list showed people in rooms they had left,
|
||||
/// offline while they sat in their dorm, and never noticed a status-visibility
|
||||
/// change.
|
||||
///
|
||||
/// The client's PresenceUpdate handler (Matchmaking.EGPABEDPEJO, registered at
|
||||
/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into
|
||||
/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync
|
||||
/// correction (:18467). A frame about the LOCAL player therefore moves that player, so
|
||||
/// these frames go to friends only, never to the subject.
|
||||
/// </summary>
|
||||
public sealed class PresenceBroadcastService(
|
||||
IServiceScopeFactory scopes,
|
||||
PlayerPresenceService presence,
|
||||
OnlinePresenceService connections,
|
||||
NotificationService notifications,
|
||||
ILogger<PresenceBroadcastService> logger)
|
||||
{
|
||||
/// <summary>PlayerSettings key for the persisted
|
||||
/// <c>/player/statusvisibility</c> choice.</summary>
|
||||
public const string StatusVisibilityPrefKey = "pref:statusVisibility";
|
||||
|
||||
/// <summary>A player counts as online while they heartbeat or hold a
|
||||
/// SignalR connection. Being in a room is NOT required: the client's
|
||||
/// presence DTO carries <c>isOnline</c> separately (LCEHINCCHOB +0x50) and
|
||||
/// DNKHCBFPLJG reads "online, in no room / the offline room" as a valid
|
||||
/// state, which is exactly what a player in the offline dorm is.</summary>
|
||||
public bool IsOnline(long playerId) =>
|
||||
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
|
||||
|
||||
/// <summary>Push the player's current presence (online state + committed
|
||||
/// room) to every friend.</summary>
|
||||
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
|
||||
|
||||
/// <summary>Push an offline frame regardless of any lingering markers —
|
||||
/// for an explicit logout.</summary>
|
||||
public Task PublishOfflineAsync(long playerId) => PublishAsync(playerId, forceOffline: true);
|
||||
|
||||
/// <summary>
|
||||
/// The last SignalR connection dropped but the player may still be
|
||||
/// playing: the socket can be closed under a live session ("RecNet
|
||||
/// HubConnection error: ... TCP Stream closed unexpectedly by the remote
|
||||
/// server") and the client reconnects on its own. Pushing offline right
|
||||
/// away greys the player out for every friend until that reconnect. Wait
|
||||
/// one activity window and only then decide: still silent means the game
|
||||
/// is really gone.
|
||||
/// </summary>
|
||||
public void ScheduleOfflineCheck(long playerId)
|
||||
{
|
||||
var delay = PlayerPresenceService.ActivityTtl + TimeSpan.FromSeconds(5);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay);
|
||||
if (IsOnline(playerId)) return;
|
||||
await PublishAsync(playerId, forceOffline: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "[presence-fanout] deferred offline check failed player={PlayerId}", playerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task PublishAsync(long playerId, bool forceOffline)
|
||||
{
|
||||
if (playerId <= 0) return;
|
||||
try
|
||||
{
|
||||
await using var scope = scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<ServerSettingsService>();
|
||||
|
||||
var friendIds = (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, playerId))
|
||||
.Where(id => id != playerId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (friendIds.Count == 0) return;
|
||||
|
||||
var online = !forceOffline && IsOnline(playerId);
|
||||
var room = online ? presence.GetRoom(playerId) : null;
|
||||
var visibility = await GetStatusVisibilityAsync(db, playerId);
|
||||
|
||||
// Honour the player's visibility choice the same way GET /player
|
||||
// does (PlayerPresenceController.ResolveHiddenAsync): recipients
|
||||
// who may not see them get the frame a genuinely offline player
|
||||
// produces. Every recipient here is already a friend, so
|
||||
// FriendsOnly needs no split; FavoriteFriendsOnly reaches only the
|
||||
// friends this player favorited (Favorited lives on the
|
||||
// favoriter's own row, RequesterId = favoriter).
|
||||
var favorited = visibility == PresenceVisibility.FavoriteFriendsOnly
|
||||
? (await db.Relationships.AsNoTracking()
|
||||
.Where(r => r.RequesterId == playerId && r.Favorited && friendIds.Contains(r.TargetId))
|
||||
.Select(r => r.TargetId)
|
||||
.ToListAsync()).ToHashSet()
|
||||
: new HashSet<long>();
|
||||
var (visibleTo, hiddenFrom) = SplitRecipients(friendIds, visibility, favorited);
|
||||
|
||||
logger.LogInformation(
|
||||
"[presence-fanout] player={PlayerId} online={Online} room={RoomId} instance={InstanceId} visibility={Visibility} -> {FriendCount} friend(s), {HiddenCount} hidden",
|
||||
playerId, online, room?.RoomId, room?.RoomInstanceId, visibility, visibleTo.Count, hiddenFrom.Count);
|
||||
|
||||
if (visibleTo.Count > 0)
|
||||
await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility);
|
||||
if (hiddenFrom.Count > 0)
|
||||
await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Best-effort: a fan-out failure must never fail the matchmake,
|
||||
// logout or hub event that triggered it.
|
||||
logger.LogWarning(ex, "[presence-fanout] push failed player={PlayerId}", playerId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Split a player's friends into those who may see their real
|
||||
/// presence and those who must see them offline, per their status
|
||||
/// visibility. <paramref name="favorited"/> holds the friends the player
|
||||
/// favorited; it only matters for FavoriteFriendsOnly.</summary>
|
||||
public static (List<long> VisibleTo, List<long> HiddenFrom) SplitRecipients(
|
||||
IReadOnlyList<long> friendIds, int visibility, IReadOnlySet<long> favorited) =>
|
||||
visibility switch
|
||||
{
|
||||
PresenceVisibility.Offline => (new List<long>(), friendIds.ToList()),
|
||||
PresenceVisibility.FavoriteFriendsOnly => (
|
||||
friendIds.Where(favorited.Contains).ToList(),
|
||||
friendIds.Where(id => !favorited.Contains(id)).ToList()),
|
||||
_ => (friendIds.ToList(), new List<long>()),
|
||||
};
|
||||
|
||||
/// <summary>Stored status visibility for one player (0 = default).</summary>
|
||||
public static async Task<int> GetStatusVisibilityAsync(DorkNetDbContext db, long playerId)
|
||||
{
|
||||
var raw = await db.PlayerSettings.AsNoTracking()
|
||||
.Where(s => s.PlayerId == playerId && s.Key == StatusVisibilityPrefKey)
|
||||
.Select(s => s.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
return int.TryParse(raw, out var v) && PresenceVisibility.IsValid(v) ? v : PresenceVisibility.Public;
|
||||
}
|
||||
|
||||
/// <summary>Stored status visibility for many players in one query.
|
||||
/// Players with no stored choice are absent from the result.</summary>
|
||||
public static async Task<Dictionary<long, int>> GetStatusVisibilitiesAsync(
|
||||
DorkNetDbContext db, IReadOnlyCollection<long> playerIds)
|
||||
{
|
||||
if (playerIds.Count == 0) return new Dictionary<long, int>();
|
||||
var rows = await db.PlayerSettings.AsNoTracking()
|
||||
.Where(s => s.Key == StatusVisibilityPrefKey && playerIds.Contains(s.PlayerId))
|
||||
.Select(s => new { s.PlayerId, s.Value })
|
||||
.ToListAsync();
|
||||
var result = new Dictionary<long, int>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (int.TryParse(row.Value, out var v) && PresenceVisibility.IsValid(v)) result[row.PlayerId] = v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
@@ -175,7 +176,25 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
var row = await GetAsync();
|
||||
return ToGameConfigurations(
|
||||
ToRecCenterDoors(row),
|
||||
ToDiscoveredGameConfigs(row));
|
||||
ToDiscoveredGameConfigs(row),
|
||||
ToCircuitsV2Limits(row));
|
||||
}
|
||||
|
||||
public async Task<CircuitsV2LimitSettings> GetCircuitsV2LimitsAsync()
|
||||
{
|
||||
var row = await GetAsync();
|
||||
return ToCircuitsV2Limits(row);
|
||||
}
|
||||
|
||||
public async Task<CircuitsV2LimitSettings> SetCircuitsV2LimitsAsync(
|
||||
CircuitsV2LimitSettings settings)
|
||||
{
|
||||
var normalized = NormalizeCircuitsV2Limits(settings);
|
||||
var existing = await GetTrackedRowAsync();
|
||||
existing.CircuitsV2LimitsJson = JsonSerializer.Serialize(normalized, JsonOptions);
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return normalized with { UpdatedAt = existing.UpdatedAt };
|
||||
}
|
||||
|
||||
public async Task<PlayMenuTagSettings> SetPlayMenuTagsAsync(
|
||||
@@ -663,9 +682,86 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
SplitTestSegmentProbabilities: "{}",
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
|
||||
/// <summary>
|
||||
/// Retail shipped one network entry — 5 RPCs per frame, 1024 bytes per
|
||||
/// 60 frames — which is what makes busy CircuitsV2 rooms drop chip
|
||||
/// sends. DorkNet ships the budgets effectively unlimited instead, and
|
||||
/// raises the room-size limits to values the client survives with
|
||||
/// headroom (each is justified in docs/recroom-2023-circuitsv2-limits.md):
|
||||
/// <list type="bullet">
|
||||
/// <item>Compute 50000 → 500000: the per-frame CPU budget shared by
|
||||
/// every circuit in the room. It is the only thing that stops a runaway
|
||||
/// loop, so it stays finite.</item>
|
||||
/// <item>Chips 3000 → 30000: the room's chip cap ("Too many
|
||||
/// chips.").</item>
|
||||
/// <item>CloudValues 100 → 1000: the room's cloud-variable cap.</item>
|
||||
/// <item>UnculledChips / VisibleChips 500 → 3000: chips past these
|
||||
/// counts get culled, so every chip of a retail-sized room stays
|
||||
/// visible.</item>
|
||||
/// <item>InkPerChip 15 → 0: this is an ink COST, charged against the
|
||||
/// client's hard-coded 30000-ink room cap, so lowering it is the
|
||||
/// increase. 0 means chips stop eating room ink.</item>
|
||||
/// </list>
|
||||
/// CloudValuesAutosaveSeconds stays at retail's 300: it is a save
|
||||
/// cadence, not a capacity limit. Admins can dial any of it back from
|
||||
/// the SPA.
|
||||
/// </summary>
|
||||
public static CircuitsV2LimitSettings DefaultCircuitsV2Limits() =>
|
||||
new(
|
||||
Network:
|
||||
[
|
||||
new(
|
||||
NumPlayersForActivation: 0,
|
||||
Bytes: UnlimitedBudget,
|
||||
BytesTimeWindowFrame60s: 60,
|
||||
Rpcs: UnlimitedBudget,
|
||||
RpcTimeWindowFrame60s: 1),
|
||||
],
|
||||
Compute: 500_000,
|
||||
Chips: 30_000,
|
||||
CloudValues: 1_000,
|
||||
CloudValuesAutosaveSeconds: 300f,
|
||||
UnculledChips: 3_000,
|
||||
VisibleChips: 3_000,
|
||||
InkPerChip: 0,
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
|
||||
/// <summary>The budget value that stands in for "unlimited". The client
|
||||
/// only ever compares a running counter against it, so any large int
|
||||
/// works; this leaves 2x headroom under int.MaxValue.</summary>
|
||||
private const int UnlimitedBudget = 1_000_000_000;
|
||||
|
||||
/// <summary>Time windows size a <c>new int[N]</c> ring buffer — one
|
||||
/// bucket per frame — inside the client's rate limiter, so a huge
|
||||
/// window is an allocation, not a bigger budget. 3600 frames (a minute
|
||||
/// at 60fps) is 14KB and far past anything useful.</summary>
|
||||
private const int MaxTimeWindowFrames = 3600;
|
||||
|
||||
/// <summary>Play-menu carousel order. <c>PlayMenuCarouselModel.TryGetDisplayPriorityMap</c>
|
||||
/// looks this exact key up (literal at Assembly-CSharp/RRUI/Data/PlayMenuCarouselModel.txt:547)
|
||||
/// and, when it is absent, logs "Could not find GameConfig for PlayMenuCarouselOrdering_1"
|
||||
/// (:758) and leaves the carousels in prefab order. The value is split on ';' then '='
|
||||
/// into id → Int32 priority (:603, :632, :655); <c>PlayMenuCarouselController.DisplayOrderPriorityImpl</c>
|
||||
/// then reorders the carousel children with SetSiblingIndex.
|
||||
///
|
||||
/// The ids are the carousel ids serialized on that component in the 2023-03-21 build's
|
||||
/// Addressables bundle (3900770abc33861326d1865e454c0901.bundle, MonoBehaviour
|
||||
/// PlayMenuCarouselController/DisplayOrderPriorityImpl): continueplaying, friendsplaying,
|
||||
/// myclubhouses, myevents, mysubscriptions, recommended, hotrooms, topcreators, featured,
|
||||
/// myfavorites, recroomoriginals, new, limitedtimeevent. Priorities follow the live
|
||||
/// service's 2023 "PlayMenuCarouselOrdering_3" segment (higher first), with the
|
||||
/// time-limited event carousel put on top.</summary>
|
||||
public const string PlayMenuCarouselOrderingKey = "PlayMenuCarouselOrdering_1";
|
||||
|
||||
public const string PlayMenuCarouselOrdering =
|
||||
"limitedtimeevent=15;hotrooms=14;new=13;recroomoriginals=12;continueplaying=11;" +
|
||||
"myfavorites=10;friendsplaying=9;recommended=8;mysubscriptions=7;featured=5;" +
|
||||
"myclubhouses=4;myevents=3;topcreators=2";
|
||||
|
||||
public static IReadOnlyList<GameConfigurationSetting> ToGameConfigurations(
|
||||
RecCenterDoorSettings doors,
|
||||
DiscoveredGameConfigSettings discovered)
|
||||
DiscoveredGameConfigSettings discovered,
|
||||
CircuitsV2LimitSettings circuits)
|
||||
{
|
||||
var rows = new List<GameConfigurationSetting>();
|
||||
foreach (var door in NormalizeRecCenterDoors(doors.Doors).Doors)
|
||||
@@ -690,8 +786,26 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
new("splitTestSoftOverrides", gameConfig.SplitTestSoftOverrides),
|
||||
new("splitTestHardOverrides", gameConfig.SplitTestHardOverrides),
|
||||
new("splitTestSegmentProbabilities", gameConfig.SplitTestSegmentProbabilities),
|
||||
new(PlayMenuCarouselOrderingKey, PlayMenuCarouselOrdering),
|
||||
]);
|
||||
|
||||
var limits = NormalizeCircuitsV2Limits(circuits);
|
||||
rows.AddRange([
|
||||
new("UGC.CircuitsV2.Limit.Network", JsonSerializer.Serialize(limits.Network, JsonOptions)),
|
||||
new("UGC.CircuitsV2.Limit.Compute", Int(limits.Compute)),
|
||||
new("UGC.CircuitsV2.Limit.Chips", Int(limits.Chips)),
|
||||
new("UGC.CircuitsV2.Limit.CloudValues", Int(limits.CloudValues)),
|
||||
new("UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds",
|
||||
limits.CloudValuesAutosaveSeconds.ToString("0.###", CultureInfo.InvariantCulture)),
|
||||
new("UGC.CircuitsV2.Limit.UnculledChips", Int(limits.UnculledChips)),
|
||||
new("UGC.CircuitsV2.Limit.VisibleChips", Int(limits.VisibleChips)),
|
||||
new("UGC.CircuitsV2.Limit.InkPerChip", Int(limits.InkPerChip)),
|
||||
]);
|
||||
return rows;
|
||||
|
||||
// The client parses every scalar with int/float.TryParse under
|
||||
// InvariantCulture, so no thousands separators.
|
||||
static string Int(int value) => value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static PlayMenuTagSettings NormalizePlayMenuTags(
|
||||
@@ -800,6 +914,104 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
SplitTestSegmentProbabilities: JsonObjectOrDefault(settings.SplitTestSegmentProbabilities),
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clamps the limits to what the client can actually survive. Four of
|
||||
/// these are crash guards rather than taste:
|
||||
/// <list type="bullet">
|
||||
/// <item>Rpcs of 0 divides by zero in the CircuitsV2 debug subscreen,
|
||||
/// which renders <c>RpcRecentMax / Rpcs</c>.</item>
|
||||
/// <item>A time window sizes a per-frame ring buffer, so an
|
||||
/// unbounded value is an out-of-memory kill at circuit init.</item>
|
||||
/// <item>Chips sizes four list preallocations in the chip-culling
|
||||
/// manager, and InkPerChip × CloudValues is an unchecked int multiply,
|
||||
/// so both are capped.</item>
|
||||
/// <item>The client rejects the whole network array — falling back to
|
||||
/// the retail 5-RPC budget — unless some entry has
|
||||
/// NumPlayersForActivation 0, so the lowest entry is forced to 0.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static CircuitsV2LimitSettings NormalizeCircuitsV2Limits(
|
||||
CircuitsV2LimitSettings settings)
|
||||
{
|
||||
var seen = new HashSet<int>();
|
||||
var network = (settings.Network ?? [])
|
||||
.Where(entry => entry is not null)
|
||||
.Select(entry => new CircuitsV2NetworkLimit(
|
||||
NumPlayersForActivation: Math.Clamp(entry.NumPlayersForActivation, 0, 1000),
|
||||
Bytes: Math.Clamp(entry.Bytes, 1, UnlimitedBudget),
|
||||
BytesTimeWindowFrame60s: Math.Clamp(entry.BytesTimeWindowFrame60s, 1, MaxTimeWindowFrames),
|
||||
Rpcs: Math.Clamp(entry.Rpcs, 1, UnlimitedBudget),
|
||||
RpcTimeWindowFrame60s: Math.Clamp(entry.RpcTimeWindowFrame60s, 1, MaxTimeWindowFrames)))
|
||||
.OrderBy(entry => entry.NumPlayersForActivation)
|
||||
.Where(entry => seen.Add(entry.NumPlayersForActivation))
|
||||
.Take(16)
|
||||
.ToList();
|
||||
|
||||
if (network.Count == 0)
|
||||
network = DefaultCircuitsV2Limits().Network;
|
||||
else if (network[0].NumPlayersForActivation != 0)
|
||||
network[0] = network[0] with { NumPlayersForActivation = 0 };
|
||||
|
||||
return new CircuitsV2LimitSettings(
|
||||
Network: network,
|
||||
// Compute is the per-frame CPU budget: CircuitsV2Manager's frame
|
||||
// tick (CircuitsV2Manager.txt OCGDEIJJBAG ISIL 240, :21241)
|
||||
// builds a fresh BENFNKBKHEA(Compute) every frame, which sums
|
||||
// four int counters and compares them <= the budget
|
||||
// (Circuits.V2/BENFNKBKHEA.txt). Nothing is allocated from it,
|
||||
// but it is the only runaway-loop guard: a budget near
|
||||
// int.MaxValue lets the summed counters wrap negative and never
|
||||
// trip. 10M keeps 200x headroom under int.MaxValue.
|
||||
Compute: Math.Clamp(settings.Compute, 1, 10_000_000),
|
||||
// The chip-culling manager's ctor preallocates four
|
||||
// List<Chip>(Chips) (PAFDMBHNMBP.txt .ctor ISIL 053-108), so
|
||||
// 0 or a negative is an ArgumentOutOfRange and 1M is 32MB of
|
||||
// pointer arrays — the ceiling.
|
||||
Chips: Math.Clamp(settings.Chips, 1, 1_000_000),
|
||||
// CalculateActiveResourceCost computes
|
||||
// inkCost += InkPerChip * Math.Min(numChips, CloudValues) in
|
||||
// plain int (CreationManager.txt ISIL 389-415, :6136-6162).
|
||||
// With InkPerChip capped at 30000 below, 10000 keeps that
|
||||
// product at 3e8, well under int.MaxValue.
|
||||
CloudValues: Math.Clamp(settings.CloudValues, 0, 10_000),
|
||||
// A non-finite value would serialize as "NaN"/"Infinity", which
|
||||
// the client's float.TryParse happily accepts.
|
||||
CloudValuesAutosaveSeconds: float.IsFinite(settings.CloudValuesAutosaveSeconds)
|
||||
? Math.Clamp(settings.CloudValuesAutosaveSeconds, 1f, 3600f)
|
||||
: 300f,
|
||||
UnculledChips: Math.Clamp(settings.UnculledChips, 1, 1_000_000),
|
||||
VisibleChips: Math.Clamp(settings.VisibleChips, 1, 1_000_000),
|
||||
// InkPerChip is an ink cost against the room's hard-coded
|
||||
// 30000-ink cap (CostInfo(0x7530, 64, 101, Chips, CloudValues)
|
||||
// in CreationManager.OnCircuitsV2LimitsRefreshed, ISIL 060,
|
||||
// :3374). Past 30000 a single chip is unaffordable, and the
|
||||
// cap also bounds the int multiply noted on CloudValues. No
|
||||
// client code divides by it, so 0 is safe.
|
||||
InkPerChip: Math.Clamp(settings.InkPerChip, 0, 30_000),
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private static CircuitsV2LimitSettings ToCircuitsV2Limits(ServerSettingsEntity row)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(row.CircuitsV2LimitsJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<CircuitsV2LimitSettings>(
|
||||
row.CircuitsV2LimitsJson,
|
||||
JsonOptions);
|
||||
if (parsed is not null)
|
||||
return NormalizeCircuitsV2Limits(parsed) with { UpdatedAt = row.UpdatedAt };
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Bad settings should not break gameconfig bootstrap.
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultCircuitsV2Limits() with { UpdatedAt = row.UpdatedAt };
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WeeklyChallengeSettings(
|
||||
@@ -848,6 +1060,38 @@ public sealed record GameConfigurationSetting(
|
||||
DateTime? StartTime = null,
|
||||
DateTime? EndTime = null);
|
||||
|
||||
/// <summary>
|
||||
/// One entry of the <c>UGC.CircuitsV2.Limit.Network</c> array. The client
|
||||
/// sorts entries ascending by <see cref="NumPlayersForActivation"/> and
|
||||
/// activates the highest one whose threshold is at or below the room's max
|
||||
/// player capacity, so a single entry with a threshold of 0 applies
|
||||
/// everywhere. Field names are the literal JSON keys the client's
|
||||
/// Newtonsoft reader binds to <c>LimitConfig.NetworkConfig</c>.
|
||||
/// </summary>
|
||||
public sealed record CircuitsV2NetworkLimit(
|
||||
int NumPlayersForActivation,
|
||||
int Bytes,
|
||||
int BytesTimeWindowFrame60s,
|
||||
int Rpcs,
|
||||
int RpcTimeWindowFrame60s);
|
||||
|
||||
/// <summary>
|
||||
/// The <c>UGC.CircuitsV2.Limit.*</c> gameconfig family, read by
|
||||
/// <c>RecRoom.CircuitsV2.Config.LimitConfig</c>. Every field maps to one
|
||||
/// key; the client falls back to its baked-in defaults for any key the
|
||||
/// server omits or sends unparseably.
|
||||
/// </summary>
|
||||
public sealed record CircuitsV2LimitSettings(
|
||||
List<CircuitsV2NetworkLimit> Network,
|
||||
int Compute,
|
||||
int Chips,
|
||||
int CloudValues,
|
||||
float CloudValuesAutosaveSeconds,
|
||||
int UnculledChips,
|
||||
int VisibleChips,
|
||||
int InkPerChip,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record DiscoveredGameConfigSettings(
|
||||
int FriendsPostGamePromptUnderFriendCount,
|
||||
int FriendsSuggestFriendCodeOnFriendsScreenCount,
|
||||
|
||||
@@ -536,6 +536,8 @@ public static class DatabaseBootstrap
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""BaseRoomNamesJson"" text NOT NULL DEFAULT '';");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RoomBlobVersionClampDisabled"" boolean NOT NULL DEFAULT false;");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""CircuitsV2LimitsJson"" text NOT NULL DEFAULT '';");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -646,6 +648,18 @@ public static class DatabaseBootstrap
|
||||
// ignore the duplicate-column error; any other schema problem
|
||||
// will surface when EF reads ServerSettings.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN ""CircuitsV2LimitsJson"" TEXT NOT NULL DEFAULT '';");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists,
|
||||
// ignore the duplicate-column error; any other schema problem
|
||||
// will surface when EF reads ServerSettings.
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureInventorySchemaAsync(DorkNetDbContext db)
|
||||
@@ -855,6 +869,11 @@ public static class DatabaseBootstrap
|
||||
@"ALTER TABLE ""Clubs"" ADD COLUMN IF NOT EXISTS ""ClubChatEnabled"" boolean NOT NULL DEFAULT true;",
|
||||
@"ALTER TABLE ""ChatMessages"" ADD COLUMN IF NOT EXISTS ""ModerationState"" integer NOT NULL DEFAULT 0;",
|
||||
@"ALTER TABLE ""TestCases"" ADD COLUMN IF NOT EXISTS ""CommentsJson"" text NOT NULL DEFAULT '[]';",
|
||||
// 2023 profile edits (PUT account/me/{emoji,bannerimage,personalpronouns,identityflags}).
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""DisplayEmoji"" character varying(32) NOT NULL DEFAULT '';",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""BannerImageName"" character varying(256) NULL;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""PersonalPronouns"" integer NOT NULL DEFAULT 0;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""IdentityFlags"" integer NOT NULL DEFAULT 0;",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "ClubRolePermissions" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
@@ -906,10 +925,28 @@ public static class DatabaseBootstrap
|
||||
@"ALTER TABLE ""Clubs"" ADD COLUMN ""ClubChatEnabled"" INTEGER NOT NULL DEFAULT 1;",
|
||||
@"ALTER TABLE ""ChatMessages"" ADD COLUMN ""ModerationState"" INTEGER NOT NULL DEFAULT 0;",
|
||||
@"ALTER TABLE ""TestCases"" ADD COLUMN ""CommentsJson"" TEXT NOT NULL DEFAULT '[]';",
|
||||
// Repair rows created while the ClubPermissionsGalleryAndModeration
|
||||
// migration carried defaultValue: "" — the empty string is not
|
||||
// valid JSON and made the comment reader throw. Idempotent: ''
|
||||
// is never a legitimate value for this column.
|
||||
@"UPDATE ""TestCases"" SET ""CommentsJson"" = '[]' WHERE ""CommentsJson"" = '';",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""DisplayEmoji"" TEXT NOT NULL DEFAULT '';",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""BannerImageName"" TEXT NULL;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""PersonalPronouns"" INTEGER NOT NULL DEFAULT 0;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""IdentityFlags"" INTEGER NOT NULL DEFAULT 0;",
|
||||
};
|
||||
foreach (var sql in sqlite)
|
||||
{
|
||||
try { await db.Database.ExecuteSqlRawAsync(sql); } catch { }
|
||||
// Only the duplicate-column error is expected (the column already
|
||||
// exists via Migrate() or a previous boot). Anything else — locked
|
||||
// database, missing table, malformed schema — must fail startup
|
||||
// here rather than as a 500 on the first endpoint that needs the
|
||||
// column.
|
||||
try { await db.Database.ExecuteSqlRawAsync(sql); }
|
||||
catch (Exception ex) when (
|
||||
ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
var sqliteTables = new[]
|
||||
@@ -940,9 +977,11 @@ public static class DatabaseBootstrap
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");",
|
||||
};
|
||||
// CREATE ... IF NOT EXISTS is already idempotent; a failure here is a
|
||||
// real problem and must fail startup.
|
||||
foreach (var sql in sqliteTables)
|
||||
{
|
||||
try { await db.Database.ExecuteSqlRawAsync(sql); } catch { }
|
||||
await db.Database.ExecuteSqlRawAsync(sql);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -263,8 +263,13 @@ public static class RequestTracingExtensions
|
||||
private static string[] ResolveSuccessBodyPaths()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("Trace__SuccessBodyPaths");
|
||||
// "/thread" is deliberately absent from the defaults: successful
|
||||
// thread payloads carry private chat messages, which RedactSecrets
|
||||
// does not touch, and they would land in application logs and
|
||||
// PlayerLogEntry rows. Opt in explicitly via Trace__SuccessBodyPaths
|
||||
// for a short-lived diagnosis instead.
|
||||
if (configured is null)
|
||||
return ["/player", "/thread", "/clone", "/rooms/", "/matchmake/"];
|
||||
return ["/player", "/clone", "/rooms/", "/matchmake/"];
|
||||
|
||||
return configured
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
|
||||
@@ -234,6 +234,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<PlayerPresenceService>();
|
||||
services.AddSingleton<JoinTimeoutService>();
|
||||
services.AddSingleton<OnlinePresenceService>();
|
||||
// Friend presence fan-out; resolves its scoped DB per push.
|
||||
services.AddSingleton<PresenceBroadcastService>();
|
||||
services.AddSingleton<PlayerLogService>();
|
||||
// S3-compatible object storage (Garage in production, MinIO/disk in dev)
|
||||
// — holds profile images + room-blob bytes. Stateless wrapper around
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -139,10 +139,31 @@ interface DiscoveredGameConfigSettings {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CircuitsV2NetworkLimit {
|
||||
numPlayersForActivation: number;
|
||||
bytes: number;
|
||||
bytesTimeWindowFrame60s: number;
|
||||
rpcs: number;
|
||||
rpcTimeWindowFrame60s: number;
|
||||
}
|
||||
|
||||
interface CircuitsV2LimitSettings {
|
||||
network: CircuitsV2NetworkLimit[];
|
||||
compute: number;
|
||||
chips: number;
|
||||
cloudValues: number;
|
||||
cloudValuesAutosaveSeconds: number;
|
||||
unculledChips: number;
|
||||
visibleChips: number;
|
||||
inkPerChip: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function Settings({ embedded }: { embedded?: boolean } = {}) {
|
||||
const [settings, setSettings] = useState<ServerSettings | null>(null);
|
||||
const [weekly, setWeekly] = useState<WeeklyChallengeSettings | null>(null);
|
||||
const [gameConfigs, setGameConfigs] = useState<DiscoveredGameConfigSettings | null>(null);
|
||||
const [circuits, setCircuits] = useState<CircuitsV2LimitSettings | null>(null);
|
||||
const [rewardOptions, setRewardOptions] = useState<RewardOptions>({
|
||||
avatarItems: [],
|
||||
consumables: [],
|
||||
@@ -152,20 +173,23 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [weeklyBusy, setWeeklyBusy] = useState(false);
|
||||
const [gameConfigBusy, setGameConfigBusy] = useState(false);
|
||||
const [circuitsBusy, setCircuitsBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [s, w, rewards, configs] = await Promise.all([
|
||||
const [s, w, rewards, configs, limits] = await Promise.all([
|
||||
get<ServerSettings>('/settings'),
|
||||
get<WeeklyChallengeSettings>('/settings/weekly-challenges'),
|
||||
get<RewardOptions>('/settings/weekly-challenges/reward-options'),
|
||||
get<DiscoveredGameConfigSettings>('/settings/gameconfigs'),
|
||||
get<CircuitsV2LimitSettings>('/settings/circuits-limits'),
|
||||
]);
|
||||
setSettings(s);
|
||||
setWeekly(w);
|
||||
setRewardOptions(rewards);
|
||||
setGameConfigs(configs);
|
||||
setCircuits(limits);
|
||||
setErr(null);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
@@ -470,6 +494,39 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const updateCircuits = (patch: Partial<CircuitsV2LimitSettings>) => {
|
||||
setCircuits((current) => current ? { ...current, ...patch } : current);
|
||||
};
|
||||
|
||||
const updateCircuitsNetwork = (index: number, patch: Partial<CircuitsV2NetworkLimit>) => {
|
||||
setCircuits((current) => current
|
||||
? {
|
||||
...current,
|
||||
network: current.network.map((entry, i) => i === index ? { ...entry, ...patch } : entry),
|
||||
}
|
||||
: current);
|
||||
};
|
||||
|
||||
const saveCircuits = async (reset = false) => {
|
||||
if (!circuits && !reset) return;
|
||||
setCircuitsBusy(true);
|
||||
try {
|
||||
const body = reset ? defaultCircuitsLimits() : circuits;
|
||||
const updated = await api<CircuitsV2LimitSettings>('/settings/circuits-limits', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
setCircuits(updated);
|
||||
toast.push(
|
||||
reset ? 'CircuitsV2 limits reset.' : 'CircuitsV2 limits saved. Players must rejoin their room.',
|
||||
'success');
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setCircuitsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBtn = (
|
||||
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
|
||||
<RefreshCw className={busy ? 'animate-spin' : ''} />
|
||||
@@ -665,6 +722,17 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{circuits && (
|
||||
<CircuitsV2LimitsPanel
|
||||
settings={circuits}
|
||||
busy={circuitsBusy}
|
||||
onChange={updateCircuits}
|
||||
onNetworkChange={updateCircuitsNetwork}
|
||||
onSave={() => saveCircuits(false)}
|
||||
onReset={() => saveCircuits(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{weekly && (
|
||||
<div className="card !p-5 max-w-5xl mt-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
@@ -1068,19 +1136,168 @@ function DiscoveredGameConfigsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function CircuitsV2LimitsPanel({
|
||||
settings,
|
||||
busy,
|
||||
onChange,
|
||||
onNetworkChange,
|
||||
onSave,
|
||||
onReset,
|
||||
}: {
|
||||
settings: CircuitsV2LimitSettings;
|
||||
busy: boolean;
|
||||
onChange: (patch: Partial<CircuitsV2LimitSettings>) => void;
|
||||
onNetworkChange: (index: number, patch: Partial<CircuitsV2NetworkLimit>) => void;
|
||||
onSave: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const number = (key: keyof CircuitsV2LimitSettings, value: string) => {
|
||||
const n = Number.parseFloat(value);
|
||||
onChange({ [key]: Number.isFinite(n) ? n : 0 } as Partial<CircuitsV2LimitSettings>);
|
||||
};
|
||||
|
||||
const netNumber = (index: number, key: keyof CircuitsV2NetworkLimit, value: string) => {
|
||||
const n = Number.parseInt(value, 10);
|
||||
onNetworkChange(index, { [key]: Number.isFinite(n) ? n : 0 } as Partial<CircuitsV2NetworkLimit>);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card !p-5 max-w-5xl mt-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold text-ink-50">CircuitsV2 limits</h2>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
Served as the <span className="font-mono">UGC.CircuitsV2.Limit.*</span> gameconfig keys.
|
||||
Retail budgets a circuit 5 RPCs per frame and 1024 bytes per second and silently drops
|
||||
everything past that; DorkNet ships both effectively unlimited, and raises the room-size
|
||||
limits below about 10x past retail. Clients read these once
|
||||
when circuits start, so players must rejoin their room to pick up a change.
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-ink-500">
|
||||
Last changed {new Date(settings.updatedAt).toLocaleString()}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={onReset} disabled={busy} className="btn-secondary text-xs">
|
||||
<RefreshCw />
|
||||
Defaults
|
||||
</button>
|
||||
<button onClick={onSave} disabled={busy} className="btn-primary text-xs">
|
||||
{busy ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
{settings.network.map((entry, index) => (
|
||||
<div key={index} className="rounded-lg border border-ink-800 bg-ink-950/30 p-4 lg:col-span-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">
|
||||
Network budget{settings.network.length > 1 ? ` — from ${entry.numPlayersForActivation} players` : ''}
|
||||
</h3>
|
||||
<p className="mt-1 text-[11px] text-ink-500">
|
||||
Each budget is spent over a rolling window measured in frames, so a window of 1 means
|
||||
per frame and 60 means roughly per second at 60fps. Widening a window makes the limit
|
||||
stricter, not looser — raise the budget instead.
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<NumberConfig
|
||||
label="Rpcs"
|
||||
value={entry.rpcs}
|
||||
onChange={(v) => netNumber(index, 'rpcs', v)}
|
||||
/>
|
||||
<NumberConfig
|
||||
label="RpcTimeWindowFrame60s"
|
||||
value={entry.rpcTimeWindowFrame60s}
|
||||
onChange={(v) => netNumber(index, 'rpcTimeWindowFrame60s', v)}
|
||||
/>
|
||||
<NumberConfig
|
||||
label="Bytes"
|
||||
value={entry.bytes}
|
||||
onChange={(v) => netNumber(index, 'bytes', v)}
|
||||
/>
|
||||
<NumberConfig
|
||||
label="BytesTimeWindowFrame60s"
|
||||
value={entry.bytesTimeWindowFrame60s}
|
||||
onChange={(v) => netNumber(index, 'bytesTimeWindowFrame60s', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Circuit budget</h3>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<NumberConfig
|
||||
label="Compute"
|
||||
value={settings.compute}
|
||||
hint="Per-frame CPU budget for all circuits in the room. Retail 50000. Keep it finite: it is what stops a runaway loop. Max 10,000,000."
|
||||
onChange={(v) => number('compute', v)}
|
||||
/>
|
||||
<NumberConfig
|
||||
label="Chips"
|
||||
value={settings.chips}
|
||||
hint="Room chip cap. Retail 3000. Max 1,000,000."
|
||||
onChange={(v) => number('chips', v)}
|
||||
/>
|
||||
<NumberConfig
|
||||
label="UnculledChips"
|
||||
value={settings.unculledChips}
|
||||
hint="Chips past this count get culled. Retail 500. Higher costs frame time."
|
||||
onChange={(v) => number('unculledChips', v)}
|
||||
/>
|
||||
<NumberConfig
|
||||
label="VisibleChips"
|
||||
value={settings.visibleChips}
|
||||
hint="Visible-chip limit. Retail 500. Above Chips, adding a chip also charges InkPerChip."
|
||||
onChange={(v) => number('visibleChips', v)}
|
||||
/>
|
||||
<NumberConfig
|
||||
label="InkPerChip"
|
||||
value={settings.inkPerChip}
|
||||
hint="Ink COST per chip against the fixed 30000-ink room cap. Retail 15; 0 = chips use no ink. Max 30,000."
|
||||
onChange={(v) => number('inkPerChip', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-ink-400">Cloud variables</h3>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<NumberConfig
|
||||
label="CloudValues"
|
||||
value={settings.cloudValues}
|
||||
hint="Room cloud-variable cap. Retail 100. Max 10,000."
|
||||
onChange={(v) => number('cloudValues', v)}
|
||||
/>
|
||||
<NumberConfig
|
||||
label="CloudValuesAutosaveSeconds"
|
||||
value={settings.cloudValuesAutosaveSeconds}
|
||||
hint="Seconds between player save-data autosaves. Retail 300. Lower means more save traffic."
|
||||
onChange={(v) => number('cloudValuesAutosaveSeconds', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberConfig({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
hint?: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="label font-mono normal-case tracking-normal">{label}</span>
|
||||
<input className="input mt-1" type="number" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
{hint && <span className="mt-1 block text-[11px] text-ink-500">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1128,6 +1345,26 @@ function JsonConfig({
|
||||
);
|
||||
}
|
||||
|
||||
function defaultCircuitsLimits(): CircuitsV2LimitSettings {
|
||||
return {
|
||||
network: [{
|
||||
numPlayersForActivation: 0,
|
||||
bytes: 1_000_000_000,
|
||||
bytesTimeWindowFrame60s: 60,
|
||||
rpcs: 1_000_000_000,
|
||||
rpcTimeWindowFrame60s: 1,
|
||||
}],
|
||||
compute: 500_000,
|
||||
chips: 30_000,
|
||||
cloudValues: 1_000,
|
||||
cloudValuesAutosaveSeconds: 300,
|
||||
unculledChips: 3_000,
|
||||
visibleChips: 3_000,
|
||||
inkPerChip: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultGameConfigs(): DiscoveredGameConfigSettings {
|
||||
return {
|
||||
friendsPostGamePromptUnderFriendCount: 3,
|
||||
|
||||
@@ -37,3 +37,21 @@ covered by AGPL §13 — you must:
|
||||
changes you have made relative to the upstream version.
|
||||
|
||||
The full text of the AGPL-3.0 license is in LICENSE.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
Third-party components
|
||||
|
||||
DorkNet.Quest vendors And64InlineHook (src/third_party/And64InlineHook/),
|
||||
an arm64 inline-hook helper by Rprop, under the MIT License. Its license
|
||||
text is kept alongside the sources at
|
||||
DorkNet.Quest/src/third_party/And64InlineHook/LICENSE.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
Acknowledgements
|
||||
|
||||
RecFlare (https://github.com/recflare, by djdevin) is an independent
|
||||
open-source 2023 Rec Room server and Quest client. DorkNet.Quest follows
|
||||
its native-hook loading approach, and its implementation served as a
|
||||
cross-check for several 2023 wire contracts. No RecFlare code is included.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# DorkNet — `march-2023-03-21`
|
||||
|
||||
This is the **December 2020.12.18** branch of DorkNet — a self-hostable
|
||||
This is the **March 2023** branch of DorkNet — a self-hostable
|
||||
reimplementation of the Rec Room backend tuned to the wire protocol of
|
||||
the **Rec Room 2020.12.18** client build.
|
||||
the **Rec Room 2023.03.21** PC client build (`appVersion` `20230317`).
|
||||
|
||||
> ⚠️ **Not affiliated with Rec Room Inc. or Against Gravity.**
|
||||
> See [DISCLAIMER](../../blob/main/DISCLAIMER.md). DorkNet ships no Rec
|
||||
> Room game assets or modified binaries — you supply your own
|
||||
> legally-acquired Rec Room 2020.12.18 install.
|
||||
> legally-acquired Rec Room 2023.03.21 install.
|
||||
|
||||
If you're not sure which DorkNet branch you want, start at the
|
||||
[main branch's chart](../../blob/main/BRANCHES.md). The launcher on
|
||||
@@ -19,23 +19,44 @@ main also auto-detects your install.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Client build** | Rec Room 2020.12.18 |
|
||||
| **Version key** (sent as `X-DorkNet-Version` header) | `december_2020_12_18` |
|
||||
| **Version plugin** | `DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs` |
|
||||
| **Schema** | One EF `Initial` migration; SQLite + Postgres both work |
|
||||
| **Client build** | Rec Room 2023.03.21 (PC, IL2CPP); reports `appVersion` `20230317` |
|
||||
| **Accepted build IDs** (`api/versioncheck/v4?v=`, mapped in `appsettings.json` → `BuildIdToVersionKey`) | `20230317`, `20230321` |
|
||||
| **Version key** (sent as `X-DorkNet-Version` header) | `march_2023_03_21` |
|
||||
| **Version plugin** | `DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs` (generation `March2023`; the folder name is historical) |
|
||||
| **Schema** | EF migrations plus an idempotent schema bootstrap; SQLite + Postgres both work |
|
||||
| **Runtime topology** | Gateway-fronted microservices with dedicated service slices and a monolith fallback |
|
||||
| **Diverges from `march-2020-03-10` by** | ~170 files |
|
||||
| **Diverges from `december-2020-12-18` by** | ~220 files |
|
||||
|
||||
Subsystems present on this branch that the March branch doesn't have:
|
||||
This branch serves **only** the 2023 client. It does not advertise or
|
||||
answer the older 2020 build keys; 2020 and 2018 clients have their own
|
||||
branches.
|
||||
|
||||
- Chat threads + group DMs (`ChatThreads`, `ChatThreadMembers`)
|
||||
- Clubs surface (`clubs.{apex}/*` — announcements, categories, subscriptions)
|
||||
- Playlists (curated + user-created)
|
||||
- Weekly challenges
|
||||
- Room keys (purchasable room access)
|
||||
- Room roles (per-room co-owner / moderator / host grants)
|
||||
- Leaderboard channel metadata
|
||||
- Loading-screen tips
|
||||
What the 2023 client gets here that the 2020 branches don't have:
|
||||
|
||||
- **Every 2023 client route.** All HTTP routes the 2023.03.21 client can
|
||||
issue reach a handler under the verb it actually uses, and
|
||||
`DorkNet.Server.Tests/ClientRouteCoverageTests.cs` fails the build if one
|
||||
regresses. Routes, verbs and wire shapes are in
|
||||
[docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md).
|
||||
- **Realtime over the SignalR hub** (`hub/v1` on the notify host): numeric
|
||||
push ids, presence updates to friends that honour each player's
|
||||
online-status visibility, and account updates when someone edits their
|
||||
profile.
|
||||
- **Room saves and sub-rooms** through the storage host, plus cloning and
|
||||
private instances:
|
||||
[docs/recroom-2023-room-save.md](docs/recroom-2023-room-save.md).
|
||||
- **CircuitsV2 limits** served as gameconfig and lifted well past retail
|
||||
(network budgets, chips, compute, cloud values, ink):
|
||||
[docs/recroom-2023-circuitsv2-limits.md](docs/recroom-2023-circuitsv2-limits.md).
|
||||
- **Room shops**: room consumables, room currencies and room keys:
|
||||
[docs/recroom-2023-room-consumables.md](docs/recroom-2023-room-consumables.md).
|
||||
- **2023 wardrobe**: owned items, color variants keyed by combination GUID,
|
||||
saved outfits and custom avatar items (custom shirts).
|
||||
- **2023 profile**: bio, display emoji, pronouns, identity flags, banner
|
||||
image and online-status visibility.
|
||||
- Inventions, clubs (roles, permissions, gallery), chat threads, playlists,
|
||||
quest reward chests, leaderboards, and the influencer and moderation
|
||||
surfaces.
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +71,7 @@ dotnet tool install --global dotnet-ef
|
||||
**2. Clone this branch:**
|
||||
|
||||
```pwsh
|
||||
git clone --branch december-2020-12-18 https://github.com/DorkSquadRR/DorkNet
|
||||
git clone --branch march-2023-03-21 https://github.com/DorkSquadRR/DorkNet
|
||||
cd DorkNet
|
||||
```
|
||||
|
||||
@@ -92,53 +113,65 @@ notes, see
|
||||
|
||||
## Patching your client
|
||||
|
||||
```pwsh
|
||||
.\tools\install-melon.ps1 `
|
||||
-RecRoomPath "C:\Path\To\Recroom_Release_Data" `
|
||||
-PhotonAppId "<your-photon-realtime-app-id>" `
|
||||
-PhotonVoiceAppId "<your-photon-voice-app-id>"
|
||||
The 2023.03.21 PC build is laid out like this:
|
||||
|
||||
```
|
||||
<install>/
|
||||
RecRoom.exe <- the game; run this
|
||||
RecRoom_Data/ <- globalgamemanagers, resources.assets, levelN
|
||||
```
|
||||
|
||||
This installs the MelonLoader mod (`DorkNet.ClientMod`) into the
|
||||
client's `Mods/` folder and writes its config to
|
||||
`MelonLoader/UserData/dorknet-clientmod.json`. The mod rewrites all
|
||||
`*.rec.net` URIs to your configured DorkNet apex, swaps the Photon
|
||||
AppIds, and bypasses TLS verification on the client's HTTPS calls so
|
||||
self-signed certs work. (First run prints "launch the game once"; do
|
||||
that, then re-run with `-ResumeBuild` to finish — see the script's
|
||||
`-?` help.)
|
||||
The client is IL2CPP, so it is patched at runtime with **MelonLoader
|
||||
0.6.x** and the `DorkNet.ClientMod` mod:
|
||||
|
||||
### Standalone Quest build (experimental)
|
||||
1. Install MelonLoader 0.6.x into the install folder, launch
|
||||
`RecRoom.exe` once so MelonLoader generates its IL2CPP assemblies, then
|
||||
quit.
|
||||
2. Build the mod and copy it into `Mods\` next to `RecRoom.exe`:
|
||||
|
||||
The December Quest APK is also IL2CPP and can build the same
|
||||
`DorkNet.ClientMod` source against LemonLoader's Android runtime. The
|
||||
current Quest path is still device-tested manually: build the mod as a
|
||||
net8 LemonLoader DLL, install it into a LemonLoader-patched APK, then
|
||||
sideload the resigned APK.
|
||||
```pwsh
|
||||
$env:DOTNET_ROOT = "C:\Program Files\dotnet"
|
||||
dotnet build .\DorkNet.ClientMod -c Release
|
||||
Copy-Item .\DorkNet.ClientMod\bin\Release\DorkNet.ClientMod.dll "<install>\Mods\"
|
||||
```
|
||||
|
||||
From this branch:
|
||||
Close the game first; the copy fails while `RecRoom.exe` holds the DLL.
|
||||
3. Copy `DorkNet.ClientMod\dorknet-clientmod.json.template` to
|
||||
`<install>\UserData\dorknet-clientmod.json` (MelonLoader 0.6 keeps
|
||||
`UserData\` at the install root), then fill in your DorkNet apex and
|
||||
Photon AppIds. The config is read once at startup.
|
||||
|
||||
```pwsh
|
||||
dotnet build .\DorkNet.ClientMod\DorkNet.ClientMod.csproj `
|
||||
-p:TargetFrameworks=net8.0 `
|
||||
-p:MelonLoaderDir="C:\path\to\LemonLoader\melon_data\MelonLoader"
|
||||
```
|
||||
The mod rewrites all `*.rec.net` URIs to your DorkNet apex, swaps the
|
||||
Photon AppIds (they are hard-coded in the IL2CPP binary, so this has to
|
||||
happen at runtime), and can bypass TLS verification on the client's HTTPS
|
||||
calls so self-signed certs work.
|
||||
|
||||
Output lands at
|
||||
`DorkNet.ClientMod\bin\Debug\net8.0\DorkNet.ClientMod.dll`. Desktop
|
||||
builds still default to net6 and keep the historical flat output path.
|
||||
> `tools\install-melon.ps1` automates these steps for the **2020** layout
|
||||
> (`Recroom_Release.exe` / `Recroom_Release_Data`). It does not recognise
|
||||
> the 2023 `RecRoom.exe` / `RecRoom_Data` layout yet, so use the manual
|
||||
> steps above for 2023.
|
||||
|
||||
Quest notes:
|
||||
### Quest
|
||||
|
||||
- Target package: `com.AgainstGravity.RecRoom`.
|
||||
- Use a public HTTPS DorkNet host; keep `"EnableTlsTrustBypass": false`
|
||||
for Quest builds.
|
||||
- The Dec Photon hooks are discovered from the unique
|
||||
`PUNNetworkManager` methods returning `AppSettings` and
|
||||
`AuthenticationValues`, with the known Dec obfuscated names as fallback.
|
||||
- Standalone APK patching/resigning is not yet wrapped by
|
||||
`install-melon.ps1`; this is the porting path, not a public one-click
|
||||
installer yet.
|
||||
`DorkNet.Quest` patches the Rec Room 2023.03.21 Quest client (versionCode
|
||||
`2128210`) for sideloading onto a standalone Meta Quest. That build reports
|
||||
`appVersion` `20230317`, the same version as the PC client, so Quest and PC
|
||||
players can share rooms.
|
||||
|
||||
The patcher injects a single native hook library into the APK. The library
|
||||
redirects the Rec Room name server to your DorkNet host, supplies your
|
||||
Photon AppIds, and turns off the client's certificate pinning and
|
||||
image-signature checks so a self-hosted server works. A build without a
|
||||
Meta app ID lets players sign in with DorkNet credentials and no Meta
|
||||
account. Each hook checks a fingerprint of its target first, so the patch
|
||||
refuses to apply to any other build.
|
||||
|
||||
You supply your own extracted APK and OBB; no game binaries are
|
||||
redistributed. Build, install and troubleshooting steps are in
|
||||
[DorkNet.Quest/README.md](DorkNet.Quest/README.md), and the operator view
|
||||
is in [docs/quest.md](docs/quest.md).
|
||||
|
||||
> The Quest build is new and has not yet been tested on a headset.
|
||||
|
||||
### Debugging (opt-in — `DorkNet.DebugMod`)
|
||||
|
||||
@@ -204,6 +237,87 @@ filter for extra players, so player indexes above the four baked spawn
|
||||
slots can reuse existing quest spawn points instead of being left out.
|
||||
Default `0` (off).
|
||||
|
||||
### Rec Royale night mode (opt-in)
|
||||
|
||||
Rec Royale never shipped a night variant, but the client carries the room
|
||||
"mood" engine (`RecRoom.Core.Scene.SceneMoodSettings`) and ready-made
|
||||
night `MoodSetting` assets in `resources.assets`. Set
|
||||
`"RecRoyaleNightMode"` in `dorknet-clientmod.json` and the mod applies one
|
||||
of those moods (sky dome, sun, fog, bloom and colour grading) as soon as
|
||||
the `RecRoyale_Frontier` scene finishes loading, and unlocks the weapon
|
||||
flashlight prefab while it is on:
|
||||
|
||||
```json
|
||||
{
|
||||
"RecRoyaleNightMode": true,
|
||||
"RecRoyaleNightMood": "Night_Calm_Outdoor_Mood",
|
||||
"RecRoyaleNightScenes": ["RecRoyale"],
|
||||
"RecRoyaleNightFlashlights": true,
|
||||
"RecRoyaleNightFog": false,
|
||||
"RecRoyaleNightExposure": -3.5,
|
||||
"RecRoyaleNightSunScale": 0.15,
|
||||
"RecRoyaleNightAmbientScale": 0.2,
|
||||
"RecRoyaleNightForcePostProcessing": true,
|
||||
"RecRoyaleNightMatchSunLights": true,
|
||||
"RecRoyaleNightDisableFog": false,
|
||||
"RecRoyaleNightHideObjects": ["Cloud"],
|
||||
"RecRoyaleNightToggleKey": "N"
|
||||
}
|
||||
```
|
||||
|
||||
`RecRoyaleNightDisableFog` turns fog off entirely while night is on
|
||||
(`RenderSettings.fog` forced off and the mood's fog block pushed out of
|
||||
range); it is ignored when `RecRoyaleNightFog` is `true`.
|
||||
`RecRoyaleNightHideObjects` hides every renderer whose object or material
|
||||
name contains one of the entries; the default hides Frontier's daytime
|
||||
cloud meshes (`CloudPlane`, `Clouds`, the `_Cloud_RecRoyale_Frontier_Mat`
|
||||
static batch). Both are restored on toggle-off or scene change.
|
||||
|
||||
The mood engine only ever drives `RenderSettings.sun`, which on Frontier
|
||||
is the sun-disc light, not the map's key light, so daylight would keep
|
||||
casting. `RecRoyaleNightMatchSunLights` (default `true`) copies the
|
||||
engine-driven sun's rotation and colour onto every other directional
|
||||
light and scales their intensity by `RecRoyaleNightSunScale`.
|
||||
|
||||
Two more RRO quirks the mod works around: the mood skybox material the
|
||||
engine swaps in (`MoodConfig.SkyBoxMaterial`) is not loaded outside
|
||||
custom-room scenes, so the mod builds one on the `Custom/SkyBox` shader
|
||||
and hands it to the engine (otherwise the sky is plain black and the
|
||||
engine refuses to touch sky colours or the sun); and post-exposure only
|
||||
shows when the scene Volume is on and the camera renders
|
||||
post-processing, which `RecRoyaleNightForcePostProcessing` (default
|
||||
`true`) forces while night mode is active.
|
||||
|
||||
Brightness: RRO maps are lightmapped, and baked lighting ignores the sun
|
||||
and ambient changes a mood makes, so the mod also darkens the mood data
|
||||
itself before applying it. `RecRoyaleNightExposure` is an EV offset added
|
||||
to the mood's colour-grading post-exposure (a full-frame post effect, so
|
||||
it dims lightmapped surfaces too; `0` = mood default), and
|
||||
`RecRoyaleNightSunScale` / `RecRoyaleNightAmbientScale` multiply the
|
||||
mood's sun and ambient intensities (`1` = unchanged). Raise the exposure
|
||||
toward `0` if it is too dark.
|
||||
|
||||
RRO scenes have no room data blob, so the mood engine starts disabled
|
||||
there; the mod replays the room-data loader's bring-up (disable, capture
|
||||
the scene's own lighting, enable), points `RenderSettings.sun` at the
|
||||
scene's directional light (Frontier ships without the reference, which
|
||||
would otherwise skip every sun change), and re-captures once the mood
|
||||
skybox material is in place so the sky colours apply. `RecRoyaleNightFog`
|
||||
is off by default: the mood's fog block is swapped for one captured from
|
||||
the scene, so fog stays as the map author left it.
|
||||
|
||||
Shipped moods: `Night_Calm_Outdoor_Mood` ("Calm Night"),
|
||||
`Night_Spooky_Outdoor_Mood` ("Spooky Night"), `Night_Wild_Outdoor_Mood`
|
||||
("Wild Night"), `Night_StuntRunner_Outdoor_Mood` and `OuterSpace_Mood`.
|
||||
`RecRoyaleNightToggleKey` is an optional `UnityEngine.KeyCode` name that
|
||||
flips night on/off in-game (off restores the scene's own mood); setting a
|
||||
key also arms the feature when `RecRoyaleNightMode` is `false`. This is
|
||||
purely **client-side**: the mod calls the mood engine's apply step
|
||||
directly and never touches the networked room-mood index, so other players
|
||||
keep their own view. Baked lightmaps are not changed, so interiors stay
|
||||
lit; the mood's colour grading does the darkening. Default `false` (off).
|
||||
Wire-level notes: `docs/recroom-2023-scene-moods.md`.
|
||||
|
||||
---
|
||||
|
||||
## Code map
|
||||
@@ -275,8 +389,8 @@ troubleshooting, see [docs/admin.md](docs/admin.md).
|
||||
|
||||
## Database & migrations
|
||||
|
||||
One EF migration (`Migrations/<timestamp>_Initial.cs`) captures the
|
||||
schema. SQLite dev DBs apply it via `Database.Migrate()`; Postgres uses
|
||||
The consolidated `Initial` migration plus the migrations added since
|
||||
(`Migrations/`) capture the schema. SQLite dev DBs apply it via `Database.Migrate()`; Postgres uses
|
||||
`EnsureCreated()` behind a transaction-scoped advisory lock. The
|
||||
entity model is the single source of truth — to add a column, edit the
|
||||
entity, boot, done (on SQLite — Postgres prod needs a migration).
|
||||
@@ -344,11 +458,12 @@ public internet:
|
||||
- **First account becomes admin.** The first account created on a fresh DB is
|
||||
granted `IsAdmin` so there's always a root admin. On a public server, create
|
||||
your admin account first and verify no one else beat you to it.
|
||||
- **Online status = Everyone.** Presence reports `StatusVisibility = 0`
|
||||
(Everyone) so every player appears online to every other player, matching
|
||||
the [everyone-is-friends](#everyone-is-friends-toggle) convenience. A public
|
||||
server may instead want to persist and honor each player's own visibility
|
||||
preference.
|
||||
- **Online status visibility.** Each player's in-game choice (Public,
|
||||
Friends only, Favorite friends only, Appear offline) is stored and
|
||||
honoured by both `GET /player` and the live presence pushes. Players who
|
||||
never pick one are Public. "Friends only" follows the
|
||||
[everyone-is-friends](#everyone-is-friends-toggle) toggle, so with that
|
||||
toggle on it shows them to everyone.
|
||||
- **Room / matchmaking capacity.** New matchmade sessions and subrooms default
|
||||
to **8** players and are capped at **40** (`GameSessionService`,
|
||||
`RoomSceneEntity.MaxPlayers`, admin `MaxCapacity` clamp). The in-game "Max
|
||||
@@ -371,12 +486,38 @@ LAN/friends infrastructure unless you harden the items above.
|
||||
See [main's CONTRIBUTING guide](../../blob/main/CONTRIBUTING.md) for
|
||||
the project-wide rules.
|
||||
|
||||
Branch-specific: schema changes on this branch don't flow to
|
||||
`march-2020-03-10` automatically. If a fix applies to both, cherry-pick
|
||||
Branch-specific: this is the repo's default branch, and Dependabot and
|
||||
server PRs target it. Schema changes here don't flow to the 2020 or 2018
|
||||
branches automatically. If a fix applies to more than one, cherry-pick
|
||||
it across. Don't rebase across branches.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Thanks to [RecFlare](https://github.com/recflare), an independent
|
||||
open-source 2023 Rec Room server by djdevin, for sharing their work in the
|
||||
open. Their server and Quest client were a valuable second opinion while
|
||||
building this branch:
|
||||
|
||||
- **Quest:** `DorkNet.Quest` follows their approach of a single native hook
|
||||
library loaded through a `DT_NEEDED` entry on `libmain.so`. Their notes
|
||||
also showed which client checks a self-hosted server has to get past.
|
||||
- **Wire contracts:** their implementation pointed us at several things we
|
||||
then confirmed in our own client disassembly. These include the profile
|
||||
edit form fields, the saved-outfit keys, the `{Success, Error}` envelope
|
||||
behind `isCreationAllowedForAccount`, and the gameconfig catalogue,
|
||||
including the `PlayMenuCarouselOrdering_*` format.
|
||||
- **Accounts:** their warning that a Quest client without a Meta account
|
||||
sends the same platform id from every headset led to the placeholder
|
||||
platform-id handling in `PlayerService`.
|
||||
|
||||
No RecFlare code is included in DorkNet. RecFlare targets a newer client
|
||||
(20230414), so every finding was re-verified against the 2023.03.21 build
|
||||
before use.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE). Hosted forks must publish their source.
|
||||
|
||||
+157
@@ -139,6 +139,45 @@ in S3 is modified. Toggle endpoint:
|
||||
`{"Disabled": bool}`. Clamped responses are served with a 60s edge TTL so
|
||||
flipping the toggle lands within a minute.
|
||||
|
||||
## CircuitsV2 limits (`/settings`)
|
||||
|
||||
`ServerSettingsEntity.CircuitsV2LimitsJson` backs the
|
||||
`UGC.CircuitsV2.Limit.*` gameconfig keys the client reads into
|
||||
`RecRoom.CircuitsV2.Config.LimitConfig`. The panel's headline setting is
|
||||
the network budget: retail allows a circuit **5 RPCs per frame** and
|
||||
**1024 bytes per 60 frames**, and silently **drops** every send past
|
||||
that, which is the usual cause of a busy CV2 room appearing to desync.
|
||||
DorkNet ships both budgets at 1,000,000,000.
|
||||
|
||||
Two things about this are counter-intuitive enough to be worth stating:
|
||||
|
||||
- The budgets are spent over a rolling window measured in **frames**, so
|
||||
widening a window makes the limit *stricter*, not looser. Raise the
|
||||
budget, not the window.
|
||||
- The **byte** budget is checked before the **RPC** budget, so raising
|
||||
`Rpcs` on its own leaves circuits blocked. Both have to move together.
|
||||
- `InkPerChip` is a **cost** charged against a 30000-ink room cap that is
|
||||
hard-coded in the client, so lowering it is what gives circuits more
|
||||
room. DorkNet ships it at 0.
|
||||
|
||||
The room-size limits ship at about 10x retail: Compute 500000, Chips
|
||||
30000, CloudValues 1000, and UnculledChips/VisibleChips 3000.
|
||||
CloudValuesAutosaveSeconds stays at 300.
|
||||
|
||||
Values are clamped on save, and most of the clamps are crash guards
|
||||
rather than taste. `Rpcs` of 0 divides by zero in the client's CV2 debug
|
||||
subscreen. A time window allocates one array slot per frame, so an
|
||||
unbounded window is an out-of-memory kill at circuit init. Chips sets
|
||||
the size of four list preallocations. InkPerChip × CloudValues is an
|
||||
unchecked int multiply. Compute is the client's only runaway-loop
|
||||
guard, so it has to stay finite. Full
|
||||
reasoning and the disassembly it came from are in
|
||||
`docs/recroom-2023-circuitsv2-limits.md`.
|
||||
|
||||
Endpoints: `GET`/`POST api/admin/v1/settings/circuits-limits`. The client
|
||||
builds its rate limiter once when circuits start, so **players must
|
||||
rejoin their room** before a change takes effect.
|
||||
|
||||
## Per-sub-room max players (`/rooms` → room detail)
|
||||
|
||||
Player caps are two separate settings, and conflating them is the easy
|
||||
@@ -179,6 +218,124 @@ Enforcement caveat carried over from `RoomEntity.MaxCapacity`: this is the
|
||||
advertised cap. The client never sets Photon `RoomOptions.MaxPlayers`, so
|
||||
hard enforcement still needs a ClientMod Photon patch.
|
||||
|
||||
## RRO room data blob — Maker Pen and chips in a Rec Room Original (`/rooms` → room detail → Sub-rooms)
|
||||
|
||||
Baked Rec Room Originals (Rec Royale, Paintball, Laser Tag, …) are seeded
|
||||
with an empty `RoomSceneEntity.DataBlobName` and empty
|
||||
`RoomEntity.CurrentDataBlobName`. Every details builder and matchmaking
|
||||
resolver (`RoomsController.CurrentOrSyntheticDataBlobName`,
|
||||
`GoToController.ResolveInitialDataBlobAsync`,
|
||||
`MatchPlayerController`) turns that into an empty `DataBlob`, and the 2023
|
||||
client reads an empty blob name as "this room has no room data": it skips
|
||||
the persistence download entirely, so there are no room roles (no Maker
|
||||
Pen despite the CDN's RRO role overlay), no CV2 room-settings chips, no
|
||||
room mood engine and no saves. Real Rec Room served these rooms a blob —
|
||||
Rec Royale's game-rule chip (`RecRoyaleSolos_GameRuleWrapperCircuit` /
|
||||
`RecRoyaleSquads_GameRuleWrapperCircuit`, prefabs in `resources.assets`)
|
||||
is delivered that way; the Frontier scene has no baked game rule.
|
||||
|
||||
The Sub-rooms tab shows, per sub-room, the blob the client will actually
|
||||
be told to download (`effectiveBlobName`) and, for baked originals, two
|
||||
actions:
|
||||
|
||||
- **Enable Maker Pen blob** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/enable`.
|
||||
Stamps the synthetic default name (`room_{id}_dorknet_v8.dat` for the
|
||||
entry scene, `room_{id}_dorknet_v8_sub{n}.dat` otherwise) on the scene
|
||||
and, for sub-room 0, on the room. No resolver changes are involved: they
|
||||
already return a non-empty name verbatim, and `CdnController` already
|
||||
answers that name for AG rooms with the RRO-editable blob (permissive
|
||||
room roles, no objects) while nothing is saved. `IsRRO` is derived from
|
||||
`IsAGRoom`, so the client keeps loading the baked map and only
|
||||
additionally fetches the blob. Idempotent; a no-op on a sub-room that
|
||||
already has a blob. Seeded originals such as Rec Royale have **no
|
||||
`RoomScenes` row at all** ("Scenes 0"); the list synthesises a virtual
|
||||
"Home" entry (`isVirtual: true`, max players = room capacity) and enable
|
||||
creates the real row with the game-side defaults
|
||||
(`RoomsController.GetOrCreateSceneForMutationAsync`).
|
||||
What the CDN hands back for that name while nothing is saved is a
|
||||
**per-room stub** built by `RoomDataBlobService.BuildRroEditableBlob`:
|
||||
the skeleton of a real save (Rec Center's `room_100_v1.dat` was the
|
||||
reference) — `version` 16 (the client's ceiling; the old roles-only stub
|
||||
was stamped 19 and the client fetched it and silently loaded the room
|
||||
with no roles, i.e. no Maker Pen), `activity_id` = the room's
|
||||
`LocationReplicationId`, `last_save_time`, empty connectable graph /
|
||||
object model, scene settings and the four `room_mood_*` blocks, plus the
|
||||
permissive role collection. The miss path now runs the same 2023
|
||||
version clamp as the S3-hit path. It also carries the room's own three
|
||||
**game-role objects** (a default role named after the room, plus the
|
||||
built-in "In-Game" and "Eliminated" roles, well-known GUIDs shared by
|
||||
every room save — Rec Center and Stunt Runner both have exactly these).
|
||||
The 2023 client grants Maker Pen off the player's *game* role
|
||||
(`ICreatorRole.CreatorRoleCanUseMakerPen` on `GameRole`), so a blob with
|
||||
no role objects leaves even a co-owner with nothing to hold — that was
|
||||
the second reason "enable + co-owner" still showed no Maker Pen. The
|
||||
default role sets `can_use_maker_pen` to true, matching the permissive
|
||||
room-role overlay; the built-in roles mirror the reference saves.
|
||||
For the two Rec Royale rooms the stub also carries the room's
|
||||
**game-rule chip** (`RecRoyaleSolos_GameRuleWrapperCircuit` prefab id
|
||||
`983db81a…`, `RecRoyaleSquads_…` `857f7500…`, read from the client's
|
||||
spawnable-tool table; a saved Squads chip is header + prefab id + tag +
|
||||
node id + an empty wrapper payload), so the match logic exists on first
|
||||
join without anyone spawning it by hand.
|
||||
- **Upload save** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/upload?mode=full|basics`,
|
||||
multipart `file` = a real `PersistedRoomData` save from ANY client
|
||||
version (`.binpb` / `.room` / `.dat`, e.g. a RecNet export of a custom
|
||||
Rec Royale room). The server rewrites it for this room
|
||||
(`RoomDataBlobService.PrepareUploadedRoomBlob`): `activity_id` → the
|
||||
room's `LocationReplicationId`, drops `output_log`, `sub_room_id` and
|
||||
every field the 2023 schema doesn't know, clamps the header versions to
|
||||
the 2023 maxima, and in `basics` mode keeps only the game-role (field 50)
|
||||
and game-rule (fields 40/41) objects while stripping the circuit payloads
|
||||
(`circuit_data`, connectable graph, `circuit_v2_data` — a newer
|
||||
CircuitsV2 graph is rejected by the client's own CV2 version gate no
|
||||
matter what). Stored under a fresh `room_{id}_upload_*.dat`, recorded in
|
||||
`RoomDataBlobs`, and the sub-room (and room, for sub-room 0) is pointed
|
||||
at it. On serve the CDN applies the RRO role overlay and clamp exactly
|
||||
as for Rec Center's saved blob. Response carries a `summary`
|
||||
(`viewsIn/viewsKept/roleViews/gameRuleViews/versionIn/versionOut`).
|
||||
- **Reset to baked scene** — `POST api/admin/v1/rooms/{id}/subrooms/{subRoomId}/blob/reset`.
|
||||
Clears both pointers. This is the escape hatch if a save left the room
|
||||
unplayable. Saved blobs stay in `RoomDataBlobs`, so the in-game restore
|
||||
list can bring one back later.
|
||||
|
||||
Both are audited (`enable_subroom_blob`, `reset_subroom_blob`). Who can
|
||||
then save the room in game is the normal rule in `SaveDataCore`: owner,
|
||||
any admin, or an accepted co-owner (`RoomRoles.Role == 0`). Grant a
|
||||
co-owner role from the Roles tab if a non-admin should be able to save.
|
||||
|
||||
Procedure that makes Rec Royale playable AND editable:
|
||||
|
||||
1. Enable the blob on the Solos room's sub-room 0 (and the Squads room's).
|
||||
2. Join as an admin; the Maker Pen is available. Spawn the room's game-rule
|
||||
chip (`RecRoyaleSolos_GameRuleWrapperCircuit` in Solos,
|
||||
`RecRoyaleSquads_GameRuleWrapperCircuit` in Squads) — via the palette's
|
||||
Game Rules section, or the debug console's `DebugSpawnTool` if it is not
|
||||
listed — then save. Without that chip there is no `BattleRoyaleManager`
|
||||
in the scene and the match never starts.
|
||||
3. From then on the saved blob (with the game-rule chip and whatever
|
||||
room-settings chips were placed) is what everyone loads. If a save
|
||||
breaks the room, Reset to baked scene.
|
||||
|
||||
Tests: `RroRoomBlobTests`.
|
||||
|
||||
### Why Rec Royale still has no Maker Pen after all of the above
|
||||
|
||||
The Maker Pen permission check (`AGMBHDNGOEH.IJEIEBGJJLB`) first runs a
|
||||
room precondition (`LALJEMHEIBH`) that, via `MLKAFHHHEGA.GHPIBJPNOFF` →
|
||||
`PNPLLCBBMJP.JGPBNEHFLJA`, looks the current room up in
|
||||
`AGRoomRuntimeConfig` — a table **baked into the client's
|
||||
`resources.assets`** — and returns a per-original boolean. Rec Center and
|
||||
Stunt Runner allow creation there; Rec Royale does not. No blob, role or
|
||||
server response changes that table, and it is consulted only for rooms the
|
||||
client considers originals (`IsRRO` = our `IsAGRoom`).
|
||||
|
||||
Server-side experiment: clone Rec Royale, flip the clone's **Rec Room
|
||||
Original (IsRRO)** checkbox off on the General tab
|
||||
(`rooms/{id}/props` `IsAGRoom: false`), upload a real save to it, join. If
|
||||
the client still loads the Frontier scene for a non-original, Rec Royale
|
||||
becomes an editable template; if the scene fails to load, the table gate
|
||||
has to be bypassed client-side (ClientMod postfix on `GHPIBJPNOFF`).
|
||||
|
||||
## Test cases ↔ GitHub issues
|
||||
|
||||
QA test cases can file and track GitHub issues. Configure with:
|
||||
|
||||
+11
-1
@@ -50,12 +50,22 @@ DorkNet.sln
|
||||
| `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, path-routed admin API, testcase routes | .NET 9 |
|
||||
| `DorkNet.Services.Web` | Public site, admin static host, same-origin admin browser API, feed static host, site API | .NET 9 |
|
||||
| `DorkNet.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 |
|
||||
| `DorkNet.Quest` | native hook library + build scripts for the Meta Quest client | C++/arm64 |
|
||||
|
||||
`ClientMod` applies the client-side patches needed to point the 2020
|
||||
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`).
|
||||
|
||||
`DorkNet.Quest` does the same jobs for the Meta Quest (Android/IL2CPP)
|
||||
2023.03.21 client, where there is no MelonLoader: one native library
|
||||
(`libdorknet.so`) is injected via a `DT_NEEDED` entry on `libmain.so`
|
||||
and installs arm64 inline hooks (host rewrite, Photon ids, TLS/pin and
|
||||
image-signature bypass, integrity-callback neutralisation). See
|
||||
[`quest.md`](quest.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -400,6 +400,18 @@ means losing post-cutover writes unless you migrate them back.
|
||||
|
||||
---
|
||||
|
||||
## Meta Quest client
|
||||
|
||||
DorkNet also serves a sideload-patched Rec Room 2023.03.21 **Quest** client
|
||||
(versionCode 2128210, appVersion `20230317` — the same version the PC client
|
||||
reports, so Quest and PC players share rooms). The same DNS/TLS/Photon/image-
|
||||
signing setup here serves it; the only Quest-specific server behaviour is the
|
||||
placeholder-platform-id handling in `PlayerService`. See [`quest.md`](quest.md)
|
||||
for the operator view and [`../DorkNet.Quest/README.md`](../DorkNet.Quest/README.md)
|
||||
for building and installing the patched APK.
|
||||
|
||||
---
|
||||
|
||||
## Photon Cloud
|
||||
|
||||
Use your own Photon AppIds. Rec Room's original Photon AppIds reject
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Meta Quest client
|
||||
|
||||
DorkNet can be played on a standalone Meta Quest headset using a
|
||||
sideload-patched Rec Room **2023.03.21** client. The patcher and the full build
|
||||
and install guide live in [`../DorkNet.Quest/`](../DorkNet.Quest/README.md);
|
||||
this page is the server operator's view.
|
||||
|
||||
## Which build
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Rec Room version | `2023.03.21` (versionName `20230317`, versionCode `2128210`) |
|
||||
| Package (store) | `com.AgainstGravity.RecRoom`, arm64-v8a, IL2CPP |
|
||||
| Ships | single APK + a ~1.4 GB OBB |
|
||||
|
||||
This build reports appVersion **`20230317`** — identical to the PC
|
||||
`march-2023-03-21` client. The server hardcodes `"20230317"` in presence /
|
||||
match / notification responses, so **Quest and PC players interoperate with no
|
||||
server change**: the version gate (`GET api/versioncheck/v4`), the presence
|
||||
`appVersion` string, and Photon room versioning all line up. Do **not** patch a
|
||||
different Quest build (e.g. `20230414`) against this branch — a different
|
||||
appVersion reads as "version mismatch, can't join".
|
||||
|
||||
## How it connects
|
||||
|
||||
There is no MelonLoader on Android, so `DorkNet.Quest` does the ClientMod +
|
||||
Photon-byte-patch jobs with one native library (`libdorknet.so`) injected via a
|
||||
`DT_NEEDED` entry on `libmain.so`. At runtime it:
|
||||
|
||||
- rewrites `<service>.rec.net` → `<service>.<ServerHost>` (for a server on
|
||||
`Domain:HostStyle = hyphen`, set `ServerHost = {service}-<env>.<apex>`) (the name server then
|
||||
hands out every other service URL — same bootstrap as the PC client);
|
||||
- injects the operator's Photon app ids and fixed region (`PhotonRegion`; it
|
||||
must match the PC clients' `PhotonCloudRegion` or the two never meet);
|
||||
- accepts the self-hosted TLS chain and image signatures;
|
||||
- neutralises the integrity callbacks that would otherwise reject a re-signed,
|
||||
hook-loaded sideload.
|
||||
|
||||
So the **same DNS, TLS, Photon and image-signing setup from
|
||||
[`deploy.md`](deploy.md) serves Quest** — every `*.<apex>` subdomain the PC
|
||||
client uses must resolve and be covered by the cert.
|
||||
|
||||
## Accounts without a Meta account
|
||||
|
||||
If the build is made with a blank `OculusAppId` (the no-Meta-account build), the
|
||||
client can't produce a real Oculus platform identity, so **every headset sends
|
||||
the placeholder platform user id `1`**. The server must not treat that as an
|
||||
identity, or all those players collapse into one shared account and are offered
|
||||
each other's "remembered logins".
|
||||
|
||||
DorkNet handles this server-side in `PlayerService`
|
||||
(`IsPlaceholderPlatformId`): platform ids `0`, blank and `1` are never stored on
|
||||
an account, never matched by the cached-login lookup
|
||||
(`cachedlogin/forplatformid`), and never used to synthesise a device id — those
|
||||
players are keyed by `deviceId` alone, exactly like the PC Steam-emulator path.
|
||||
A real Meta platform id (the Meta build) still tags and lists normally.
|
||||
|
||||
Thanks to [RecFlare](https://github.com/recflare) for documenting this
|
||||
pitfall in their Quest client notes.
|
||||
|
||||
> Covered by `DorkNet.Server.Tests/QuestPlaceholderPlatformIdTests.cs`. **Never
|
||||
> add a rule that accepts platform id `1` as a real identity** — the whole
|
||||
> no-Meta fleet sends the same value.
|
||||
|
||||
## Operator checklist
|
||||
|
||||
1. DNS + TLS for every `*.<apex>` service subdomain (see `deploy.md`).
|
||||
2. Your own Photon Realtime/Voice app ids (see `deploy.md` → Photon Cloud).
|
||||
3. Image signing key configured (`ImageSignatureService`), as for PC.
|
||||
4. Build with `ServerHost=<apex>`, the Photon ids and region baked in, passing
|
||||
`-Obb` so you get a release folder (`DorkNet-<ver>.xapk` plus
|
||||
`install.cmd`/`install.sh`). Players run the installer with the headset
|
||||
plugged in; the app appears as **DorkNet** under Unknown Sources.
|
||||
|
||||
No server component is Quest-specific beyond the placeholder-platform-id
|
||||
handling above.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user