commit d471b418b956b804ff99d2bfc4b9a1b940945df1 Author: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Wed May 27 01:06:12 2026 +0300 Import march-2020-03-10 from private repo (scrubbed 2026-05-27) Targets Rec Room 2020.03.10 client (also serves 2020.03.06). Re-import after adding: - DorkNet.ClientMod/dorknet-clientmod.json.template — config schema the launcher's manifest-driven patcher renders into UserData/. - tools/build-patcher-zip.ps1 — produces dorknet-clientpatch-{key}.zip for v1-march-* Release uploads. - .github/workflows/release.yml — on tag push publishes both dorknet-server-march_2020_03_10-win-x64.zip and dorknet-clientpatch-march_2020_03_10.zip as Release assets. Same scrub rules as december; tools/build-patcher-zip.ps1 added to the maintainer-side allow-list so it survives. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1ddd462 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,72 @@ +# Keep the build context small + reproducible. Anything not strictly +# needed for `dotnet restore && dotnet publish` is excluded. + +# Build artefacts +**/bin/ +**/obj/ +**/*.user +**/*.suo + +# Dev-only configs (Local layer must NEVER ship into a container — +# secrets are passed by env vars on Coolify). +**/appsettings.Local.json +**/appsettings.Development.json + +# Local runtime data +**/data/ +**/server.log +**/*.log + +# IDE noise +.vs/ +.vscode/ +.idea/ + +# Git +.git/ +.gitignore +.gitattributes + +# Reverse-engineering scratch (not needed for production server) +Cpp2ILOutput/ +Cpp2IL_CS/ +Cpp2IL_ISIL/ +Il2CppDump/ +RR_protos/ +RR_ProtobufDefinitions.zip +GameAssembly.dll +global-metadata.dat +il2cpp.h +dump.cs +script.json +RoomPersistenceManager_decompiled.txt + +# Local TLS certs (mkcert) — never copy into the image +*.pem +hosts.dorknet.txt + +# Tools that aren't part of the server runtime +tools/ +Tools/ + +# Vite SPA build leftovers — node_modules is huge and the dist is +# rebuilt fresh by the npm-build stage in the Dockerfile, so neither +# should ship into the docker context. The source under admin-ui/src +# and admin-ui/{package.json,vite.config.ts,...} is what we copy in. +DorkNet.Server/admin-ui/node_modules/ +DorkNet.Server/admin-ui/dist/ +DorkNet.Server/admin-ui/.vite/ +DorkNet.Server/wwwroot/admin/assets/ +DorkNet.Server/wwwroot/admin/index.html +DorkNet.Server/wwwroot/admin/*.map + +# Top-level docs / planning +README.md +LICENSE +NOTICE +BUILD_PROPER_PLAN.md +nginx.conf + +# Other stages of the workflow +*.md +docs/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..96ef7b8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: Release (march-2020-03-10) + +# Auto-publishes dorknet-server + dorknet-clientpatch zips to a +# GitHub Release on tag push. The launcher (on the public main +# branch) fetches the most-recent tag matching its prefix and +# downloads these assets, so the tag format MUST start with the +# branch's release_tag_prefix declared in versions.json. + +on: + push: + tags: + - 'v1-march-*' + +jobs: + release: + runs-on: windows-latest + + env: + VERSION_KEY: march_2020_03_10 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + # ── Server self-contained publish ──────────────────────────────── + - name: Publish DorkNet.Server (win-x64, self-contained) + run: | + dotnet publish DorkNet.Server/DorkNet.Server.csproj ` + -c Release -r win-x64 --self-contained ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:EnableCompressionInSingleFile=true ` + -o publish/server + + - name: Zip server publish + run: | + Compress-Archive -Path publish/server/* ` + -DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip + + # ── Client patcher zip (built via tools/build-patcher-zip.ps1) ─── + - name: Build client patcher zip + run: | + .\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }} + + # ── Publish both as Release assets ─────────────────────────────── + - name: Upload Release assets + uses: softprops/action-gh-release@v2 + with: + files: | + dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip + dist/dorknet-clientpatch-${{ env.VERSION_KEY }}.zip + fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1461f82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +## .NET +bin/ +obj/ +*.user +*.suo +.vs/ +.vscode/ +*.csproj.bak +dist/ +## Data / secrets +# Local SQLite + uploaded blobs only — DON'T match the source-tree +# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on +# case-insensitive filesystems. Use the precise runtime data dirs. +DorkNet.Server/bin/Debug/net9.0/data/ +DorkNet.Server/bin/Release/net9.0/data/ +# appsettings.Production.json is intentionally versioned — contains +# only the Postgres provider switch + Kestrel bind address. All real +# secrets come from Coolify env vars at runtime. +tmp/ +## OS +.DS_Store +Thumbs.db + +## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit) +dump.cs +il2cpp.h +script.json +stringliteral.json +Il2CppDump/ +Cpp2ILOutput/ +Cpp2IL_CS/ +Cpp2IL_ISIL/ +RoomPersistenceManager_decompiled.txt + +## Per-machine editor / agent settings +.claude/ +global-metadata.dat +GameAssembly.dll +_wildcard.rec.net+1.pem +_wildcard.rec.net+1-key.pem +dist$ZIPNAME.zip + +## Vite admin SPA build output — derived from DorkNet.Server/admin-ui/ +## via `npm run build` (local) or the admin-ui Dockerfile stage (deploy). +## Each build emits a new hash (assets/index-.js) so committing +## these files leaves a stale index.html → JS hash mismatch the next +## time someone rebuilds. Keep the source committed, regenerate output. +DorkNet.Server/wwwroot/admin/index.html +DorkNet.Server/wwwroot/admin/assets/ +DorkNet.Server/wwwroot/admin/*.map +DorkNet.Server/admin-ui/node_modules/ +DorkNet.Server/admin-ui/dist/ +DorkNet.Server/admin-ui/.vite/ +DorkNet.Server/admin-ui/tsconfig.tsbuildinfo + +## Public-facing site (Vite + React, DorkNet.Server/site/) +## Same reasoning as admin-ui above: hashed output, regenerate per build. +DorkNet.Server/wwwroot/site/index.html +DorkNet.Server/wwwroot/site/assets/ +DorkNet.Server/wwwroot/site/*.map +DorkNet.Server/site/node_modules/ +DorkNet.Server/site/dist/ +DorkNet.Server/site/.vite/ +DorkNet.Server/site/tsconfig.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8c50073 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,108 @@ +# DorkNet.Server container image — Coolify deploy target. +# ──────────────────────────────────────────────────────────────────── +# Multi-stage: SDK image builds + publishes, runtime image is the +# slimmer aspnet:9.0 with only the published output. +# +# Build context: repo root (so Tools/ and DorkNet.Models/ are visible +# to ProjectReference + dotnet restore). The .dockerignore at the +# repo root keeps the bin/, obj/, and other dev junk out of the +# context. + +ARG DOTNET_VERSION=9.0 +ARG NODE_VERSION=20 + +# ── admin SPA build stage ────────────────────────────────────────── +# The Vite + React admin UI under DorkNet.Server/admin-ui builds in a +# dedicated node image because the .NET SDK image doesn't ship npm. +# Output is dropped into /wwwroot/admin and copied into the .NET build +# stage just before `dotnet publish`, which is configured to skip its +# own inline npm step via -p:SkipAdminUIBuild=true. +FROM node:${NODE_VERSION}-alpine AS admin-ui +WORKDIR /ui +# Lockfile first for layer caching: when only application source +# changes, the npm install step stays cached. +COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./ +RUN npm install --no-fund --no-audit --loglevel=error +# Now the source. vite.config.ts writes to ../wwwroot/admin/, which +# from /ui is /wwwroot/admin/ — pre-create the parent so Vite's +# emptyOutDir won't refuse to create above its cwd. +COPY DorkNet.Server/admin-ui/ ./ +RUN mkdir -p /wwwroot/admin \ + && npm run build \ + && ls -la /wwwroot/admin \ + && ls -la /wwwroot/admin/assets + +# ── public-facing site build stage ───────────────────────────────── +# Mirrors the admin-ui stage. Source under DorkNet.Server/site, +# output emitted to /wwwroot/site for the .NET stage to copy. +FROM node:${NODE_VERSION}-alpine AS site +WORKDIR /ui +COPY DorkNet.Server/site/package.json DorkNet.Server/site/package-lock.json* ./ +RUN npm install --no-fund --no-audit --loglevel=error +COPY DorkNet.Server/site/ ./ +RUN mkdir -p /wwwroot/site \ + && npm run build \ + && ls -la /wwwroot/site + +# ── build stage ──────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build +WORKDIR /src + +# Copy csproj files first so `dotnet restore` is cached when only +# source files change. Web SDK pulls in transitive packages from the +# DorkNet.Models project ref, so its csproj needs to be present too. +COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/ +COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/ +RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj + +# Now copy the rest of the .NET source, then drop the prebuilt admin +# SPA into wwwroot/admin so the publish step ships it as static content. +COPY DorkNet.Server/ DorkNet.Server/ +COPY DorkNet.Models/ DorkNet.Models/ +COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/ +COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/ + +# Publish. SkipAdminUIBuild=true bypasses the csproj target that would +# otherwise try to invoke npm (which this image doesn't have). +RUN dotnet publish DorkNet.Server/DorkNet.Server.csproj \ + -c Release \ + --no-restore \ + -p:SkipAdminUIBuild=true \ + -o /app/publish + +# ── runtime stage ────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime +WORKDIR /app + +# curl + wget for healthcheck probes — Coolify v4 defaults to wget for +# its own probe regardless of what the image's HEALTHCHECK uses, so +# both have to be present. The aspnet:9.0 base image strips both by +# default to keep the surface area small. +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl wget \ + && rm -rf /var/lib/apt/lists/* + +# Run as a non-root user — aspnet:9.0 ships with the `app` UID 1654. +# Coolify mounts persistent volumes with write permission for this UID. +USER app + +COPY --from=build --chown=app:app /app/publish . + +# Postgres provider is selected by appsettings.Production.json. +# ASPNETCORE_ENVIRONMENT defaults to Production in the aspnet image +# but we set it explicitly for clarity. +ENV ASPNETCORE_ENVIRONMENT=Production \ + ASPNETCORE_URLS=http://0.0.0.0:8080 \ + DOTNET_RUNNING_IN_CONTAINER=true \ + DOTNET_PRINT_TELEMETRY_MESSAGE=false + +EXPOSE 8080 + +# /healthz returns 503 until the migration + seed pass complete on +# the singleton holding the pg_advisory_xact_lock; other replicas +# block on the lock during the same window. Coolify's default 30s +# probe interval is fine. +HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=3 \ + CMD curl --fail --silent http://localhost:8080/healthz || exit 1 + +ENTRYPOINT ["dotnet", "DorkNet.Server.dll"] diff --git a/DorkNet.ClientMod/DorkNet.ClientMod.csproj b/DorkNet.ClientMod/DorkNet.ClientMod.csproj new file mode 100644 index 0000000..d246de6 --- /dev/null +++ b/DorkNet.ClientMod/DorkNet.ClientMod.csproj @@ -0,0 +1,76 @@ + + + + net6.0 + latest + enable + DorkNet.ClientMod + DorkNet.ClientMod + false + false + $(NoWarn);CS8602;CS8604;CS0436 + + + + + C:\Users\you\Documents\Recnet\dist\RecRoom-Clean-2020.03.10 + $(RecRoomDir)\MelonLoader + $(MelonLoaderDir)\Il2CppAssemblies + + + + + $(MelonLoaderDir)\net6\MelonLoader.dll + false + + + $(MelonLoaderDir)\net6\0Harmony.dll + false + + + $(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll + false + + + $(MelonLoaderDir)\net6\Il2CppInterop.Common.dll + false + + + + $(Il2CppAssembliesDir)\Il2Cppmscorlib.dll + false + + + $(Il2CppAssembliesDir)\Il2CppSystem.dll + false + + + $(Il2CppAssembliesDir)\Assembly-CSharp.dll + false + + + $(Il2CppAssembliesDir)\Assembly-CSharp-firstpass.dll + false + + + $(Il2CppAssembliesDir)\Photon3Unity3D.dll + false + + + $(Il2CppAssembliesDir)\UnityEngine.CoreModule.dll + false + + + + diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs new file mode 100644 index 0000000..913ddac --- /dev/null +++ b/DorkNet.ClientMod/Mod.cs @@ -0,0 +1,1103 @@ +// MelonLoader port of the DorkNet.ClientPatch BepInEx plugin. Applies +// the minimum set of Harmony patches needed for the 2020 watch to +// connect to a DorkNet server: +// +// 1. URI rewrite — `.rec.net` → user-configured host +// 2. Photon AppId — override the baked-in Photon Cloud AppId +// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth +// 4. TLS trust bypass — make BouncyCastle never reject server certs +// +// Config lives in \UserData\dorknet-clientmod.json. The +// install-melon.ps1 wrapper writes that file on install; you can also +// edit it by hand and relaunch the client. +// +// Why string-based AccessTools lookups instead of typeof(Il2Cpp...): +// MelonLoader's Il2CppInterop adds an `Il2Cpp` prefix to game-side +// namespaces (so `RecNet.Core` becomes `Il2CppRecNet.Core`) but the +// exact policy for globally-namespaced types like `PhotonNetwork` and +// for nested types varies across MelonLoader minor versions. Looking +// types up by their original game-side name via AccessTools.TypeByName +// (which searches every loaded assembly) keeps this file working +// regardless of the prefix scheme, and avoids needing a verified +// Il2CppAssemblies directory just to write the source. + +using HarmonyLib; +using MelonLoader; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text.Json; + +[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")] +[assembly: MelonGame(null, null)] + +namespace DorkNet.ClientMod; + +public class Mod : MelonMod +{ + internal static MelonLogger.Instance Log => Instance!.LoggerInstance; + internal static Mod? Instance; + internal static string? DiagnosticsLogPath; + private static readonly Dictionary ResolvedTypeCache = new(); + private readonly HashSet _diagnosticPatchLabels = new(); + private bool _diagnosticCoreRegistered; + private bool _diagnosticGameComplete; + private int _diagnosticRetryFrame; + + // Loader-agnostic config — same .Value-shaped accessor the patches + // would see in the BepInEx port, just backed by a plain JSON file + // instead of BepInEx's TOML config system. Defaults match the BepInEx + // plugin's defaults so dropping this mod in without writing a config + // file gives the same behaviour as the recommended install. + public static class Cfg + { + public static string ServerHost = "localhost"; + public static string PhotonAppId = ""; + public static string PhotonVoiceAppId = ""; + // Forced PhotonServerSettings.PreferredRegion (with HostType + // switched to PhotonCloud) so two watches don't auto-ping + // themselves into different "best" regions and end up in + // parallel Photon rooms sharing only a name. PhotonPatches + // .ApplyForcedRegion prefers the server-told region from + // Matchmaking.LocalRoomInstance.PhotonRegionId when available + // and falls back to this config value on initial boot before + // any /goto has returned. Default matches the server's + // appsettings.json Photon:CloudRegion ("eu"). + public static string PhotonCloudRegion = "eu"; + public static bool InjectAuthValues = true; + public static bool EnableTlsTrustBypass = true; + } + + public override void OnInitializeMelon() + { + Instance = this; + Log.Msg("=== DorkNet ClientMod loading ==="); + LoadConfig(); + DiagnosticPatches.Write("[lifecycle] OnInitializeMelon"); + } + + public override void OnLateInitializeMelon() + { + DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon"); + Log.Msg("=== Registering client patches ==="); + TryPatch("Uri ctor (string)", typeof(Uri), "ctor", + new[] { typeof(string) }, + prefix: nameof(UriPatches.UriStringCtor_Prefix)); + TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor", + new[] { typeof(string), typeof(UriKind) }, + prefix: nameof(UriPatches.UriStringCtor_Prefix)); + TryPatchByName("PhotonNetwork", "ConnectUsingSettings", + prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix)); + TryPatchByName("NetworkingPeer", "CallAuthenticate", + prefix: nameof(PhotonPatches.AuthValuesInjector_Prefix)); + TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication", + "NotifyServerCertificate", + prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix)); + TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication", + "NotifyServerCertificate", + prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix)); + // Post-save blob-name refresh. The 2020 watch's + // RoomPersistenceManager.RoomDataBlobName is only ever set by + // /goto-driven flows — neither the SubscriptionUpdateRoom push + // nor SubscriptionUpdatePresence pushes the server fires after + // save propagate into this field. So the PostSaveReloading + // sub-state's MasterReloadRoomAsync deserializes the cached + // bytes from the OLD blob name (the one /goto returned at boot), + // wiping any MakerPen object the user just placed even though + // the new blob is already on S3. + // + // Verified live: after a dorm save with object placement, server + // pushes SubscriptionUpdateRoom + PresenceUpdate both carrying + // dataBlob="dorm_p_v(N+1).dat", but the watch's + // set_RoomDataBlobName trace immediately after the save shows + // old="v(N)" new="v(N)" — no propagation, no fresh download, + // no respawn. Leave+return triggers /goto which DOES update + // RoomDataBlobName, so the fix is to do the same write + // ourselves: when entering the SavingRoomSuccess state on the + // RoomPersistenceManager, read the just-pushed + // Rooms.LocalRoomScene.DataBlobName and force-set + // __instance.RoomDataBlobName to it. That fires the real + // setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync + // for the new blob → PostSaveReloading picks up the fresh + // bytes and respawns the MakerPen objects. + TryPatchByName("RecRoom.Persistence.RoomPersistenceManager", + "set_RoomDataBlobName", + args: new[] { typeof(string) }, + prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix)); + // Intercept the SubscriptionUpdateRoom push BEFORE the watch's + // (cachedRoomId, cachedSubRoomId) early-out gate drops it. We + // stash the just-arrived Scenes[0].DataBlobName into a static + // so the set_RoomDataBlobName hijack can use it — otherwise + // LocalRoomDetails / LocalRoomScene never get the new blob + // name on the second-or-later push for the same (room, sub) + // tuple (= every post-save push in a dorm). + TryPatchByName("RecNet.Rooms", + "OnSubscriptionUpdateRoom", + prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix)); + // Force AccountExtensions.CanLocalPlayerChat to return true. The + // 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote + // Coroutine d__53 offset 040) gates on this — if false, the + // coroutine bails before raising the Photon RPC, so the sender + // doesn't even see their own message. The check ANDs three + // signals: LocalAccount.TreatAsJunior (ObscuredBool at offset + // 0x40), a PlatformManager flag, and a ModerationManager + // singleton field. At least one returns the wrong value against + // our server. Force-allow — single-tenant private server, text + // moderation isn't a concern. + TryPatchByName("RecNet.AccountExtensions", + "CanLocalPlayerChat", + args: Type.EmptyTypes, + postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix)); + // PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat + // font asset's character table via TMP_FontAsset.HasCharacters + // to strip glyphs the font can't render. On 2020 Rec Room the + // chat font field can be null at the moment this fires (font + // asset bundle hasn't loaded yet?) and HasCharacters NREs deep + // inside Unity's TMP — which propagates out through the chat + // send callback and aborts the RPC. The watch's own ISIL has + // "skipping invalid character check" log paths for missing + // assets but doesn't actually short-circuit on the null field + // we hit. Skip the method entirely; chat lines will just keep + // any glyphs the font can't render (most common: emoji). + TryPatchByName("RecRoom.Players.PlayerEmotes", + "RemoveInvalidCharactersFromMessage", + prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix)); + // Trace hooks so we can see exactly where chat dies. Send path: + // KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string) + // → PlayerEmotes.SendChatEmote(msg, true) + // → SendChatEmoteCoroutine d__53 → PurifyString HTTP call + // → b__0 callback (error, cleanVersion) — fires Photon RPC + // AND local-echoes via ProcessNewChatMessageReceived + // → on each receiver (including self): RpcChatEmote(msg) → + // ProcessNewChatMessageReceived → ReceiveRoomChat appends to + // the chat log buffer. + TryPatchByName("RecRoom.Players.PlayerEmotes", + "SendChatEmote", + args: new[] { typeof(string), typeof(bool) }, + prefix: nameof(ChatPatches.SendChatEmote_Prefix)); + TryPatchByName("RecRoom.Players.PlayerEmotes", + "ProcessNewChatMessageReceived", + args: new[] { typeof(string), typeof(bool) }, + prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix)); + TryPatchByName("RecRoom.Players.PlayerEmotes", + "RpcChatEmote", + args: new[] { typeof(string) }, + prefix: nameof(ChatPatches.RpcChatEmote_Prefix)); + RegisterDiagnostics(); + Log.Msg("=== Client patches registered ==="); + } + + public override void OnUpdate() + { + if (_diagnosticGameComplete) return; + if (_diagnosticRetryFrame++ > 3600) return; + if ((_diagnosticRetryFrame % 60) != 0) return; + + var logMisses = (_diagnosticRetryFrame % 300) == 0; + _diagnosticGameComplete = RegisterGameDiagnostics(logMisses); + if (_diagnosticGameComplete) + DiagnosticPatches.Write("[diagnostics] all game-side diagnostic hooks registered"); + } + + public override void OnApplicationQuit() + { + DiagnosticPatches.Write("[lifecycle] OnApplicationQuit"); + } + + // ── Config ──────────────────────────────────────────────────────── + private static void LoadConfig() + { + try + { + // Compute the UserData path ourselves rather than calling + // MelonLoader.MelonUtils.UserDataDirectory (which is renamed + // to MelonEnvironment.UserDataDirectory in newer 0.6.x builds + // and shifts again in 0.7.x). Anchoring off the mod DLL's + // location keeps this stable across versions: the DLL lives + // in /Mods/, so two dirname()'s reach the game root, + // then MelonLoader/UserData/ is right next to it. + var modDll = Assembly.GetExecutingAssembly().Location; + var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty; + var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty; + var userData = Path.Combine(gameDir, "MelonLoader", "UserData"); + Directory.CreateDirectory(userData); + DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log"); + var path = Path.Combine(userData, "dorknet-clientmod.json"); + if (!File.Exists(path)) + { + Log.Msg($"[config] no file at {path}; using defaults (ServerHost={Cfg.ServerHost})"); + return; + } + var doc = JsonDocument.Parse(File.ReadAllText(path)); + var r = doc.RootElement; + if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost; + if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId; + if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId; + if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion; + if (r.TryGetProperty("InjectAuthValues", out v)) Cfg.InjectAuthValues = v.GetBoolean(); + if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean(); + Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "" : "")}, " + + $"InjectAuthValues={Cfg.InjectAuthValues}, EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}"); + } + catch (Exception ex) + { + Log.Warning($"[config] load failed, using defaults: {ex.Message}"); + } + } + + // ── Patch dispatch helpers ──────────────────────────────────────── + private bool TryPatch(string label, Type target, string methodName, + Type[]? args, string? prefix = null, string? postfix = null) + { + try + { + var method = methodName == "ctor" + ? (MethodBase)target.GetConstructor(args ?? Type.EmptyTypes)! + : (args is null ? AccessTools.Method(target, methodName) + : AccessTools.Method(target, methodName, args)); + if (method is null) + { + Log.Warning($"[patch-miss] {label}"); + return false; + } + HarmonyInstance.Patch(method, + prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)), + postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix))); + Log.Msg($"[patch-ok] {label}"); + return true; + } + catch (Exception ex) + { + Log.Error($"[patch-fail] {label}: {ex.Message}"); + return false; + } + } + + // Resolve the game-side type by its ORIGINAL name (no Il2Cpp prefix) + // by walking every loaded assembly. Tries the exact name first, then + // the same name with an `Il2Cpp` prefix, then a substring scan as a + // last resort — covers all the IL2CPP interop naming schemes the user + // might end up with depending on the MelonLoader / Il2CppInterop + // version installed. + private bool TryPatchByName(string typeName, string methodName, + Type[]? args = null, string? prefix = null, string? postfix = null, + bool logMiss = true) + { + var label = FormatPatchLabel(typeName, methodName, args); + var type = ResolveType(typeName); + if (type is null) + { + if (logMiss) Log.Warning($"[patch-miss] {label}: type not found"); + return false; + } + return TryPatch(label, type, methodName, args, prefix, postfix); + } + + private static Type? ResolveType(string name) + { + if (ResolvedTypeCache.TryGetValue(name, out var cached)) + return cached; + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + var t = asm.GetType(name, throwOnError: false); + if (t is not null) return CacheResolvedType(name, t); + var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false); + if (prefixed is not null) return CacheResolvedType(name, prefixed); + // For namespaced types like "RecNet.Core", also try with + // the Il2Cpp prefix on the namespace (i.e. "Il2CppRecNet.Core"). + var dot = name.IndexOf('.'); + if (dot > 0) + { + var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false); + if (nsPrefixed is not null) return CacheResolvedType(name, nsPrefixed); + } + } + catch { /* unloaded / dynamic asm — skip */ } + } + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + foreach (var t in GetLoadableTypes(asm)) + { + if (MatchesRequestedType(t, name)) + return CacheResolvedType(name, t); + } + } + return null; + } + + private static Type CacheResolvedType(string name, Type type) + { + ResolvedTypeCache[name] = type; + return type; + } + + private static IEnumerable GetLoadableTypes(Assembly asm) + { + try + { + return asm.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + var types = new List(); + foreach (var t in ex.Types) + { + if (t is not null) types.Add(t); + } + return types; + } + catch + { + return Array.Empty(); + } + } + + private static bool MatchesRequestedType(Type type, string requested) + { + var prefixed = "Il2Cpp" + requested; + if (type.FullName == requested || type.FullName == prefixed) return true; + if (type.Name == requested || type.Name == prefixed) return true; + return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true || + type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true; + } + + private static string FormatPatchLabel(string typeName, string methodName, Type[]? args) + { + if (args is null) return $"{typeName}.{methodName}"; + var names = new List(); + foreach (var arg in args) + names.Add(arg.Name); + return $"{typeName}.{methodName}({string.Join(",", names)})"; + } + + private MethodInfo GetPatchMethod(string name) + { + // Look in all three patch holder classes — small enough that a + // linear scan is cheaper than per-class lookups. + foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches) }) + { + var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static); + if (m is not null) return m; + } + throw new MissingMethodException($"patch method '{name}' not found on any holder"); + } + + private void RegisterDiagnostics() + { + Log.Msg("=== Registering diagnostics ==="); + if (!_diagnosticCoreRegistered) + { + TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes, + prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix)); + TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) }, + prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix)); + _diagnosticCoreRegistered = true; + } + + _diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true); + Log.Msg("=== Diagnostics registered ==="); + } + + private bool RegisterGameDiagnostics(bool logMisses) + { + var complete = true; + complete &= TryPatchDiagnosticByName("BootSequence", "RegisterError", new[] { typeof(string) }, + prefix: nameof(DiagnosticPatches.BootSequenceRegisterError_Prefix), + logMiss: logMisses); + + foreach (var method in new[] + { + "UnityTimeCheat", + "ObscuredTypeCheat", + "DeveloperFlagCheat", + "HeightChangeCheat", + "AdvancedMotionCheat", + "StreamingAssetCheat" + }) + { + complete &= TryPatchDiagnosticByName("AnalyticsHelper", method, + prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix), + logMiss: logMisses); + } + + complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", + prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix), + logMiss: logMisses); + complete &= TryPatchDiagnosticByName("SteamManager", "Awake", + prefix: nameof(DiagnosticPatches.NamedMethod_Prefix), + logMiss: logMisses); + complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PreLoginInitialize", + prefix: nameof(DiagnosticPatches.NamedMethod_Prefix), + logMiss: logMisses); + complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PostLoginInitialize", + prefix: nameof(DiagnosticPatches.NamedMethod_Prefix), + logMiss: logMisses); + return complete; + } + + private bool TryPatchDiagnosticByName(string typeName, string methodName, + Type[]? args = null, string? prefix = null, string? postfix = null, + bool logMiss = true) + { + var label = FormatPatchLabel(typeName, methodName, args); + if (_diagnosticPatchLabels.Contains(label)) + return true; + + if (!TryPatchByName(typeName, methodName, args, prefix, postfix, logMiss)) + return false; + + _diagnosticPatchLabels.Add(label); + return true; + } +} + +// ── Patch holders ───────────────────────────────────────────────────── +// Each holder is a static class with public static Prefix methods that +// Harmony invokes. Kept separate from Mod for readability and so the +// reflection lookup in GetPatchMethod stays simple. + +internal static class UriPatches +{ + public static void UriStringCtor_Prefix(ref string uriString) + { + if (string.IsNullOrEmpty(uriString)) return; + var host = Mod.Cfg.ServerHost; + if (string.IsNullOrEmpty(host)) return; + if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return; + var rewritten = uriString.Replace(".rec.net", "." + host); + if (rewritten != uriString) + { + Mod.Log.Msg($"[uri-rewrite] {uriString} → {rewritten}"); + uriString = rewritten; + } + } +} + +internal static class PhotonPatches +{ + public static void PhotonAppIdOverride_Prefix() + { + if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return; + try + { + // PhotonNetwork.PhotonServerSettings — accessed via reflection + // so the type-name resolution stays in one place. Mutating the + // ScriptableObject in-place is what the BepInEx plugin does + // too; the Photon Connect path reads AppID / VoiceAppID off + // this object immediately after. + var photonNetwork = Mod.Instance is null ? null : FindGameType("PhotonNetwork"); + if (photonNetwork is null) return; + var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static); + var settings = settingsProp?.GetValue(null); + if (settings is null) return; + + var settingsType = settings.GetType(); + var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId"); + var voiceProp = settingsType.GetProperty("VoiceAppID") ?? settingsType.GetProperty("VoiceAppId"); + + var newId = Mod.Cfg.PhotonAppId; + var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId; + + appIdProp?.SetValue(settings, newId); + voiceProp?.SetValue(settings, newVoice); + Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice}"); + + // Force a deterministic Photon region so two watches running + // this mod end up on the same Photon master and "JoinByName" + // produces ONE shared room (not two parallel ones in eu/us + // because each watch auto-picked a different best region). + // + // The 2020 watch's default HostType is BestRegion(4) — every + // launch pings all Photon Cloud regions and picks the lowest + // latency. Two players geographically apart hit different + // "best" regions; same room name, two regions = two rooms. + // Server-side our matchmaking response carries + // `photonRegionId` but the watch's Photon code never reads + // it; the only consumer is Matchmaking.SetPhotonRegionPings + // which only reports pings *back* to the server. + // + // Strategy: switch HostType to PhotonCloud(1) and set + // PreferredRegion. Prefer the server-told region (the most + // recent Matchmaking.LocalRoomInstance.PhotonRegionId, if + // any) over the config default — that way a server-side + // region change propagates without redeploying the mod. + ApplyForcedRegion(settings, settingsType); + } + catch (Exception ex) { Mod.Log.Warning($"[photon-appid] override failed: {ex.Message}"); } + } + + /// Forces PhotonServerSettings.HostType = PhotonCloud + /// and writes PreferredRegion. Honors any region the server + /// has handed us in Matchmaking.LocalRoomInstance.PhotonRegionId + /// first; falls back to Cfg.PhotonCloudRegion when no room + /// instance has been received yet (initial boot). + private static void ApplyForcedRegion(object settings, Type settingsType) + { + try + { + var hostTypeProp = settingsType.GetField("HostType") + ?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType"); + var preferredProp = settingsType.GetField("PreferredRegion") + ?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion"); + if (hostTypeProp is null || preferredProp is null) + { + Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found"); + return; + } + + // ServerSettings.HostingOption.PhotonCloud = 1 (verified in + // Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum + // type from the field/property so we don't depend on a + // specific Il2Cpp proxy namespace. + Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType + ?? ((PropertyInfo)hostTypeProp).PropertyType; + Type? regionEnum = (preferredProp as FieldInfo)?.FieldType + ?? ((PropertyInfo)preferredProp).PropertyType; + + // Prefer server-provided region over config. We read it via + // the same reflection style as the existing patches so the + // Il2Cpp type-name suffix doesn't bite us. + string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us"; + + object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud + object regionValue; + try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); } + catch + { + Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us"); + regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1 + region = "us"; + } + + if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue); + else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue); + if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue); + else ((PropertyInfo)preferredProp).SetValue(settings, regionValue); + + Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}"); + } + catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); } + } + + /// Reads + /// RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId as a + /// lowercase enum name (eu, us, asia, …). Returns null if no room + /// instance has been received yet, or if any link in the chain is + /// null. Server-told region wins over the static config default + /// because the server is authoritative about which Photon region + /// hosts the room the watch is about to join. + private static string? ReadServerProvidedRegion() + { + try + { + var matchmaking = FindGameType("RecNet.Matchmaking"); + var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null); + if (localRoom is null) return null; + var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom); + if (regionObj is null) return null; + // CloudRegionCode enum.ToString() gives the lowercase region + // name (eu, us, asia, …) — see CloudRegionCode.cs. + return regionObj.ToString(); + } + catch { return null; } + } + + public static void AuthValuesInjector_Prefix(object __instance) + { + if (!Mod.Cfg.InjectAuthValues) return; + try + { + // Photon's AuthValues live on PhotonNetwork.AuthValues. The 2020 + // watch never sets them itself, so we set them right before + // NetworkingPeer.CallAuthenticate forwards to the wire op. The + // userid is RecNet.Core.LocalAccountId, the LoginLock comes + // from PlayerPrefs key LoginLockTokenV2. + // RecNet.Core.LocalAccountId is declared `int` in the game + // build (dump.cs:573622 → 0x30 backing field). Convert via + // Convert.ToInt64 so boxing as int still gives us the long we + // need below; a direct (long) cast on boxed int would throw. + var coreType = FindGameType("RecNet.Core"); + var accountIdObj = coreType?.GetProperty("LocalAccountId", BindingFlags.Public | BindingFlags.Static)?.GetValue(null); + var accountId = accountIdObj is null ? 0L : Convert.ToInt64(accountIdObj); + if (accountId <= 0) + { + Mod.Log.Msg("[auth-injector] skip — no LocalAccountId yet (pre-login Photon ping)"); + return; + } + + var photonNetwork = FindGameType("PhotonNetwork"); + var avProp = photonNetwork?.GetProperty("AuthValues", BindingFlags.Public | BindingFlags.Static); + var existing = avProp?.GetValue(null); + if (existing is not null) + { + var userIdProp = existing.GetType().GetProperty("UserId"); + var existingUserId = userIdProp?.GetValue(existing) as string; + if (!string.IsNullOrEmpty(existingUserId)) return; + } + + var token = GetCurrentLockToken(); + + // Build a new AuthenticationValues. The type lives in the + // ExitGames.Client.Photon.LoadBalancing namespace (game-side). + // CustomAuthenticationType.Custom = 0 in this build. + var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues"); + if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; } + var av = Activator.CreateInstance(authValuesType); + var authTypeProp = authValuesType.GetProperty("AuthType"); + if (authTypeProp is not null) + { + var customAuthValue = authTypeProp.PropertyType.IsEnum + ? Enum.ToObject(authTypeProp.PropertyType, 0) + : Convert.ChangeType(0, authTypeProp.PropertyType); + authTypeProp.SetValue(av, customAuthValue); + } + authValuesType.GetProperty("UserId")?.SetValue(av, accountId.ToString()); + var addParam = authValuesType.GetMethod("AddAuthParameter", new[] { typeof(string), typeof(string) }); + addParam?.Invoke(av, new object[] { "userid", accountId.ToString() }); + addParam?.Invoke(av, new object[] { "LoginLock", token ?? string.Empty }); + + avProp?.SetValue(null, av); + Mod.Log.Msg($"[auth-injector] set AuthValues userid={accountId} LoginLock={(string.IsNullOrEmpty(token) ? "" : "")}"); + } + catch (Exception ex) { Mod.Log.Warning($"[auth-injector] failed: {ex.Message}"); } + } + + private static string? GetCurrentLockToken() + { + try + { + var playerPrefs = FindGameType("UnityEngine.PlayerPrefs"); + var getString = playerPrefs?.GetMethod("GetString", new[] { typeof(string), typeof(string) }); + var raw = (string?)getString?.Invoke(null, new object[] { "LoginLockTokenV2", string.Empty }); + if (string.IsNullOrEmpty(raw)) return null; + const string key = "\"LockToken\":\""; + var ix = raw.IndexOf(key, StringComparison.Ordinal); + if (ix < 0) return null; + ix += key.Length; + var end = raw.IndexOf('"', ix); + return end < 0 ? null : raw[ix..end]; + } + catch { return null; } + } + + private static Type? FindGameType(string name) + { + // Mirrors Mod.ResolveType but inlined here so the holder doesn't + // depend on Mod's internal helpers. + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + var t = asm.GetType(name, throwOnError: false); + if (t is not null) return t; + var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false); + if (prefixed is not null) return prefixed; + var dot = name.IndexOf('.'); + if (dot > 0) + { + var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false); + if (nsPrefixed is not null) return nsPrefixed; + } + } + catch { } + } + return null; + } +} + +internal static class SavePatches +{ + // Last DataBlobName parsed off a SubscriptionUpdateRoom push for + // our currently-loaded room. Captured BEFORE the watch's caching + // gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat + // (roomId, subRoomId) tuple — which is every post-save push for a + // dorm). SetRoomDataBlobName_Prefix reads this to hijack the + // would-be no-op setter call into a real transition. + private static volatile string? s_lastPushedBlobName; + private static long s_lastPushedRoomId; + private static long s_lastPushedSubRoomId; + + // Hook on Notifications.OnNotificationReceived(string json). Fires for + // every SignalR message before any handler dispatch. Parses the JSON + // and, when the Id is SubscriptionUpdateRoom, stashes the new blob. + public static void OnNotificationReceived_Prefix(string message) + { + try + { + if (string.IsNullOrEmpty(message)) return; + // Fast reject: only parse JSON if it could plausibly be a + // SubscriptionUpdateRoom payload. Saves cycles on the (very + // frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes. + if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0) + return; + using var doc = System.Text.Json.JsonDocument.Parse(message); + var root = doc.RootElement; + if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return; + if (!root.TryGetProperty("Id", out var idEl) || + idEl.ValueKind != System.Text.Json.JsonValueKind.String || + !string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal)) + return; + if (!root.TryGetProperty("Msg", out var msg) || + msg.ValueKind != System.Text.Json.JsonValueKind.Object) return; + if (!msg.TryGetProperty("Room", out var room) || + room.ValueKind != System.Text.Json.JsonValueKind.Object) return; + long roomId = 0; + if (room.TryGetProperty("RoomId", out var ridEl) && + ridEl.ValueKind == System.Text.Json.JsonValueKind.Number) + roomId = ridEl.GetInt64(); + if (!msg.TryGetProperty("Scenes", out var scenes) || + scenes.ValueKind != System.Text.Json.JsonValueKind.Array || + scenes.GetArrayLength() == 0) return; + var first = scenes[0]; + long subRoomId = 0; + if (first.TryGetProperty("RoomSceneId", out var sidEl) && + sidEl.ValueKind == System.Text.Json.JsonValueKind.Number) + subRoomId = sidEl.GetInt64(); + if (!first.TryGetProperty("DataBlobName", out var blobEl) || + blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return; + var blob = blobEl.GetString(); + if (string.IsNullOrEmpty(blob)) return; + s_lastPushedRoomId = roomId; + s_lastPushedSubRoomId = subRoomId; + s_lastPushedBlobName = blob; + Mod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}"); + } + catch (Exception ex) + { + Mod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}"); + } + } + + public static void OnSubscriptionUpdateRoom_Prefix(object message) + { + try + { + if (message is null) return; + var dict = message; + + // The watch parses this dict as Dictionary + // via Util.GetKey. We need three keys: RoomId, SubRoomId, + // and Scenes[0].DataBlobName. The Il2Cpp dict supports + // get_Item(key) — call it via reflection. + var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) }); + if (getItem is null) return; + + // The push wire shape from server (BuildRoomDetails) is + // {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives + // on the Room sub-object; the scene's blob on Scenes[0]. + object? roomObj = TryGet(dict, getItem, "Room"); + object? scenesObj = TryGet(dict, getItem, "Scenes"); + if (roomObj is null || scenesObj is null) return; + + long roomId = 0; + long subRoomId = 0; + try + { + var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) }); + if (roomItem is not null) + { + var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" }); + if (rid is not null) roomId = Convert.ToInt64(rid.ToString()); + } + } + catch { /* roomId stays 0 — we'll log it and bail later */ } + + // Scenes is an IL2CPP List. Take the first + // (current dorm has one scene; multi-scene rooms target + // Scenes[currentSubRoomId] specifically, but the post-save + // push always carries the scene that was just saved). + object? firstScene = null; + try + { + var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) }); + firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 }); + } + catch { /* leave firstScene null */ } + if (firstScene is null) return; + + string? newBlob = null; + try + { + var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) }); + if (sceneItem is not null) + { + var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" }); + newBlob = blob?.ToString(); + var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" }); + if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString()); + } + } + catch { /* leave newBlob null */ } + + if (string.IsNullOrEmpty(newBlob)) return; + + s_lastPushedRoomId = roomId; + s_lastPushedSubRoomId = subRoomId; + s_lastPushedBlobName = newBlob; + Mod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}"); + } + catch (Exception ex) + { + Mod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}"); + } + } + + private static object? TryGet(object dict, MethodInfo getItem, string key) + { + try { return getItem.Invoke(dict, new object[] { key }); } + catch { return null; } + } + + // RoomPersistenceManager.set_RoomDataBlobName runs the change-event + // path only when the new value differs from the cached field. The + // 2020 watch's post-save propagation reads from RoomInstance.DataBlob + // (set by /goto, never updated by SubscriptionUpdateRoom), so on a + // save it calls the setter with the OLD blob — a no-op that skips + // OnRoomDataBlobNameChanged. The cached PersistedRoomData stays + // pointed at the boot-time bytes, and PostSaveReloading deserializes + // those over the live world — wiping any MakerPen object the user + // just placed. + // + // Live trace evidence (Latest.log, dorm save at 17:14): the watch's + // own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms + // after b__4 returns True, even though the SubscriptionUpdateRoom + // push already carried Scenes[0].DataBlobName="v11". + // + // Strategy: intercept the setter. If the incoming value matches the + // current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a + // newer version (the freshly-pushed one), substitute that into the + // value parameter so the real setter sees a transition and fires + // OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob). + // Falls through silently when no override is needed. + public static void SetRoomDataBlobName_Prefix(object __instance, ref string value) + { + string currentForLog = ""; + string sceneForLog = ""; + string scenes0ForLog = ""; + try + { + if (__instance is null) + { + Mod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping"); + return; + } + + var rpmType = __instance.GetType(); + var blobNameProp = rpmType.GetProperty("RoomDataBlobName", + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (blobNameProp is null) + { + Mod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}"); + return; + } + currentForLog = (blobNameProp.GetValue(__instance) as string) ?? ""; + + var roomsType = FindGameType("RecNet.Rooms"); + if (roomsType is null) + { + Mod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found"); + return; + } + + var localScene = roomsType.GetProperty("LocalRoomScene", + BindingFlags.Public | BindingFlags.Static)?.GetValue(null); + if (localScene is not null) + sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? ""; + + var localDetails = roomsType.GetProperty("LocalRoomDetails", + BindingFlags.Public | BindingFlags.Static)?.GetValue(null); + if (localDetails is not null) + { + var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails); + if (scenes is not null) + { + var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) }); + var first = indexer?.Invoke(scenes, new object[] { 0 }); + if (first is not null) + scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? ""; + } + } + + // Unconditional trace of EVERYTHING we read so we can see in + // the log exactly why we did or didn't override. + Mod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " + + $"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " + + $"lastPushedBlob=\"{s_lastPushedBlobName ?? ""}\""); + + // Pick a candidate "newer" blob name. Priority order: + // 1. Last-pushed-from-SubscriptionUpdateRoom (captured by + // OnSubscriptionUpdateRoom_Prefix BEFORE the watch's + // cache gate drops the push). + // 2. LocalRoomScene.DataBlobName. + // 3. Scenes[0].DataBlobName. + string? pushedBlob = s_lastPushedBlobName; + if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) + pushedBlob = sceneForLog is "" or "" ? null : sceneForLog; + if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) + pushedBlob = scenes0ForLog is "" or "" ? null : scenes0ForLog; + + if (string.IsNullOrEmpty(pushedBlob)) return; + if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return; + if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return; + + Mod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}"); + value = pushedBlob; + } + catch (Exception ex) + { + Mod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " + + $"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")"); + } + } + + // Local copy of the FindGameType helper used by PhotonPatches — IL2CPP + // type-name resolution stays per-holder so SavePatches doesn't reach + // across into another internal class. + private static Type? FindGameType(string name) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + var t = asm.GetType(name, throwOnError: false); + if (t is not null) return t; + var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false); + if (prefixed is not null) return prefixed; + var dot = name.IndexOf('.'); + if (dot > 0) + { + var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false); + if (nsPrefixed is not null) return nsPrefixed; + } + } + catch { } + } + return null; + } +} + +internal static class TlsPatches +{ + // Both BouncyCastle TLS authenticators have the same `void + // NotifyServerCertificate(...)` signature whose body throws when the + // chain doesn't validate. Returning false from a Harmony prefix on a + // void method skips the original — the certificate is accepted by + // virtue of NotifyServerCertificate never throwing. + public static bool NotifyServerCertificate_Prefix() + { + return !Mod.Cfg.EnableTlsTrustBypass; // false skips original; true (bypass off) lets it run + } +} + +internal static class DiagnosticPatches +{ + public static void Write(string message) + { + var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}"; + try { Mod.Log.Msg(line); } catch { } + try + { + if (!string.IsNullOrEmpty(Mod.DiagnosticsLogPath)) + File.AppendAllText(Mod.DiagnosticsLogPath, line + Environment.NewLine); + } + catch { } + } + + public static void ApplicationQuit_Prefix() + { + Write("[quit] UnityEngine.Application.Quit()"); + WriteStack(); + } + + public static void ApplicationQuitInt_Prefix(int exitCode) + { + Write($"[quit] UnityEngine.Application.Quit({exitCode})"); + WriteStack(); + } + + public static void BootSequenceRegisterError_Prefix(string error) + { + Write($"[boot-error] BootSequence.RegisterError: {error}"); + WriteStack(); + } + + public static void AnalyticsCheat_Prefix(MethodBase __originalMethod) + { + Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}"); + WriteStack(); + } + + public static void NamedMethod_Prefix(MethodBase __originalMethod) + { + Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}"); + WriteStack(); + } + + public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod) + { + Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}"); + WriteStack(); + return false; + } + + private static void WriteStack() + { + try + { + Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false)); + } + catch (Exception ex) + { + Write("[stack] failed: " + ex.Message); + } + } +} + +internal static class ChatPatches +{ + private static bool s_logged; + + // Force RecNet.AccountExtensions.CanLocalPlayerChat to return true. + // PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this + // before raising the in-room Photon chat RPC; if it returns false + // the coroutine bails silently and the sender doesn't even see + // their own message echo. + public static void CanLocalPlayerChat_Postfix(ref bool __result) + { + if (!__result && !s_logged) + { + s_logged = true; + Mod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat"); + } + __result = true; + } + + // Skip the original. Returning false from a Harmony prefix stops + // the underlying call. + public static bool RemoveInvalidCharactersFromMessage_Prefix() + { + Mod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped"); + return false; + } + + public static void SendChatEmote_Prefix(string message) + { + Mod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\""); + } + + public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer) + { + Mod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}"); + } + + public static void RpcChatEmote_Prefix(string message) + { + Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\""); + } +} diff --git a/DorkNet.ClientMod/dorknet-clientmod.json.template b/DorkNet.ClientMod/dorknet-clientmod.json.template new file mode 100644 index 0000000..c3bc687 --- /dev/null +++ b/DorkNet.ClientMod/dorknet-clientmod.json.template @@ -0,0 +1,8 @@ +{ + "ServerHost": "{HOST}", + "PhotonAppId": "{PHOTON_APPID}", + "PhotonVoiceAppId": "{PHOTON_VOICE_APPID}", + "PhotonCloudRegion": "{PHOTON_REGION}", + "InjectAuthValues": true, + "EnableTlsTrustBypass": true +} diff --git a/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj b/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj new file mode 100644 index 0000000..ee2dfcf --- /dev/null +++ b/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj @@ -0,0 +1,106 @@ + + + + + net6.0 + latest + enable + DorkNet.ClientPatch + DorkNet.ClientPatch + false + false + + + + + + + $(BepInExCore)\BepInEx.Core.dll + false + + + $(BepInExCore)\BepInEx.Unity.IL2CPP.dll + false + + + $(BepInExCore)\0Harmony.dll + false + + + $(BepInExCore)\Il2CppInterop.Runtime.dll + false + + + $(BepInExCore)\Il2CppInterop.Common.dll + false + + + $(BepInExCore)\MonoMod.Utils.dll + false + + + + + $(BepInExInterop)\Il2Cppmscorlib.dll + false + + + $(BepInExInterop)\Il2CppSystem.dll + false + + + $(BepInExInterop)\Assembly-CSharp.dll + false + + + $(BepInExInterop)\Assembly-CSharp-firstpass.dll + false + + + $(BepInExInterop)\Photon3Unity3D.dll + false + + + $(BepInExInterop)\UnityEngine.UI.dll + false + + + $(BepInExInterop)\Unity.TextMeshPro.dll + false + + + $(BepInExInterop)\UnityEngine.CoreModule.dll + false + + + $(BepInExInterop)\RecRoom.Datastructures.Runtime.dll + false + + + $(BepInExInterop)\Unity.Addressables.dll + false + + + $(BepInExInterop)\RecRoom.Async.dll + false + + + $(BepInExInterop)\RecRoom.Promises.Runtime.dll + false + + + + diff --git a/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj.lscache b/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj.lscache new file mode 100644 index 0000000..91f7f97 --- /dev/null +++ b/DorkNet.ClientPatch/DorkNet.ClientPatch.csproj.lscache @@ -0,0 +1,231 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=DorkNet.ClientPatch +CommandLineArgsForDesignTimeEvaluation=-langversion:latest -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=10.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=DorkNet.ClientPatch +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../DorkNet.sln +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/DorkNet.ClientPatch.dll +TargetRefPath=obj/Debug/ref/DorkNet.ClientPatch.dll +TemporaryDependencyNodeTargetIdentifier=net6.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:6 +/define:TRACE;DEBUG;NET;NET6_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\DorkNet.ClientPatch.dll +/refout:obj\Debug\refint\DorkNet.ClientPatch.dll +/target:library +/warnaserror- +/utf8output +/deterministic+ +/langversion:latest +/warnaserror+:NU1605 + +[sourceFiles] +obj/Debug/ + .NETCoreApp,Version=v6.0.AssemblyAttributes.cs + DorkNet.ClientPatch.AssemblyInfo.cs +Plugin.cs + +[metadataReferences] +/microsoft.netcore.app.ref/6.0.36/ref/net6.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll + +[analyzerReferences] +/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +/microsoft.netcore.app.ref/6.0.36/analyzers/dotnet/cs/System.Text.Json.SourceGeneration.dll + +[analyzerConfigFiles] +/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_6_default.globalconfig +obj/Debug/DorkNet.ClientPatch.GeneratedMSBuildEditorConfig.editorconfig diff --git a/DorkNet.ClientPatch/NuGet.Config b/DorkNet.ClientPatch/NuGet.Config new file mode 100644 index 0000000..3bbbc47 --- /dev/null +++ b/DorkNet.ClientPatch/NuGet.Config @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/DorkNet.ClientPatch/Plugin.cs b/DorkNet.ClientPatch/Plugin.cs new file mode 100644 index 0000000..26a6fe8 --- /dev/null +++ b/DorkNet.ClientPatch/Plugin.cs @@ -0,0 +1,2011 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using BepInEx; +using BepInEx.Configuration; +using BepInEx.Unity.IL2CPP; +using BepInEx.Logging; +using HarmonyLib; +using ExitGames.Client.Photon.LoadBalancing; // AuthenticationValues, CustomAuthenticationType +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using RecNet; // Core, Login, Matchmaking +using Steamworks; +using UnityEngine; + +namespace DorkNet.ClientPatch; + +/// +/// BepInEx 6 IL2CPP plugin that owns every client-side change DorkNet +/// needs to make to the 2020 watch — Photon AppId override, RecNet +/// domain rewrite, and Photon CustomAuth identity injection. +/// +/// Replaces the previous patch-client.ps1 byte-edit approach so the +/// flow on a client update is now: download the new build → drop the +/// plugin in BepInEx/plugins/ → done. No more hardcoded resources.assets +/// offsets, no more hosts-file edits, no more mkcert wildcard. +/// +/// Configuration lives in BepInEx/config/sh.dork.clientpatch.cfg +/// (auto-created on first run). Edit values, restart the client, the +/// patches re-apply with the new settings. +/// +[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)] +public sealed class Plugin : BasePlugin +{ + internal static new ManualLogSource Log = null!; + + /// Replacement domain for every *.rec.net URL the watch + /// would otherwise hit. "localhost" means + /// https://ns.rec.net/?v=2 → https://ns.localhost/?v=2 at + /// runtime (Uri ctor patch). Empty disables the rewrite. + public static ConfigEntry ServerHost = null!; + + /// Photon Cloud Realtime AppId. When non-empty, replaces + /// PhotonNetwork.PhotonServerSettings.AppID right before + /// ConnectUsingSettings hands off to NetworkingPeer. + public static ConfigEntry PhotonAppId = null!; + + /// Photon Voice AppId. Empty falls back to + /// . + public static ConfigEntry PhotonVoiceAppId = null!; + + /// Send userid+LoginLock AuthValues with the + /// Photon connect so DorkNet's /photon/customauth endpoint + /// can validate the session. Disable while debugging Photon + /// connectivity if AuthValues are interfering. + public static ConfigEntry InjectAuthValues = null!; + + /// Replacement branding string. Every user-facing UI + /// label that contains "RecNet" gets the substring replaced + /// with this value at runtime — so error dialogs read + /// "Failed to connect to DorkNet" instead of + /// "Failed to connect to RecNet". Empty disables the rewrite + /// (useful if you want logs to keep matching upstream + /// documentation). + public static ConfigEntry BrandName = null!; + public static ConfigEntry EnableBrandRename = null!; + + /// Skip the Steam DRM "RestartAppIfNecessary" check. The + /// vanilla watch calls + /// Steamworks.SteamAPI.RestartAppIfNecessary(471710) on + /// boot, which forces a relaunch through Steam if the .exe was + /// started directly (Steam not running, or launched from + /// outside Steam). Enabling this returns false from that call so + /// the watch keeps booting wherever it was started. Has no effect + /// if you're launching from Steam normally. + public static ConfigEntry SkipSteamRestart = null!; + + /// Fake out the Steamworks SDK so the watch can boot + /// without Steam.exe running. Patches the C#-level Steamworks.NET + /// wrapper methods to return canned success values WITHOUT calling + /// the underlying native steam_api64.dll, so we don't need + /// Goldberg / SmartSteamEmu / etc. (which trip Defender as PUA). + /// + /// Patches applied when on: + /// - SteamAPI.Init → true + /// - SteamAPI.IsSteamRunning → true + /// - SteamAPI.GetHSteamPipe → fake non-zero handle + /// - SteamAPI.GetHSteamUser → fake non-zero handle + /// - SteamUser.GetSteamID → CSteamID built from FakeSteamId + /// - SteamFriends.GetPersonaName → FakeAccountName + /// - SteamPlatformManager.GetAuthSessionTicket → fake bytes + /// + /// Our DorkNet auth backend doesn't actually validate the Steam + /// ticket — it just uses platformId (SteamID) as the + /// cached_login lookup key — so the fake ticket is fine. + public static ConfigEntry FakeSteamApi = null!; + + /// SteamID64 to fake when is on. + /// Match the value our auth backend has on file for the player — + /// the same SteamID lets the player keep their account across + /// machines. Empty = deterministic per-machine ID derived from + /// the OS user + machine name. + public static ConfigEntry FakeSteamId = null!; + + /// Display name returned from + /// Steamworks.SteamFriends.GetPersonaName when + /// is on. Empty = the OS user name. + public static ConfigEntry FakeAccountName = null!; + public static ConfigEntry EnableTlsTrustBypass = null!; + public static ConfigEntry EnablePhotonRegionBypass = null!; + public static ConfigEntry EnableBootDiagnostics = null!; + public static ConfigEntry DetailedUnityLogs = null!; + + /// Targeted diagnostics for avatar/store DTOs and UI binding + /// failures. This is intentionally narrower than raw HTTP tracing so the + /// logs identify crash-causing item ids/GUIDs without dumping tokens or + /// every request body. + public static ConfigEntry DetailedAvatarStoreLogs = null!; + + /// Legacy crash guard that replaces avatar item GUIDs unknown to + /// the local client with a known-safe item before the client parser runs. + /// Keep off by default now that the server should only send client-known + /// wardrobe items; enabling it changes the visible equipped item. + public static ConfigEntry SubstituteUnknownAvatarItems = null!; + + /// Legacy guard for stale servers that returned the wrong shape + /// from storefront objective completion. Keep off by default so the client + /// exercises the real server endpoint. + public static ConfigEntry SkipStorefrontObjectivePosts = null!; + + /// Legacy mirror guard that blocks direct torso unequip. This can + /// interfere with normal outfit editing, so it is opt-in only. + public static ConfigEntry BlockDirectTorsoUnequip = null!; + + /// Cleanup exception suppressor used only while debugging teardown + /// crashes. Keep off by default so real crashes stay visible. + public static ConfigEntry SuppressScreenCleanupNullRef = null!; + public static ConfigEntry FailFastRequiredPatches = null!; + public static ConfigEntry CatchAllManagedExceptions = null!; + + public override void Load() + { + Log = base.Log; + + ServerHost = Config.Bind( + "Server", "Host", "localhost", + "Domain that replaces *.rec.net in every URL the watch builds. " + + "Empty disables the rewrite (use only if pointing at the official servers)."); + + PhotonAppId = Config.Bind( + "Photon", "AppId", string.Empty, + "Photon Cloud Realtime AppId (UUID, no curly braces). " + + "Empty leaves the AppId baked into the build untouched."); + + PhotonVoiceAppId = Config.Bind( + "Photon", "VoiceAppId", string.Empty, + "Photon Cloud Voice AppId. Empty falls back to AppId."); + + InjectAuthValues = Config.Bind( + "Photon", "InjectAuthValues", true, + "Attach userid + LoginLock to PhotonNetwork.AuthValues right before " + + "OpAuthenticate so DorkNet's /photon/customauth endpoint can identify " + + "the player. Disable to debug pure Photon connectivity issues."); + + BrandName = Config.Bind( + "UI", "BrandName", "DorkNet", + "Replacement branding string. Any UI label containing 'RecNet' " + + "gets that substring replaced with this value at runtime, so " + + "error dialogs and the loading screen show the private-server " + + "brand. Empty disables the rewrite."); + + EnableBrandRename = Config.Bind( + "UI", "EnableBrandRename", false, + "Patch Unity/TMP text setters to replace RecNet with BrandName. " + + "Disabled by default because broad UI text setter hooks add a lot " + + "of IL2CPP detours for cosmetic value only."); + + SkipSteamRestart = Config.Bind( + "Steam", "SkipRestartAppIfNecessary", true, + "Bypass Steamworks.SteamAPI.RestartAppIfNecessary so the watch " + + "doesn't relaunch itself through Steam on boot. No effect when " + + "launched normally from the Steam library; useful when running " + + "the game directly via Recroom_Release.exe (BepInEx mod-loader " + + "shims often need this to avoid an infinite restart loop)."); + + FakeSteamApi = Config.Bind( + "Steam", "FakeSteamApi", false, + "Pretend the Steam SDK initialised even when Steam.exe isn't " + + "running. Patches Steamworks.NET wrappers to return canned " + + "success values so the watch's SteamPlatformManager.Initialize " + + "succeeds without a live Steam process. DorkNet's auth backend " + + "uses SteamID as a lookup key only — no real ticket validation — " + + "so the fake ticket is accepted. Default off so launches via " + + "Steam itself behave normally."); + + FakeSteamId = Config.Bind( + "Steam", "FakeSteamId", string.Empty, + "SteamID64 returned by SteamUser.GetSteamID when FakeSteamApi=true. " + + "Empty = random 63-bit ID generated on first run and persisted " + + "to BepInEx/config/sh.dork.clientpatch.deviceid. Reuses the " + + "same ID on every subsequent launch on the same install. " + + "Pin an explicit ID to share an account across different " + + "machines (e.g. paste your real SteamID64 to migrate from a " + + "previous manually-modded client)."); + + FakeAccountName = Config.Bind( + "Steam", "FakeAccountName", string.Empty, + "Display name returned by SteamFriends.GetPersonaName when " + + "FakeSteamApi=true. Empty = OS user name."); + + EnableTlsTrustBypass = Config.Bind( + "Network", "EnableTlsTrustBypass", true, + "Bypass the old BestHTTP/BouncyCastle certificate verifier. " + + "Required for many modern certificates, but kept as a switch so " + + "the patch can be removed when testing plugin stability."); + + EnablePhotonRegionBypass = Config.Bind( + "Photon", "EnableRegionBypass", true, + "Bypass the 2020 Photon region ping coroutine and force us. " + + "Required when modern Photon returns region codes this client " + + "does not know."); + + EnableBootDiagnostics = Config.Bind( + "Diagnostics", "EnableBootDiagnostics", false, + "Patch BootSequence.RegisterError to log extra stack traces. " + + "Disabled by default because it hooks a fragile IL2CPP boot method."); + + DetailedUnityLogs = Config.Bind( + "Diagnostics", "DetailedUnityLogs", true, + "Enable full Unity stack traces for Log/Warning/Error/Exception " + + "messages and mirror Unity exceptions into the BepInEx log. " + + "Useful while chasing client teardown crashes."); + + DetailedAvatarStoreLogs = Config.Bind( + "Diagnostics", "DetailedAvatarStoreLogs", true, + "Log avatar/store DTOs and BrowsableAvatarItem binding failures. " + + "Useful when the watch tears down after opening the store, mirror, " + + "or gift UI. Logs are capped per run to keep LogOutput.log readable."); + + SubstituteUnknownAvatarItems = Config.Bind( + "Compatibility", "SubstituteUnknownAvatarItems", true, + "Replace avatar item GUIDs the local client does not know with a " + + "safe fallback item. This prevents parser crashes from bad server " + + "data, but can change clothing appearance for unsupported items."); + + SkipStorefrontObjectivePosts = Config.Bind( + "Compatibility", "SkipStorefrontObjectivePosts", false, + "Skip RecNet.Storefronts.CompleteObjectives client calls. This was " + + "only for stale servers that returned the wrong response shape; " + + "leave off so the server endpoint is tested normally."); + + BlockDirectTorsoUnequip = Config.Bind( + "Compatibility", "BlockDirectTorsoUnequip", false, + "Block PlayerAvatar.UnequipOutfitItem when the target body part is " + + "torso. This can stop the mirror UI from unequipping shirts, so it " + + "is disabled by default."); + + SuppressScreenCleanupNullRef = Config.Bind( + "Diagnostics", "SuppressScreenCleanupNullRef", false, + "Suppress one known ScreenPlayerController.CleanupLocalPlayer " + + "NullReferenceException during teardown. Disabled by default so " + + "crashes stay visible in logs."); + + FailFastRequiredPatches = Config.Bind( + "Diagnostics", "FailFastRequiredPatches", false, + "Abort plugin startup when a required Harmony patch cannot be " + + "applied. Disabled by default so one IL2CPP target mismatch is " + + "logged without taking down every other patch."); + + CatchAllManagedExceptions = Config.Bind( + "Compatibility", "CatchAllManagedExceptions", true, + "Install broad managed exception guards: observe task exceptions, " + + "log AppDomain unhandled exceptions, and swallow exceptions that " + + "escape the Rec Room scheduler frame loop. This cannot recover " + + "native Unity access violations."); + + GlobalManagedCatchAll.Install(); + UnityDetailedLogBridge.Install(); + + Log.LogInfo($"{MyPluginInfo.PLUGIN_NAME} v{MyPluginInfo.PLUGIN_VERSION} loaded. " + + $"ServerHost={ServerHost.Value}, PhotonAppId={(string.IsNullOrEmpty(PhotonAppId.Value) ? "" : "")}, " + + $"InjectAuthValues={InjectAuthValues.Value}, DetailedUnityLogs={DetailedUnityLogs.Value}, " + + $"DetailedAvatarStoreLogs={DetailedAvatarStoreLogs.Value}, " + + $"EnableBrandRename={EnableBrandRename.Value}, EnableTlsTrustBypass={EnableTlsTrustBypass.Value}, " + + $"EnablePhotonRegionBypass={EnablePhotonRegionBypass.Value}, EnableBootDiagnostics={EnableBootDiagnostics.Value}, " + + $"SubstituteUnknownAvatarItems={SubstituteUnknownAvatarItems.Value}, " + + $"SkipStorefrontObjectivePosts={SkipStorefrontObjectivePosts.Value}, " + + $"BlockDirectTorsoUnequip={BlockDirectTorsoUnequip.Value}, " + + $"SuppressScreenCleanupNullRef={SuppressScreenCleanupNullRef.Value}, " + + $"FailFastRequiredPatches={FailFastRequiredPatches.Value}, " + + $"CatchAllManagedExceptions={CatchAllManagedExceptions.Value}"); + + var harmony = new Harmony(MyPluginInfo.PLUGIN_GUID); + ApplyPatches(harmony); + } + + private static void ApplyPatches(Harmony harmony) + { + // Do not use PatchAll here. On IL2CPP, even discovering and + // preparing unused patch classes can touch fragile generated methods. + // Keep the active detour set explicit and close to the manual patcher. + PatchRequired(harmony, typeof(UriStringCtorPatch), UriStringCtorPatch.Prepare(), "URI string rewrite"); + PatchRequired(harmony, typeof(UriStringKindCtorPatch), UriStringKindCtorPatch.Prepare(), "URI string/kind rewrite"); + PatchRequired(harmony, typeof(PhotonAppIdOverride), PhotonAppIdOverride.Prepare(), "Photon AppId override"); + PatchRequired(harmony, typeof(AuthValuesInjector), AuthValuesInjector.Prepare(), "Photon auth injector"); + PatchRequired(harmony, typeof(TlsTrustServerOnly), TlsTrustServerOnly.Prepare(), "BestHTTP TLS trust bypass"); + PatchRequired(harmony, typeof(TlsTrustLegacy), TlsTrustLegacy.Prepare(), "legacy TLS trust bypass"); + PatchRequired(harmony, typeof(PhotonRegionPingShortcut), PhotonRegionPingShortcut.Prepare(), "Photon region shortcut"); + + PatchOptional(harmony, typeof(TMPTextBrandRename), TMPTextBrandRename.Prepare(), "TMP brand rename"); + PatchOptional(harmony, typeof(UITextBrandRename), UITextBrandRename.Prepare(), "Unity UI brand rename"); + PatchOptional(harmony, typeof(SteamRestartBypass), SteamRestartBypass.Prepare(), "Steam restart bypass"); + + PatchOptional(harmony, typeof(InteropHelpTestClientFake), SteamFakery.Enabled, "fake Steam client availability"); + PatchOptional(harmony, typeof(InteropHelpTestServerFake), SteamFakery.Enabled, "fake Steam server availability"); + PatchOptional(harmony, typeof(InteropHelpTestPlatformFake), SteamFakery.Enabled, "fake Steam platform availability"); + PatchOptional(harmony, typeof(SteamApiInitFake), SteamFakery.Enabled, "fake Steam init"); + PatchOptional(harmony, typeof(SteamApiIsRunningFake), SteamFakery.Enabled, "fake Steam running"); + PatchOptional(harmony, typeof(SteamApiGetPipeFake), SteamFakery.Enabled, "fake Steam pipe"); + PatchOptional(harmony, typeof(SteamApiGetUserFake), SteamFakery.Enabled, "fake Steam user"); + PatchOptional(harmony, typeof(SteamUserIdFake), SteamFakery.Enabled, "fake Steam id"); + PatchOptional(harmony, typeof(SteamFriendsPersonaNameFake), SteamFakery.Enabled, "fake Steam persona"); + PatchOptional(harmony, typeof(SteamFriendsCountFake), SteamFakery.Enabled, "fake Steam friend count"); + PatchOptional(harmony, typeof(SteamFriendsByIndexFake), SteamFakery.Enabled, "fake Steam friend index"); + PatchOptional(harmony, typeof(SteamApiRunCallbacksFake), SteamFakery.Enabled, "fake Steam callbacks"); + PatchOptional(harmony, typeof(SteamApiShutdownFake), SteamFakery.Enabled, "fake Steam shutdown"); + PatchOptional(harmony, typeof(SteamClientWarningHookFake), SteamFakery.Enabled, "fake Steam warning hook"); + PatchOptional(harmony, typeof(SteamCallbackNativeRegistrationFake), SteamFakery.Enabled, "fake Steam callback registration"); + PatchOptional(harmony, typeof(SteamFriendsFriendNameFake), SteamFakery.Enabled, "fake Steam friend name"); + PatchOptional(harmony, typeof(SteamFriendsAvatarFake), SteamFakery.Enabled, "fake Steam avatar"); + PatchOptional(harmony, typeof(SteamFriendsRichPresenceFake), SteamFakery.Enabled, "fake Steam rich presence"); + PatchOptional(harmony, typeof(SteamUtilsImageRGBAFake), SteamFakery.Enabled, "fake Steam image rgba"); + PatchOptional(harmony, typeof(SteamUtilsImageSizeFake), SteamFakery.Enabled, "fake Steam image size"); + PatchOptional(harmony, typeof(SteamGetAuthTicketShortcut), SteamFakery.Enabled, "fake Steam platform ticket"); + PatchOptional(harmony, typeof(SteamUserGetAuthTicketFake), SteamFakery.Enabled, "fake Steam user ticket"); + + PatchOptional(harmony, typeof(AvatarItemParseSubstitute), AvatarItemParseSubstitute.Prepare(), "avatar item substitute"); + PatchOptional(harmony, typeof(AvatarItemParseSubstituteTwoArg), AvatarItemParseSubstituteTwoArg.Prepare(), "avatar item substitute two-arg"); + PatchOptional(harmony, typeof(DynamicAvatarItemImposterCatchAll), GlobalManagedCatchAll.Enabled, "avatar imposter catch-all"); + PatchOptional(harmony, typeof(SchedulerManagedCatchAll), GlobalManagedCatchAll.Enabled, "scheduler managed catch-all"); + PatchOptional(harmony, typeof(StorefrontCompleteObjectivesGuard), StorefrontCompleteObjectivesGuard.Prepare(), "storefront objective guard"); + PatchOptional(harmony, typeof(StorefrontGiftDropDiagnostics), AvatarStoreDiagnostics.Enabled, "storefront gift diagnostics"); + PatchOptional(harmony, typeof(UnlockedAvatarItemDiagnostics), AvatarStoreDiagnostics.Enabled, "unlocked avatar diagnostics"); + PatchOptional(harmony, typeof(GiftPackageDiagnostics), AvatarStoreDiagnostics.Enabled, "gift package diagnostics"); + PatchOptional(harmony, typeof(StorefrontBalanceDiagnostics), AvatarStoreDiagnostics.Enabled, "storefront balance diagnostics"); + PatchOptional(harmony, typeof(PlayerAvatarOutfitDiagnostics), AvatarStoreDiagnostics.Enabled, "player outfit diagnostics"); + PatchOptional(harmony, typeof(RequiredTorsoUnequipGuard), RequiredTorsoUnequipGuard.Prepare(), "torso unequip guard"); + PatchOptional(harmony, typeof(ScreenPlayerCleanupGuard), ScreenPlayerCleanupGuard.Prepare(), "screen cleanup guard"); + PatchOptional(harmony, typeof(BootSequenceRegisterErrorTrace), BootSequenceRegisterErrorTrace.Prepare(), "boot diagnostics"); + + Log.LogInfo("Explicit Harmony patch list applied."); + } + + private static void PatchRequired(Harmony harmony, Type patchType, bool enabled, string label) + { + if (!enabled) + { + Log.LogInfo($"[patch-skip] {label}"); + return; + } + + try + { + harmony.CreateClassProcessor(patchType).Patch(); + Log.LogInfo($"[patch-ok] {label}"); + } + catch (Exception ex) + { + Log.LogError($"[patch-failed] {label}: {ex}"); + if (FailFastRequiredPatches?.Value == true) + throw; + } + } + + private static void PatchOptional(Harmony harmony, Type patchType, bool enabled, string label) + { + if (!enabled) + { + Log.LogDebug($"[patch-skip] {label}"); + return; + } + + try + { + harmony.CreateClassProcessor(patchType).Patch(); + Log.LogInfo($"[patch-ok] {label}"); + } + catch (Exception ex) + { + Log.LogWarning($"[patch-failed] optional {label}: {ex}"); + } + } +} + +internal static class MyPluginInfo +{ + public const string PLUGIN_GUID = "sh.dork.clientpatch"; + public const string PLUGIN_NAME = "DorkNet ClientPatch"; + public const string PLUGIN_VERSION = "1.4.8"; +} + +// ── 1. URI rewrite — every Uri the watch builds with a .rec.net host +// gets its host substituted to ServerHost. Catches the bootstrap +// https://ns.rec.net/?v=2 (the only hard-coded URL in the watch — +// every other service URL comes back in that response, which our +// server controls). Also catches any odd hard-coded Photon Voice / +// realtime URLs the SDK might construct. +// +// Patching the BCL Uri constructor is broader than ideal — any +// string containing ".rec.net" will be rewritten — but in practice +// the watch doesn't construct any non-URL strings with that +// substring. If false positives appear we'll narrow to the +// HTTPRequest constructor or transpiler the call site. + +[HarmonyPatch(typeof(Uri), MethodType.Constructor, new[] { typeof(string) })] +internal static class UriStringCtorPatch +{ + public static bool Prepare() => + Plugin.ServerHost != null && !string.IsNullOrEmpty(Plugin.ServerHost.Value); + + public static void Prefix(ref string uriString) + { + if (string.IsNullOrEmpty(uriString)) return; + if (string.IsNullOrEmpty(Plugin.ServerHost.Value)) return; + if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return; + var rewritten = uriString.Replace(".rec.net", "." + Plugin.ServerHost.Value); + if (rewritten != uriString) + { + Plugin.Log.LogInfo($"[uri-rewrite] {uriString} → {rewritten}"); + uriString = rewritten; + } + } +} + +[HarmonyPatch(typeof(Uri), MethodType.Constructor, new[] { typeof(string), typeof(UriKind) })] +internal static class UriStringKindCtorPatch +{ + public static bool Prepare() => UriStringCtorPatch.Prepare(); + + public static void Prefix(ref string uriString) + { + // Reuse the string-only patch's logic by piping through it. Simpler + // than copy-pasting and keeps a single rewrite implementation. + UriStringCtorPatch.Prefix(ref uriString); + } +} + +// ── 2. Photon AppId override — patched at the latest possible moment +// (right before NetworkingPeer.Connect uses ServerSettings.AppID). +// Patching the ScriptableObject directly at plugin Load doesn't +// work because ServerSettings is loaded from Resources lazily; by +// the time Connect runs, it's loaded and we can mutate it. + +[HarmonyPatch(typeof(PhotonNetwork), nameof(PhotonNetwork.ConnectUsingSettings))] +internal static class PhotonAppIdOverride +{ + public static bool Prepare() => + Plugin.PhotonAppId != null && !string.IsNullOrEmpty(Plugin.PhotonAppId.Value); + + public static void Prefix() + { + if (string.IsNullOrEmpty(Plugin.PhotonAppId.Value)) return; + try + { + var settings = PhotonNetwork.PhotonServerSettings; + if (settings == null) return; + + var newAppId = Plugin.PhotonAppId.Value; + var newVoiceId = string.IsNullOrEmpty(Plugin.PhotonVoiceAppId.Value) + ? newAppId + : Plugin.PhotonVoiceAppId.Value; + + if (settings.AppID != newAppId || settings.VoiceAppID != newVoiceId) + { + Plugin.Log.LogInfo( + $"[photon-appid] override AppID={settings.AppID}→{newAppId}, " + + $"VoiceAppID={settings.VoiceAppID}→{newVoiceId}"); + settings.AppID = newAppId; + settings.VoiceAppID = newVoiceId; + } + } + catch (Exception ex) + { + Plugin.Log.LogWarning($"[photon-appid] override failed: {ex.Message}"); + } + } +} + +// ── 3. Photon AuthValues injector — sets userid + LoginLock in +// PhotonNetwork.AuthValues right before any authenticate op leaves +// the wire. Verified empirically: vanilla 2020 watch never sets +// AuthValues itself, so we have to do it externally for our +// /photon/customauth endpoint to receive identity. + +// NetworkingPeer.CallAuthenticate is the watch's single internal entry +// point that ultimately calls LoadBalancingPeer.OpAuthenticate / +// OpAuthenticateOnce on the wire. The vanilla 2020 watch never sets +// PhotonNetwork.AuthValues itself, so we set it here just before +// CallAuthenticate forwards to the base op. +[HarmonyPatch(typeof(NetworkingPeer), nameof(NetworkingPeer.CallAuthenticate))] +internal static class AuthValuesInjector +{ + public static bool Prepare() => + Plugin.InjectAuthValues != null && Plugin.InjectAuthValues.Value; + + [HarmonyPrefix] + public static void EnsureAuthValues(NetworkingPeer __instance) + { + if (!Plugin.InjectAuthValues.Value) return; + try + { + var existing = PhotonNetwork.AuthValues; + if (existing != null && !string.IsNullOrEmpty(existing.UserId)) + { + // Already set this run — don't clobber. Lets a future + // patch upstream us if needed. + return; + } + + var accountId = Core.LocalAccountId; + if (accountId <= 0) + { + // Pre-login Photon ping has no account yet; let it go + // through anonymous, the post-login reconnect will pick + // up our values. + Plugin.Log.LogInfo("[auth-injector] skip — no LocalAccountId yet"); + return; + } + + // Read the LoginLock straight from PlayerPrefs (key + // LoginLockTokenV2). The watch persists it there in + // Matchmaking.Login at login time, so by the time any + // Photon op fires it's already on disk — no need to call + // the private Matchmaking.LoadLockToken(). + var token = MatchmakingHelpers.GetCurrentLockToken(); + + var av = new AuthenticationValues + { + AuthType = CustomAuthenticationType.Custom, + UserId = accountId.ToString(), + }; + av.AddAuthParameter("userid", accountId.ToString()); + av.AddAuthParameter("LoginLock", token ?? string.Empty); + + PhotonNetwork.AuthValues = av; + + Plugin.Log.LogInfo( + $"[auth-injector] set Photon AuthValues userid={accountId} " + + $"LoginLock={(string.IsNullOrEmpty(token) ? "" : "")}"); + } + catch (Exception ex) + { + Plugin.Log.LogWarning($"[auth-injector] failed: {ex.Message}"); + } + } +} + +// ── 4. UI brand rename — replace "RecNet" → BrandName in any text the +// watch displays. Hook the two universal text-set entry points +// (Unity UI Text + TMP_Text) so we catch both legacy and modern +// UI bindings. The watch's hardcoded error strings ("Failed to +// connect to RecNet (error code: …)", "Connecting to RecNet…", +// etc.) flow through these setters before reaching the user. + +[HarmonyPatch(typeof(TMPro.TMP_Text), nameof(TMPro.TMP_Text.text), MethodType.Setter)] +internal static class TMPTextBrandRename +{ + public static bool Prepare() => + Plugin.EnableBrandRename != null && Plugin.EnableBrandRename.Value && + Plugin.BrandName != null && !string.IsNullOrEmpty(Plugin.BrandName.Value); + + public static void Prefix(ref string value) + { + BrandRenameHelper.Apply(ref value); + } +} + +[HarmonyPatch(typeof(UnityEngine.UI.Text), nameof(UnityEngine.UI.Text.text), MethodType.Setter)] +internal static class UITextBrandRename +{ + public static bool Prepare() => TMPTextBrandRename.Prepare(); + + public static void Prefix(ref string value) + { + BrandRenameHelper.Apply(ref value); + } +} + +internal static class BrandRenameHelper +{ + public static void Apply(ref string value) + { + if (string.IsNullOrEmpty(value)) return; + var brand = Plugin.BrandName.Value; + if (string.IsNullOrEmpty(brand)) return; + if (value.IndexOf("RecNet", StringComparison.Ordinal) < 0) return; + var replaced = value.Replace("RecNet", brand); + if (replaced != value) value = replaced; + } +} + +// ── 5. Steam DRM bypass — short-circuit +// Steamworks.SteamAPI.RestartAppIfNecessary so the watch doesn't +// forcibly re-launch through Steam when the .exe was started +// directly. Has no effect when launched from the Steam library +// (RestartAppIfNecessary returns false anyway in that case); +// matters when running the game outside Steam, which is the +// common modding/dev path. + +[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.RestartAppIfNecessary))] +internal static class SteamRestartBypass +{ + public static bool Prepare() => + Plugin.SkipSteamRestart != null && Plugin.SkipSteamRestart.Value; + + public static bool Prefix(ref bool __result) + { + if (!Plugin.SkipSteamRestart.Value) return true; // run original + Plugin.Log.LogInfo("[steam-bypass] short-circuiting RestartAppIfNecessary → false"); + __result = false; + return false; // skip original + } +} + +// ── 6. Fake Steamworks SDK — when FakeSteamApi=true we hijack the +// C#-level Steamworks.NET wrapper methods to return canned values +// WITHOUT calling steam_api64.dll. This is the AV-friendly +// alternative to Goldberg/SmartSteamEmu — pure managed code, +// no replacement DLL, no PUA flag. +// +// Coverage is just-enough for the watch's boot path. We patch: +// - SteamAPI.Init / IsSteamRunning / GetHSteamPipe / GetHSteamUser +// - SteamUser.GetSteamID / GetAuthSessionTicket +// - SteamFriends.GetPersonaName +// If the watch ever calls a Steamworks method we don't fake, the +// underlying DLL stub returns its default (usually "no" / 0) and +// that path quietly does nothing — better than crashing. + +internal static class SteamFakery +{ + public static bool Enabled => + Plugin.FakeSteamApi != null && Plugin.FakeSteamApi.Value; + + private static ulong? _resolvedSteamId; + public static ulong ResolvedSteamId + { + get + { + if (_resolvedSteamId.HasValue) return _resolvedSteamId.Value; + var raw = Plugin.FakeSteamId.Value; + if (!string.IsNullOrEmpty(raw) && ulong.TryParse(raw, out var explicitId)) + { + _resolvedSteamId = explicitId; + return explicitId; + } + // Per-install random ID, persisted to BepInEx/config/ + // sh.dork.clientpatch.deviceid. Generated once on first + // run; reused on every subsequent launch on the same + // install. Keeps the same DorkNet account across plugin + // updates and game updates, but a clean reinstall (or a + // different machine without the file copied over) gets a + // brand-new account. + // + // Why not SHA256(username + machine)? The mod-31-bit + // collision space (we packed it into the 9-digit + // 76561198_xxxxxxxxx slot) made birthday-paradox collisions + // realistic — Alexa@DESKTOP-XYZ is plausibly the same as + // someone else's. Random 63-bit gives ~2³¹ users before + // 50% collision, which we'll comfortably never hit. + var deviceFile = System.IO.Path.Combine( + BepInEx.Paths.ConfigPath, "sh.dork.clientpatch.deviceid"); + ulong rand; + try + { + if (System.IO.File.Exists(deviceFile) && + ulong.TryParse(System.IO.File.ReadAllText(deviceFile).Trim(), out var stored)) + { + rand = stored; + } + else + { + var bytes = new byte[8]; + System.Security.Cryptography.RandomNumberGenerator.Fill(bytes); + // Mask top bit so the resulting SteamID64 fits the + // valid Public-universe individual range (the + // top byte stays 0x01). + rand = System.BitConverter.ToUInt64(bytes, 0) & 0x7FFF_FFFF_FFFF_FFFFUL; + System.IO.File.WriteAllText(deviceFile, rand.ToString()); + Plugin.Log.LogInfo($"[steam-fake] generated new deviceid → {deviceFile}"); + } + } + catch (Exception ex) + { + // Filesystem unavailable for some reason — fall back + // to a one-shot random ID so the boot still proceeds. + // (Account won't persist across this session, but at + // least the player can play.) + Plugin.Log.LogWarning($"[steam-fake] deviceid persistence failed ({ex.Message}); using ephemeral ID"); + var bytes = new byte[8]; + System.Security.Cryptography.RandomNumberGenerator.Fill(bytes); + rand = System.BitConverter.ToUInt64(bytes, 0) & 0x7FFF_FFFF_FFFF_FFFFUL; + } + // SteamID64 layout: top 32 bits = universe(8) + accountType(4) + instance(20), + // bottom 32 bits = accountID. The 76561197960265728 prefix is + // (Public, Individual, Desktop). We OR our random low-32 bits in. + const ulong SteamId64Base = 76561197960265728UL; + _resolvedSteamId = SteamId64Base + (rand & 0xFFFF_FFFFUL); + return _resolvedSteamId.Value; + } + } + + public static string ResolvedAccountName => + string.IsNullOrEmpty(Plugin.FakeAccountName.Value) + ? (System.Environment.UserName ?? "DorkNetTester") + : Plugin.FakeAccountName.Value; +} + +// InteropHelp.TestIfAvailableClient/TestIfPlatformSupported/TestIfAvailable +// GameServer are the gate every Steamworks.NET wrapper passes through +// before delegating to the native steam_api64.dll. With FakeSteamApi +// they would otherwise throw "Steamworks is not initialized" because +// no real native pipe was opened — even though our SteamAPI.Init +// prefix returned true, the wrapper checks an internal sentinel set +// by the native init callback. Patch these three to no-op so EVERY +// Steamworks.NET method behind them silently uses our faked output +// (or quietly does nothing for ones we haven't faked). +[HarmonyPatch(typeof(Steamworks.InteropHelp), nameof(Steamworks.InteropHelp.TestIfAvailableClient))] +internal static class InteropHelpTestClientFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix() => !Plugin.FakeSteamApi.Value; // skip original when faking +} + +[HarmonyPatch(typeof(Steamworks.InteropHelp), nameof(Steamworks.InteropHelp.TestIfAvailableGameServer))] +internal static class InteropHelpTestServerFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix() => !Plugin.FakeSteamApi.Value; +} + +[HarmonyPatch(typeof(Steamworks.InteropHelp), nameof(Steamworks.InteropHelp.TestIfPlatformSupported))] +internal static class InteropHelpTestPlatformFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix() => !Plugin.FakeSteamApi.Value; +} + +[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.Init))] +internal static class SteamApiInitFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref bool __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + Plugin.Log.LogInfo("[steam-fake] SteamAPI.Init → true"); + __result = true; + return false; + } +} + +[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.IsSteamRunning))] +internal static class SteamApiIsRunningFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref bool __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + __result = true; + return false; + } +} + +[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.GetHSteamPipe))] +internal static class SteamApiGetPipeFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref Steamworks.HSteamPipe __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + // HSteamPipe is a struct wrapping a uint — pick anything non-zero. + __result = new Steamworks.HSteamPipe(1); + return false; + } +} + +[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.GetHSteamUser))] +internal static class SteamApiGetUserFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref Steamworks.HSteamUser __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + __result = new Steamworks.HSteamUser(1); + return false; + } +} + +[HarmonyPatch(typeof(Steamworks.SteamUser), nameof(Steamworks.SteamUser.GetSteamID))] +internal static class SteamUserIdFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref Steamworks.CSteamID __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + __result = new Steamworks.CSteamID(SteamFakery.ResolvedSteamId); + return false; + } +} + +[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.GetPersonaName))] +internal static class SteamFriendsPersonaNameFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref string __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + __result = SteamFakery.ResolvedAccountName; + return false; + } +} + +// SteamPlatformManager.PostLoginInitialize iterates the Steam friends +// list (GetFriendCount → for-loop GetFriendByIndex). Without these +// stubs each call falls through TestIfAvailableClient (no-op'd above) +// straight into native steam_api64.dll, which segfaults on our fake +// pipe/user handles. Returning 0 from GetFriendCount short-circuits +// the whole loop so the watch never queries any friend data. +[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.GetFriendCount))] +internal static class SteamFriendsCountFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref int __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + __result = 0; + return false; + } +} + +// Defensive: should never be hit if GetFriendCount returns 0, but if +// some other code path indexes regardless we return an empty CSteamID. +[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.GetFriendByIndex))] +internal static class SteamFriendsByIndexFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref Steamworks.CSteamID __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + __result = new Steamworks.CSteamID(0UL); + return false; + } +} + +// Audit (2026-05-11): grepping every "Call ." in +// the watch's IL2CPP ISIL turned up exactly 14 Steamworks methods used +// across the entire binary. The patches above cover Init / IsRunning / +// GetHSteamPipe / GetHSteamUser / GetSteamID / GetPersonaName / +// GetFriendCount / GetFriendByIndex / GetAuthSessionTicket / +// RestartAppIfNecessary. The remaining 6 are stubbed below — every +// one of them needs a no-op or zero return to avoid falling through +// to a native steam_api64.dll call with our fake handles, which +// segfaults the process. + +// SteamAPI.RunCallbacks is called every frame from a MonoBehaviour. +// Native call dispatches queued callbacks; with no real pipe, it'd +// dereference null and crash. No-op skips the dispatch — our synthetic +// callbacks (e.g. GetAuthSessionTicketResponse) are fired directly, +// not through this queue. +[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.RunCallbacks))] +internal static class SteamApiRunCallbacksFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix() => !Plugin.FakeSteamApi.Value; +} + +// SteamAPI.Shutdown — called on app exit. Native expects to clean up +// a real pipe; no-op so a successful exit doesn't crash. +[HarmonyPatch(typeof(Steamworks.SteamAPI), nameof(Steamworks.SteamAPI.Shutdown))] +internal static class SteamApiShutdownFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix() => !Plugin.FakeSteamApi.Value; +} + +// SteamClient.SetWarningMessageHook installs a callback for Steam's +// internal warnings. No-op — we don't need warnings, and the native +// call would dereference our zero pipe. +[HarmonyPatch(typeof(Steamworks.SteamClient), nameof(Steamworks.SteamClient.SetWarningMessageHook))] +internal static class SteamClientWarningHookFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix() => !Plugin.FakeSteamApi.Value; +} + +// The watch registers a few Steam callback bases during +// SteamPlatformManager.Initialize. With FakeSteamApi=true there is no valid +// native Steam pipe behind those callback bases. The one callback the boot path +// needs is synthesized below, so skip native callback registration in fake mode. +[HarmonyPatch] +internal static class SteamCallbackNativeRegistrationFake +{ + public static bool Prepare() => SteamFakery.Enabled; + + public static IEnumerable TargetMethods() + { + foreach (var callbackName in new[] + { + "GetAuthSessionTicketResponse_t", + "GameRichPresenceJoinRequested_t", + "MicroTxnAuthorizationResponse_t", + "GameOverlayActivated_t" + }) + { + var type = AccessTools.TypeByName("Steamworks.SteamCallbacks+" + callbackName); + if (type == null) + { + Plugin.Log.LogWarning($"[steam-fake] callback type not found: {callbackName}"); + continue; + } + + var register = AccessTools.Method(type, "RegisterCallback"); + if (register != null) yield return register; + + var unregister = AccessTools.Method(type, "UnregisterCallback"); + if (unregister != null) yield return unregister; + } + } + + public static bool Prefix(MethodBase __originalMethod) + { + if (!Plugin.FakeSteamApi.Value) return true; + Plugin.Log.LogDebug($"[steam-fake] skip native callback registration: {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}"); + return false; + } +} + +// SteamFriends.GetFriendPersonaName / GetMediumFriendAvatar — only +// reachable if GetFriendCount > 0, but defensively stubbed in case +// some other call path indexes a friend directly. +[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.GetFriendPersonaName))] +internal static class SteamFriendsFriendNameFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref string __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + __result = string.Empty; + return false; + } +} + +[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.GetMediumFriendAvatar))] +internal static class SteamFriendsAvatarFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref int __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + __result = 0; + return false; + } +} + +// SteamFriends.SetRichPresence — sets the "playing X" string visible +// to Steam friends. Cosmetic; native call would crash. Return true +// (success) so the watch doesn't log a failure. +[HarmonyPatch(typeof(Steamworks.SteamFriends), nameof(Steamworks.SteamFriends.SetRichPresence))] +internal static class SteamFriendsRichPresenceFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref bool __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + __result = true; + return false; + } +} + +// SteamUtils.GetImageRGBA / GetImageSize — used to fetch Steam avatar +// pixel data. Without a real avatar handle (we return 0 from +// GetMediumFriendAvatar), these would normally not be called, but +// stubbed defensively to fail-fast if the watch tries. +[HarmonyPatch(typeof(Steamworks.SteamUtils), nameof(Steamworks.SteamUtils.GetImageRGBA))] +internal static class SteamUtilsImageRGBAFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref bool __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + __result = false; + return false; + } +} + +[HarmonyPatch(typeof(Steamworks.SteamUtils), nameof(Steamworks.SteamUtils.GetImageSize))] +internal static class SteamUtilsImageSizeFake +{ + public static bool Prepare() => SteamFakery.Enabled; + public static bool Prefix(ref bool __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + __result = false; + return false; + } +} + + +// SteamPlatformManager.GetAuthSessionTicket() returns an IPromise that +// only completes when Steam fires the GetAuthSessionTicketResponse_t +// callback (real Steam dispatches it asynchronously a few hundred ms +// after GetAuthSessionTicket). With FakeSteamApi=true there's no +// native Steam to fire it, so the watch waits ~5s and then logs +// "Timed out waiting for Steam GetAuthSessionTicketResponse" → login +// stalls for 5s before the boot continues. +// +// Postfix-patch the method so the callback fires synthetically right +// after GetAuthSessionTicket returns: build a k_EResultOK response +// pointing at our fake HAuthTicket and feed it back through the watch's +// own OnGetAuthSessionTicketResponse handler. That completes the +// inner promise immediately and the boot doesn't pay the 5s timeout. +[HarmonyPatch(typeof(SteamPlatformManager), nameof(SteamPlatformManager.GetAuthSessionTicket))] +internal static class SteamGetAuthTicketShortcut +{ + public static bool Prepare() => SteamFakery.Enabled; + + public static void Postfix(SteamPlatformManager __instance) + { + if (!Plugin.FakeSteamApi.Value) return; + try + { + var response = new Steamworks.GetAuthSessionTicketResponse_t + { + m_eResult = Steamworks.EResult.k_EResultOK, + m_hAuthTicket = new Steamworks.HAuthTicket(0xDEADBEEFu), + }; + __instance.OnGetAuthSessionTicketResponse(response); + Plugin.Log.LogInfo("[steam-fake] synth GetAuthSessionTicketResponse → k_EResultOK"); + } + catch (Exception ex) + { + Plugin.Log.LogWarning($"[steam-fake] synth ticket-response failed: {ex.Message}"); + } + } +} + +// SteamUser.GetAuthSessionTicket is the entry point for the watch's +// auth flow. Real signature: +// HAuthTicket GetAuthSessionTicket(byte[] pTicket, int cbMaxTicket, +// out uint pcbTicket) +// We fake a 64-byte ticket containing the SteamID + a marker so our +// /cachedlogin endpoint could in principle distinguish forged vs real +// (it currently doesn't validate — see auth/AuthController.cs). +[HarmonyPatch(typeof(Steamworks.SteamUser), nameof(Steamworks.SteamUser.GetAuthSessionTicket))] +internal static class SteamUserGetAuthTicketFake +{ + public static bool Prepare() => SteamFakery.Enabled; + + public static bool Prefix( + Il2CppStructArray pTicket, + int cbMaxTicket, + ref uint pcbTicket, + ref Steamworks.HAuthTicket __result) + { + if (!Plugin.FakeSteamApi.Value) return true; + + const int desiredLen = 64; + var len = System.Math.Min(desiredLen, cbMaxTicket); + // Fill the ticket with deterministic but-not-zero bytes. First + // 8 bytes = SteamID little-endian; rest = a magic marker so a + // future server-side validator could detect a forged session. + var sid = System.BitConverter.GetBytes(SteamFakery.ResolvedSteamId); + for (int i = 0; i < len; i++) + { + pTicket[i] = i < sid.Length ? sid[i] : (byte)('D' ^ (i & 0x0F)); + } + pcbTicket = (uint)len; + __result = new Steamworks.HAuthTicket(0xDEADBEEF); + Plugin.Log.LogInfo($"[steam-fake] GetAuthSessionTicket → {len} bytes, SteamID={SteamFakery.ResolvedSteamId}"); + return false; + } +} + +// ── 7. TLS cert trust — make BouncyCastle (the watch's BestHTTP TLS +// stack, vendored circa 2020) accept any server certificate. +// +// Why: Coolify auto-issues Let's Encrypt ECDSA certs (E-series +// intermediate → ISRG Root X2). The 2020 BouncyCastle bundle +// pre-dates ISRG Root X2's 2020 launch, so chain validation can't +// reach a trusted root and the TLS handshake aborts with alert 90 +// user_canceled. Swap the cert verifier hooks for no-ops so chain +// validation is bypassed; the actual ECDSA crypto still runs. +// +// Two layers patched: +// a) NotifyServerCertificate (Legacy + ServerOnly Tls +// Authentication) — these are the per-handshake callbacks BC +// invokes after parsing the cert chain. Default impls in this +// build wrap an ICertificateVerifyer; we short-circuit them. +// b) AlwaysValidVerifyer.IsValid is already always-true, so any +// code path that already uses it is fine — the patches above +// convert the rest. +[HarmonyPatch(typeof(BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication), + nameof(BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication.NotifyServerCertificate))] +internal static class TlsTrustServerOnly +{ + public static bool Prepare() => + Plugin.EnableTlsTrustBypass != null && Plugin.EnableTlsTrustBypass.Value; + + public static bool Prefix() => false; // skip original — never reject +} + +[HarmonyPatch(typeof(Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication), + nameof(Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication.NotifyServerCertificate))] +internal static class TlsTrustLegacy +{ + public static bool Prepare() => + Plugin.EnableTlsTrustBypass != null && Plugin.EnableTlsTrustBypass.Value; + + public static bool Prefix() => false; // skip original — never reject +} + +// ── 8z. Photon region ping bypass — short-circuit +// PUNNetworkManager.PingPhotonRegions() with a pre-resolved +// {us:1} dictionary so the broken coroutine never runs. +// +// The original PingPhotonRegionsInternal coroutine ToDictionary's +// PhotonNetwork's available-region list keyed by +// `CloudRegionCode`. The 2020 watch's CloudRegionCode enum knows +// 13 codes (eu, us, asia, jp, au, usw, sa, cae, kr, in, ru, rue, +// none) — anything else from Photon Cloud's modern NameServer +// (e.g. usw2, eu2, tr, za, jp2) parses as `none = 4`. Two +// unknown codes → two regions with key `none` → ToDictionary +// throws `ArgumentException: An item with the same key has +// already been added. Key: none` (output_log.txt:209-220), and +// the coroutine fails before completing the promise — every +// consumer awaiting it is stuck. +// +// We can't fix Photon's region list (it comes from +// Photon Cloud's NameServer, governed by the AppId's dashboard +// config). Patching the dashboard to disable unknown regions +// works in theory but the user's AppId is configured for the +// full set. Cheaper to just hand the watch a "single us region" +// promise — Rec Room only ever connected to one region per +// session anyway, and our dorknet config (Photon:CloudRegion) +// is "us" by default. +[HarmonyPatch(typeof(PUNNetworkManager), nameof(PUNNetworkManager.PingPhotonRegions))] +internal static class PhotonRegionPingShortcut +{ + public static bool Prepare() => + Plugin.EnablePhotonRegionBypass != null && Plugin.EnablePhotonRegionBypass.Value; + + public static bool Prefix( + ref RecRoom.Async.IPromise> __result) + { + try + { + // Single-entry dict {us = 1ms}. Watch awaits this + // promise then picks the "best" (only) region. No ping + // is actually performed — the promise resolves + // immediately so downstream Photon connect logic + // proceeds without waiting on a broken coroutine. + var dict = new Il2CppSystem.Collections.Generic.Dictionary< + CloudRegionCode, int>(); + dict[CloudRegionCode.us] = 1; + + var promise = new RecRoom.Async.Promise>(); + promise.Complete(dict.Cast>()); + // Il2CppInterop's Promise wrapper isn't covariantly + // related to IPromise at the C# level, so go through + // Cast() (every interop wrapper exposes it). + __result = promise.Cast>>(); + + // The ORIGINAL coroutine doesn't just return a dict — at + // PingPhotonRegionsInternal.txt:928+940 it also calls + // PhotonNetwork.OverrideBestCloudServer(bestCode) + + // ConnectToBestCloudServer(gameVersion), which is what + // actually opens the wire connection to the chosen region. + // We bypassed the coroutine, so we have to issue those + // calls ourselves — otherwise PhotonNetwork.IsConnected + // stays false and BootSequence's + // b__68_5 fires + // RegisterError("Unable to connect to RecNet game + // servers") (BootSequence.txt:10490). + PhotonNetwork.OverrideBestCloudServer(CloudRegionCode.us); + // gameVersion: empty string keeps the watch's existing + // PhotonServerSettings.AppVersion. The original coroutine + // at line 940 passes a similarly-stub value. + PhotonNetwork.ConnectToBestCloudServer(string.Empty); + + Plugin.Log.LogInfo("[photon-region] short-circuit → {us:1} + ConnectToBestCloudServer"); + return false; // skip original + } + catch (Exception ex) + { + Plugin.Log.LogWarning($"[photon-region] short-circuit failed: {ex.Message}; falling back to original"); + return true; + } + } +} + +// ── 8a. Avatar parse exception swallow — wrap +// AvatarItem.FromRecNetString(string) so an "InvalidOperationException: +// Missing data for guid …" / AvatarParseException("Bad outfit +// string …") doesn't propagate up the IL2CPP stack and tear down +// the watch's player. +// +// The dorm scene's hardcoded ImposterSpawnManager prefabs reference +// avatar items by GUID — some of those GUIDs are SWATCH/MASK asset +// guids (in swatchLookup/maskLookup) instead of +// AvatarItem guids (in avatarItemDataLookup). The watch's +// parser only checks the AvatarItem dict and throws on misses; +// those throws bubble through ImposterSpawnManager.ProcessQueue → +// Scheduler → Update and ultimately destroy the local player +// (output_log.txt:1568-1599). Returning a default AvatarItem +// for the bad guid lets the queue processor skip the item and +// keep going. +// +// The two-arg overload (called by the one-arg overload) is what +// actually throws — patch the entry point so both call sites +// benefit. +// Substitute unknown GUIDs with a known-safe fallback BEFORE the +// original parser runs — purely returning default(AvatarItem) on +// throw leaves the watch's callers with a struct whose +// AvatarItemData / AvatarItemVisualData are null, which they then +// dereference (NullReferenceException → caught by the finalizer → +// caller dereferences the still-default struct → IL2CPP runtime +// segfault, no managed log). +// +// Strategy: check the GUID prefix against +// AvatarItemWardrobeRuntimeConfig.Config.avatarItemDataLookup +// (loaded lazily from the client's own runtime config). If the GUID +// isn't in the dict, rewrite avatarItemDesc / itemGuid to point +// at FALLBACK_GUID — a confirmed-safe AvatarItem (Hat_Angler) +// that always renders. The original method then runs unchanged on +// the substituted GUID and returns a fully-populated AvatarItem. +// Callers see a real AvatarItem, render the fallback prefab, and +// continue without segfaulting. +internal static class AvatarItemSafelist +{ + // Hat_Angler — first entry in StarterOutfitSelections, slot 0 + // (Head). Its AvatarItem is in avatarItemDataLookup AND its + // prefab is in avatarItemPrefabLookup, so any caller can render + // it as a fallback without triggering further misses. + public const string FallbackGuid = "03f8c394-28fa-4087-978b-8d108f0bd969"; + + private static System.Collections.Generic.HashSet? _safe; + private static int _failCount; + + public static bool IsSafe(string guid) + { + if (Plugin.SubstituteUnknownAvatarItems == null || + !Plugin.SubstituteUnknownAvatarItems.Value) + return true; + + if (string.IsNullOrEmpty(guid)) return false; + if (_safe is null) + { + try + { + var config = RecRoom.Avatar.Data.Runtime.AvatarItemWardrobeRuntimeConfig.Config; + if (config == null) + { + if (System.Threading.Interlocked.Increment(ref _failCount) <= 3) + Plugin.Log.LogWarning("[avatar-safelist] runtime config is null — returning false"); + return false; + } + if (config.avatarItemDataLookup == null) + { + if (System.Threading.Interlocked.Increment(ref _failCount) <= 3) + Plugin.Log.LogWarning("[avatar-safelist] avatarItemDataLookup is null — returning false"); + return false; + } + var set = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var k in config.avatarItemDataLookup.Keys) + { + if (!string.IsNullOrEmpty(k)) set.Add(k); + } + _safe = set; + Plugin.Log.LogInfo($"[avatar-safelist] initialized with {set.Count} GUIDs"); + } + catch (Exception ex) + { + if (System.Threading.Interlocked.Increment(ref _failCount) <= 3) + Plugin.Log.LogWarning($"[avatar-safelist] init failed: {ex.GetType().Name} {ex.Message}"); + return false; + } + } + return _safe.Contains(guid); + } +} + +[HarmonyPatch(typeof(RecRoom.Avatar.Data.Runtime.AvatarItem), + nameof(RecRoom.Avatar.Data.Runtime.AvatarItem.FromRecNetString), + new[] { typeof(string) })] +internal static class AvatarItemParseSubstitute +{ + private static int _subCount; + // Per-GUID first-time log dedup so the log shows which UNIQUE + // unknown GUIDs are being hit (cap at 50 different guids; after + // that we suppress to keep the log readable). Without this the + // first 5 hits of one repeating guid burn the global cap and + // hide every other guid from the log — which made it impossible + // to tell whether wardrobe store tiles were being substituted. + private static readonly System.Collections.Concurrent.ConcurrentDictionary _seenGuids + = new(StringComparer.OrdinalIgnoreCase); + + public static bool Prepare() => + Plugin.SubstituteUnknownAvatarItems != null && Plugin.SubstituteUnknownAvatarItems.Value; + + public static void Prefix(ref string avatarItemDesc) + { + if (string.IsNullOrEmpty(avatarItemDesc)) return; + var comma = avatarItemDesc.IndexOf(','); + var guid = comma < 0 ? avatarItemDesc : avatarItemDesc.Substring(0, comma); + if (AvatarItemSafelist.IsSafe(guid)) return; + var hitN = System.Threading.Interlocked.Increment(ref _subCount); + // Log the FIRST occurrence of each unique unknown guid (up to + // 50 unique guids), plus a periodic heartbeat every 250 hits + // total so we know substitution volume without spam. + var firstSeen = _seenGuids.TryAdd(guid, 0); + if ((firstSeen && _seenGuids.Count <= 50) || hitN % 250 == 0) + Plugin.Log.LogWarning( + $"[avatar-parse] substitute unknown guid '{guid}' → fallback (Hat_Angler) " + + $"[unique #{_seenGuids.Count} / total #{hitN}]"); + // Keep whatever customization tail the caller had so the + // 5-segment AvatarItemSelection format stays well-formed. + var tail = comma < 0 ? ",,," : avatarItemDesc.Substring(comma); + avatarItemDesc = AvatarItemSafelist.FallbackGuid + tail; + } + + // Belt-and-suspenders: if a GUID slips past IsSafe but the + // original still throws (e.g. transient lookup race), return a + // default item. Better a missing prefab than a hard crash. + [HarmonyFinalizer] + public static Exception? Finalizer( + Exception __exception, + string avatarItemDesc, + ref RecRoom.Avatar.Data.Runtime.AvatarItem __result) + { + if (__exception == null) return null; + if (Plugin.SubstituteUnknownAvatarItems == null || + !Plugin.SubstituteUnknownAvatarItems.Value) + return __exception; + + Plugin.Log.LogWarning( + $"[avatar-parse] finalizer swallowed {__exception.GetType().Name}: " + + $"{__exception.Message} — desc='{avatarItemDesc}'"); + __result = default!; + return null; + } +} + +[HarmonyPatch(typeof(RecRoom.Avatar.Data.Runtime.AvatarItem), + nameof(RecRoom.Avatar.Data.Runtime.AvatarItem.FromRecNetString), + new[] { typeof(string), typeof(Il2CppSystem.ArraySegment) })] +internal static class AvatarItemParseSubstituteTwoArg +{ + private static int _subCount; + private static readonly System.Collections.Concurrent.ConcurrentDictionary _seenGuids + = new(StringComparer.OrdinalIgnoreCase); + + public static bool Prepare() => + Plugin.SubstituteUnknownAvatarItems != null && Plugin.SubstituteUnknownAvatarItems.Value; + + public static void Prefix(ref string itemGuid) + { + if (string.IsNullOrEmpty(itemGuid)) return; + if (AvatarItemSafelist.IsSafe(itemGuid)) return; + var hitN = System.Threading.Interlocked.Increment(ref _subCount); + var firstSeen = _seenGuids.TryAdd(itemGuid, 0); + if ((firstSeen && _seenGuids.Count <= 50) || hitN % 250 == 0) + Plugin.Log.LogWarning( + $"[avatar-parse] (2-arg) substitute unknown guid '{itemGuid}' → fallback (Hat_Angler) " + + $"[unique #{_seenGuids.Count} / total #{hitN}]"); + itemGuid = AvatarItemSafelist.FallbackGuid; + } + + [HarmonyFinalizer] + public static Exception? Finalizer( + Exception __exception, + string itemGuid, + ref RecRoom.Avatar.Data.Runtime.AvatarItem __result) + { + if (__exception == null) return null; + if (Plugin.SubstituteUnknownAvatarItems == null || + !Plugin.SubstituteUnknownAvatarItems.Value) + return __exception; + + Plugin.Log.LogWarning( + $"[avatar-parse] finalizer swallowed {__exception.GetType().Name}: " + + $"{__exception.Message} — guid='{itemGuid}'"); + __result = default!; + return null; + } +} + +internal static class GlobalManagedCatchAll +{ + private static int _installed; + private static int _logCount; + + public static bool Enabled => + Plugin.CatchAllManagedExceptions != null && Plugin.CatchAllManagedExceptions.Value; + + public static void Install() + { + if (!Enabled) return; + if (System.Threading.Interlocked.Exchange(ref _installed, 1) == 1) return; + + AppDomain.CurrentDomain.UnhandledException += (_, args) => + { + Log("appdomain", args.ExceptionObject as Exception); + }; + + System.Threading.Tasks.TaskScheduler.UnobservedTaskException += (_, args) => + { + Log("task", args.Exception); + args.SetObserved(); + }; + + } + + public static Exception? Swallow(Exception? exception, string source) + { + if (exception == null) return null; + if (!Enabled) return exception; + Log(source, exception); + return null; + } + + private static void Log(string source, Exception? ex) + { + if (System.Threading.Interlocked.Increment(ref _logCount) > 80) return; + if (ex == null) + Plugin.Log.LogWarning($"[catch-all:{source}] swallowed unknown managed exception"); + else + Plugin.Log.LogWarning($"[catch-all:{source}] swallowed {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"); + } + +} + +internal static class UnityDetailedLogBridge +{ + private static int _installed; + + public static bool Enabled => + Plugin.DetailedUnityLogs != null && Plugin.DetailedUnityLogs.Value; + + public static void Install() + { + if (!Enabled) return; + if (System.Threading.Interlocked.Exchange(ref _installed, 1) == 1) return; + + try + { + Application.SetStackTraceLogType(LogType.Log, StackTraceLogType.ScriptOnly); + Application.SetStackTraceLogType(LogType.Warning, StackTraceLogType.Full); + Application.SetStackTraceLogType(LogType.Assert, StackTraceLogType.Full); + Application.SetStackTraceLogType(LogType.Error, StackTraceLogType.Full); + Application.SetStackTraceLogType(LogType.Exception, StackTraceLogType.Full); + Plugin.Log.LogInfo("[unity-logs] enabled full stack traces for warnings/errors/exceptions"); + } + catch (Exception ex) + { + Plugin.Log.LogWarning($"[unity-logs] install failed: {ex.GetType().Name} {ex.Message}"); + } + } +} + +[HarmonyPatch(typeof(RRUI.Data.Components.DynamicAvatarItemImposter), "OnAvatarItemDescChange")] +internal static class DynamicAvatarItemImposterCatchAll +{ + public static bool Prepare() => GlobalManagedCatchAll.Enabled; + + [HarmonyFinalizer] + public static Exception? Finalizer(Exception __exception) + { + return GlobalManagedCatchAll.Swallow(__exception, "avatar-imposter"); + } +} + +[HarmonyPatch] +internal static class SchedulerManagedCatchAll +{ + public static bool Prepare() => GlobalManagedCatchAll.Enabled; + + public static System.Collections.Generic.IEnumerable TargetMethods() + { + var scheduler = AccessTools.TypeByName("RecRoom.Core.Scheduler"); + if (scheduler == null) yield break; + + foreach (var name in new[] + { + "Update", + "UpdateQueue", + "LateUpdate", + "FixedUpdate", + "OnPostUpdate", + "OnPreRenderUpdate", + "OnRigidbodyExLateUpdate" + }) + { + var method = AccessTools.Method(scheduler, name); + if (method != null) yield return method; + } + } + + [HarmonyFinalizer] + public static Exception? Finalizer(Exception __exception, MethodBase __originalMethod) + { + return GlobalManagedCatchAll.Swallow(__exception, __originalMethod.Name); + } +} + +// ── 9. Storefront objective guard — older DorkNet deployments returned +// objective-progress JSON from api/storefronts/v1/objectives, but the +// March 2020 client deserializes that POST as +// Storefronts.BalanceUpdateResponseDTO. The mismatch throws +// KeyNotFoundException inside RecNet.Storefronts and tears down the +// player shortly after entering RecCenter. The server endpoint is fixed +// in this repo, but this guard keeps clients alive against stale servers. +[HarmonyPatch(typeof(RecNet.Storefronts), nameof(RecNet.Storefronts.CompleteObjectives))] +internal static class StorefrontCompleteObjectivesGuard +{ + public static bool Prepare() => + Plugin.SkipStorefrontObjectivePosts != null && Plugin.SkipStorefrontObjectivePosts.Value; + + public static bool Prefix() + { + if (Plugin.SkipStorefrontObjectivePosts == null || + !Plugin.SkipStorefrontObjectivePosts.Value) + return true; + + Plugin.Log.LogInfo("[storefront-objectives] skipped client POST; server-side reward sync is disabled"); + return false; + } +} + +// ── 9b. Targeted avatar/store diagnostics — the Unity output log often +// shows only the RRUI binding property that failed, not the RecNet DTO +// that populated it. These patches log the exact gift/drop/unlocked item +// descriptors the client accepted. Avoid patching tiny RRUI property +// getters here; HarmonyX wrappers around IL2CPP getters can fault before +// managed finalizers run. +internal static class AvatarStoreDiagnostics +{ + private const int MaxDtoLogs = 120; + private static int _dtoLogs; + + public static bool Enabled => + Plugin.DetailedAvatarStoreLogs != null && Plugin.DetailedAvatarStoreLogs.Value; + + public static void LogStorefrontGiftDrop(RecNet.StorefrontGiftDrop drop, string source) + { + if (!Enabled || drop == null || !ShouldLogDto()) return; + Plugin.Log.LogWarning( + $"[diag:{source}] StorefrontGiftDrop id={Safe(() => drop.GiftDropId)} " + + $"type={Safe(() => drop.AvatarItemType)} content={Safe(() => drop.Content)} " + + $"rarity={Safe(() => drop.Rarity)} name='{Safe(() => drop.FriendlyName)}' " + + $"desc='{Safe(() => drop.AvatarItemDescOrHairDyeDesc)}' " + + $"consumable='{Safe(() => drop.ConsumableItemDesc)}' " + + $"equipment='{Safe(() => drop.EquipmentPrefabName)}' mod='{Safe(() => drop.EquipmentModificationGuid)}'"); + } + + public static void LogUnlockedAvatarItem(RecNet.Avatars.UnlockedAvatarItem item, string source) + { + if (!Enabled || item == null || !ShouldLogDto()) return; + Plugin.Log.LogWarning( + $"[diag:{source}] UnlockedAvatarItem type={Safe(() => item.AvatarItemType)} " + + $"platform={Safe(() => item.PlatformMask)} rarity={Safe(() => item.Rarity)} " + + $"name='{Safe(() => item.FriendlyName)}' desc='{Safe(() => item.AvatarItemDesc)}'"); + } + + public static void LogGiftPackage(RecNet.Avatars.GiftPackage gift, string source) + { + if (!Enabled || gift == null || !ShouldLogDto()) return; + Plugin.Log.LogWarning( + $"[diag:{source}] GiftPackage id={Safe(() => gift.Id)} valid={Safe(() => gift.IsValid)} " + + $"consumed={Safe(() => gift.Consumed)} platformOk={Safe(() => gift.SupportsCurrentPlatform)} " + + $"type={Safe(() => gift.AvatarItemType)} context={Safe(() => gift.GiftContext)} " + + $"rarity={Safe(() => gift.GiftRarity)} error='{Safe(() => gift.ErrorMessage)}' " + + $"desc='{Safe(() => gift.AvatarItemDescOrHairDyeDesc)}' " + + $"consumable='{Safe(() => gift.ConsumableItemDesc)}' " + + $"equipment='{Safe(() => gift.EquipmentPrefabName)}' mod='{Safe(() => gift.EquipmentModificationGuid)}'"); + } + + private static bool ShouldLogDto() => + System.Threading.Interlocked.Increment(ref _dtoLogs) <= MaxDtoLogs; + + public static string Safe(Func read) + { + try { return read()?.ToString() ?? ""; } + catch (Exception ex) { return $""; } + } + + public static string DescribeObject(object? value) + { + if (value == null) return ""; + try + { + var text = value.ToString(); + return string.IsNullOrWhiteSpace(text) + ? value.GetType().FullName ?? value.GetType().Name + : text; + } + catch (Exception ex) + { + return $""; + } + } + + public static string DescribeBalance(object? value) + { + if (value == null) return ""; + return "CurrencyType=" + Safe(() => ReadMember(value, "CurrencyType")) + + " BalanceType=" + Safe(() => ReadMember(value, "BalanceType")) + + " Balance=" + Safe(() => ReadMember(value, "Balance")); + } + + public static string DescribeAvatarObject(object? value) + { + if (value == null) return ""; + var recNet = Safe(() => InvokeNoArg(value, "ToRecNetString")); + if (!recNet.StartsWith(""; + } + + private static object? InvokeNoArg(object value, string name) + { + var method = value.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, Type.EmptyTypes, null); + return method == null ? "" : method.Invoke(value, null); + } +} + +[HarmonyPatch(typeof(RecNet.StorefrontGiftDrop), nameof(RecNet.StorefrontGiftDrop.Deserialize))] +internal static class StorefrontGiftDropDiagnostics +{ + public static bool Prepare() => AvatarStoreDiagnostics.Enabled; + + public static void Postfix(RecNet.StorefrontGiftDrop __instance) + { + AvatarStoreDiagnostics.LogStorefrontGiftDrop(__instance, "deserialize"); + } +} + +[HarmonyPatch(typeof(RecNet.Avatars.UnlockedAvatarItem), nameof(RecNet.Avatars.UnlockedAvatarItem.Deserialize))] +internal static class UnlockedAvatarItemDiagnostics +{ + public static bool Prepare() => AvatarStoreDiagnostics.Enabled; + + public static void Postfix(RecNet.Avatars.UnlockedAvatarItem __instance) + { + AvatarStoreDiagnostics.LogUnlockedAvatarItem(__instance, "deserialize"); + } +} + +[HarmonyPatch(typeof(RecNet.Avatars.GiftPackage), nameof(RecNet.Avatars.GiftPackage.Deserialize))] +internal static class GiftPackageDiagnostics +{ + public static bool Prepare() => AvatarStoreDiagnostics.Enabled; + + public static void Postfix(RecNet.Avatars.GiftPackage __instance) + { + AvatarStoreDiagnostics.LogGiftPackage(__instance, "deserialize"); + } +} + +[HarmonyPatch] +internal static class StorefrontBalanceDiagnostics +{ + private static int _logCount; + + public static bool Prepare() => AvatarStoreDiagnostics.Enabled; + + public static System.Collections.Generic.IEnumerable TargetMethods() + { + foreach (var method in AccessTools.GetDeclaredMethods(typeof(RecNet.Storefronts))) + { + if (method.Name is "UpdateCachedBalance" or "UpdateCachedBalances" or + "RaiseBalanceUpdatedEvent" or "OnStorefrontBalanceUpdated" or + "OnStorefrontBalancePurchased" or "OnStorefrontBalanceAddReceived") + yield return method; + } + } + + public static void Prefix(MethodBase __originalMethod, object[] __args) + { + if (!AvatarStoreDiagnostics.Enabled || System.Threading.Interlocked.Increment(ref _logCount) > 160) + return; + + try + { + Plugin.Log.LogWarning($"[diag:storefront:{__originalMethod.Name}] args={DescribeArgs(__args)}"); + } + catch (Exception ex) + { + Plugin.Log.LogWarning($"[diag:storefront:{__originalMethod.Name}] log failed: {ex.GetType().Name} {ex.Message}"); + } + } + + [HarmonyFinalizer] + public static Exception? Finalizer(Exception __exception, MethodBase __originalMethod, object[] __args) + { + if (__exception == null) return null; + Plugin.Log.LogWarning( + $"[diag:storefront:{__originalMethod.Name}] exception {__exception.GetType().Name}: {__exception.Message} " + + $"args={DescribeArgs(__args)}\n{__exception.StackTrace}"); + return GlobalManagedCatchAll.Swallow(__exception, "storefront:" + __originalMethod.Name); + } + + private static string DescribeArgs(object[]? args) + { + if (args == null || args.Length == 0) return ""; + var parts = new List(); + for (var i = 0; i < args.Length; i++) + parts.Add($"#{i}={DescribeArg(args[i])}"); + return string.Join("; ", parts); + } + + private static string DescribeArg(object? arg) + { + if (arg == null) return ""; + if (arg is System.Collections.IEnumerable seq && arg is not string) + { + var items = new List(); + var count = 0; + foreach (var item in seq) + { + if (++count <= 8) items.Add(AvatarStoreDiagnostics.DescribeBalance(item)); + } + return $"[{string.Join(" | ", items)}] count>={count}"; + } + + var typeName = arg.GetType().FullName ?? arg.GetType().Name; + if (typeName.Contains("Balance", StringComparison.OrdinalIgnoreCase)) + return AvatarStoreDiagnostics.DescribeBalance(arg); + return AvatarStoreDiagnostics.DescribeObject(arg); + } +} + +[HarmonyPatch] +internal static class PlayerAvatarOutfitDiagnostics +{ + private static int _logCount; + + public static bool Prepare() => AvatarStoreDiagnostics.Enabled; + + public static System.Collections.Generic.IEnumerable TargetMethods() + { + foreach (var method in AccessTools.GetDeclaredMethods(typeof(PlayerAvatar))) + { + if (method.Name is "ApplyLocalPlayerAvatarSettings" or "EquipOutfitItem" or + "EquipOutfitItemInternal" or "SetLocalPlayerSelectedOutfit" or + "LocalLoadSavedOutfit" or "UnequipOutfitItem" or "OnPlayerOutfitSelectionsChanged") + yield return method; + } + } + + public static void Prefix(MethodBase __originalMethod, object[] __args) + { + if (!AvatarStoreDiagnostics.Enabled || System.Threading.Interlocked.Increment(ref _logCount) > 140) + return; + + try + { + Plugin.Log.LogWarning($"[diag:outfit:{__originalMethod.Name}] args={DescribeArgs(__args)}"); + } + catch (Exception ex) + { + Plugin.Log.LogWarning($"[diag:outfit:{__originalMethod.Name}] log failed: {ex.GetType().Name} {ex.Message}"); + } + } + + [HarmonyFinalizer] + public static Exception? Finalizer(Exception __exception, MethodBase __originalMethod, object[] __args) + { + if (__exception == null) return null; + Plugin.Log.LogWarning( + $"[diag:outfit:{__originalMethod.Name}] exception {__exception.GetType().Name}: {__exception.Message} " + + $"args={DescribeArgs(__args)}\n{__exception.StackTrace}"); + return GlobalManagedCatchAll.Swallow(__exception, "outfit:" + __originalMethod.Name); + } + + private static string DescribeArgs(object[]? args) + { + if (args == null || args.Length == 0) return ""; + var parts = new List(); + for (var i = 0; i < args.Length; i++) + parts.Add($"#{i}={DescribeArg(args[i])}"); + return string.Join("; ", parts); + } + + private static string DescribeArg(object? arg) + { + if (arg == null) return ""; + if (arg is string s) return s; + if (arg is System.Collections.IEnumerable seq) + { + var items = new List(); + var count = 0; + foreach (var item in seq) + { + if (++count <= 8) items.Add(AvatarStoreDiagnostics.DescribeAvatarObject(item)); + } + return $"[{string.Join(" | ", items)}] count>={count}"; + } + + var typeName = arg.GetType().FullName ?? arg.GetType().Name; + if (typeName.Contains("Avatar", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("Outfit", StringComparison.OrdinalIgnoreCase) || + typeName.Contains("BodyPart", StringComparison.OrdinalIgnoreCase)) + return AvatarStoreDiagnostics.DescribeAvatarObject(arg); + return AvatarStoreDiagnostics.DescribeObject(arg); + } +} + +// ── 10. Mirror outfit guard -- the March 2020 mirror UI can try to remove +// the required torso item when clicking the currently equipped shirt. The +// client then force-adds a default torso ("Adding required torso item!") and +// the local player teardown follows shortly after. Treat a direct torso +// unequip as a no-op; normal shirt swaps still go through EquipOutfitItem. +[HarmonyPatch(typeof(PlayerAvatar), nameof(PlayerAvatar.UnequipOutfitItem))] +internal static class RequiredTorsoUnequipGuard +{ + private const int TorsoBodyPart = 1; + + public static bool Prepare() => + Plugin.BlockDirectTorsoUnequip != null && Plugin.BlockDirectTorsoUnequip.Value; + + public static bool Prefix(object[] __args, ref bool __result) + { + if (Plugin.BlockDirectTorsoUnequip == null || + !Plugin.BlockDirectTorsoUnequip.Value) + return true; + + if (TryReadBodyPart(__args, out var bodyPart) && bodyPart == TorsoBodyPart) + { + Plugin.Log.LogInfo("[avatar-outfit] blocked direct torso unequip from mirror UI"); + __result = false; + return false; + } + + return true; + } + + private static bool TryReadBodyPart(object[]? args, out int bodyPart) + { + bodyPart = int.MinValue; + if (args is not { Length: > 1 } || args[1] is null) return false; + + try + { + bodyPart = Convert.ToInt32(args[1]); + return true; + } + catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException) + { + var text = args[1].ToString(); + if (text?.EndsWith("Torso", StringComparison.OrdinalIgnoreCase) == true) + { + bodyPart = TorsoBodyPart; + return true; + } + + if (int.TryParse(text, out bodyPart)) + return true; + + Plugin.Log.LogDebug($"[avatar-outfit] could not decode bodyPart argument: {text}"); + return false; + } + } +} + +// ── 11. Screen cleanup guard — the March 2020 desktop HUD can throw a +// NullReferenceException while tearing down the local player if the +// ScreenHUD/cursor objects are already gone. That exception appears in the +// current crash path after the local player is destroyed. Suppress only this +// cleanup exception so we can keep the process alive long enough to expose +// the real disconnect/crash trigger in the logs. +[HarmonyPatch(typeof(RecRoom.ScreenPlayerController), "CleanupLocalPlayer")] +internal static class ScreenPlayerCleanupGuard +{ + public static bool Prepare() => + Plugin.SuppressScreenCleanupNullRef != null && Plugin.SuppressScreenCleanupNullRef.Value; + + public static Exception? Finalizer(Exception __exception) + { + if (__exception == null) return null; + if (Plugin.SuppressScreenCleanupNullRef == null || + !Plugin.SuppressScreenCleanupNullRef.Value) + return __exception; + + if (IsCleanupHudNullReference(__exception)) + { + Plugin.Log.LogWarning("[screen-cleanup] swallowed CleanupLocalPlayer null-ref during local player teardown"); + return null; + } + return __exception; + } + + private static bool IsCleanupHudNullReference(Exception exception) + { + if (exception is NullReferenceException) return true; + + // IL2CPP exceptions arrive through Harmony as Il2CppException wrappers, + // so match the IL2CPP stack text rather than only the managed type. + var text = exception.ToString(); + return text.Contains("System.NullReferenceException", StringComparison.Ordinal) + && text.Contains("RecRoom.ScreenPlayerController.CleanupLocalPlayer", StringComparison.Ordinal) + && text.Contains("RecRoom.UI.ScreenHUDElement.get_RectTransform", StringComparison.Ordinal); + } +} + +// ── 12. Diagnostics — log every BootSequence.RegisterError invocation +// with a managed-side stack trace so we can tell which API call +// actually failed (the IL2CPP stack traces in Player.log are +// obfuscated to method symbols like "RKHMMYKOXZK"). The watch's +// boot pipeline collapses any failure into "Rec Room update +// required", which is useless without knowing the actual cause. +[HarmonyPatch(typeof(BootSequence), "RegisterError")] +internal static class BootSequenceRegisterErrorTrace +{ + public static bool Prepare() => + Plugin.EnableBootDiagnostics != null && Plugin.EnableBootDiagnostics.Value; + + // The original Prefix(string error) was emitting blank text — Harmony's + // IL2CPP string marshaling for an instance-method first parameter was + // dropping the string value (every entry came out as "[boot-error] " in + // the log). Use HarmonyX's __args to pull the raw arguments and decode + // them manually, plus dump the actual call site so we know which of + // the two known callers + // - "Error initializing Core Systems" (BootSequence:5555) + // - "Unable to connect to RecNet game servers" (BootSequence:10490) + // fired. + public static void Prefix(object[] __args, BootSequence __instance) + { + string? err = null; + try + { + if (__args is { Length: > 0 } && __args[0] is { } a0) + { + err = a0 switch + { + string s => s, + Il2CppSystem.String i2cs => i2cs.ToString(), + _ => a0.ToString(), + }; + } + } + catch (Exception ex) { err = $""; } + Plugin.Log.LogWarning($"[boot-error] '{err ?? ""}'"); + try + { + Plugin.Log.LogWarning(new System.Diagnostics.StackTrace(1, true).ToString()); + } + catch { /* StackTrace can fail in IL2CPP; swallow */ } + } +} + +internal static class MatchmakingHelpers +{ + /// Reads the watch's persistent LoginLock from PlayerPrefs. + /// The key is LoginLockTokenV2 (verified at + /// Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:1810 — the + /// Move rdi, "LoginLockTokenV2" + Call PlayerPrefs.GetString + /// pair inside Matchmaking.LoadLockToken). Stored value is a + /// JSON object of RecNet.Matchmaking+LoginLockTokenParams + /// (Cpp2IL_CS/.../RecNet/Matchmaking.cs:129) with three fields: + /// Platform (int), PlatformId (string), LockToken + /// (string). We extract LockToken by scanning for the literal + /// "LockToken":"…" pair — cheaper than parsing JSON for one + /// string, and the watch's serialiser doesn't produce escapes that'd + /// break a naive scan. + /// + public static string? GetCurrentLockToken() + { + try + { + var raw = PlayerPrefs.GetString("LoginLockTokenV2", string.Empty); + if (string.IsNullOrEmpty(raw)) return null; + const string key = "\"LockToken\":\""; + var ix = raw.IndexOf(key, StringComparison.Ordinal); + if (ix < 0) return null; + ix += key.Length; + var end = raw.IndexOf('"', ix); + return end < 0 ? null : raw[ix..end]; + } + catch { return null; } + } +} diff --git a/DorkNet.ClientPatch/README.md b/DorkNet.ClientPatch/README.md new file mode 100644 index 0000000..e365d1b --- /dev/null +++ b/DorkNet.ClientPatch/README.md @@ -0,0 +1,217 @@ +# DorkNet ClientPatch BepInEx Plugin + +Single client-side artefact that does **everything** DorkNet needs to +patch into the 2020 watch: + +1. Rewrites every `*.rec.net` URL the watch builds → user-configured + replacement host (default `localhost`). Catches the bootstrap + `https://ns.rec.net/?v=2`; every other service URL flows in from + the nameserver response which the DorkNet server controls. No + more hosts file, no more wildcard mkcert (Cloudflare Tunnel terminates + real TLS at the edge of the configured host). +2. Overrides Photon Cloud `AppId` / `VoiceAppId` right before + `PhotonNetwork.ConnectUsingSettings`. No more byte-edits to + `resources.assets`, no more hardcoded offsets to chase across game + versions. +3. Attaches `userid` + `LoginLock` to `PhotonNetwork.AuthValues` right + before any authenticate op leaves the wire, so DorkNet's + `/photon/customauth` endpoint can identify the player and enforce + single-session. + +Configuration lives in `BepInEx/config/sh.dork.clientpatch.cfg`. Edit +values, restart the client, patches re-apply with the new settings. + +## Quickstart (recommended) + +Use the wrapper script — handles BepInEx install, interop generation, +plugin build, and config drop in one go: + +```pwsh +.\tools\install-plugin.ps1 ` + -RecRoomPath "C:\Program Files (x86)\Steam\steamapps\common\RecRoom\Recroom_Release_Data" ` + -PhotonAppId +``` + +The script auto-downloads a pinned BepInEx 6 IL2CPP build from +`builds.bepinex.dev` on first run and caches it at +`%LOCALAPPDATA%\DorkNet\BepInEx-Unity.IL2CPP-win-x64-6.0.0-be.696.zip` +so re-runs on the same machine don't re-download. To override the +pinned build (newer/older), pass `-BepInExZip `. + +After a client update, just re-run with the same args. The script +re-builds the plugin against the new game's interop assemblies. + +To remove: `.\tools\install-plugin.ps1 -RecRoomPath ... -Revert`. + +The legacy `patch-client.ps1` (asset-byte-edit + hosts file + mkcert +wildcard) is kept for backwards compat — see the file's header for +when you'd still want it. + +## Manual install (if you don't want to use install-plugin.ps1) + +Same steps, just done by hand. Useful for understanding the layout. + +## Why this exists + +The vanilla 2020 client never sets `PhotonNetwork.AuthValues` — verified +by an exhaustive grep across both `Assembly-CSharp.dll` and the Photon +`Assembly-CSharp-firstpass.dll` (no callers of +`PhotonNetwork.AuthValues =`, `new AuthenticationValues(...)`, +`AddAuthParameter(...)`, or `SetAuthPostData(...)`). Photon Cloud +therefore assigns a random per-connection UserId and our +`/photon/customauth` callback receives no client identity, so we can't +reject duplicate sessions. + +This plugin patches `NetworkingPeer.OpAuthenticate` / +`OpAuthenticateOnce` (the single chokepoint right before Photon's +authenticate op leaves the wire) and attaches: + +- `AuthType = CustomAuthenticationType.Custom` +- `UserId = ` +- `AuthGetParameters: userid=, LoginLock=` + +That makes the Photon Cloud `{userid}` / `{LoginLock}` placeholders +populate, our endpoint validates `LoginLock` against +`PlayerPresenceService` (Redis), and Photon's own UserId-collision +detection kicks duplicate connections automatically. + +## Prerequisites + +- 2020 client build (Steam build `20200306`, AppID `471710`). +- BepInEx **6.x IL2CPP** — *not* the regular Mono build. Download from + https://github.com/BepInEx/BepInEx/releases (look for the + `BepInEx_unix_x86_64_6.0.0-be.696_*.zip` line, Windows variant). +- .NET 6 SDK on the build machine (this project uses `net6.0`). + +## One-time Setup + +1. **Install BepInEx 6 IL2CPP into the client directory.** Extract the + release zip into the folder containing `Recroom_Release.exe`. After + extraction the layout looks like: + + ``` + / + Recroom_Release.exe + BepInEx/ + core/ + config/ + plugins/ ← we drop our DLL here + dotnet/ ← BepInEx-shipped runtime + winhttp.dll ← BepInEx loader + doorstop_config.ini + ``` + +2. **Generate Il2Cpp interop assemblies.** Run the game once. BepInEx + inspects the IL2CPP metadata on first launch and writes interop + assemblies to `BepInEx/interop/`. This is a one-time ~30s step; + re-do it any time the game updates. + + You should see lines like + `[Info: BepInEx] Generating Il2Cpp interop assemblies` in + `BepInEx/LogOutput.log`. + +3. **Quit the game.** The interop assemblies are now sitting in + `BepInEx/interop/Il2CppAssembly-CSharp.dll` etc. + +## Building the plugin + +The csproj references BepInEx core assemblies via `BepInExCore` and generated +interop assemblies via `BepInExInterop`. Build from the repo root: + +```pwsh +$env:BepInExCore = "C:\Program Files (x86)\Steam\steamapps\common\RecRoom\BepInEx\core" +$env:BepInExInterop = "C:\Program Files (x86)\Steam\steamapps\common\RecRoom\BepInEx\interop" +dotnet build DorkNet.ClientPatch -c Release +``` + +Output lands at: + +``` +DorkNet.ClientPatch/bin/Release/DorkNet.ClientPatch.dll +``` + +## Installing the plugin + +Copy the built DLL into the game's `BepInEx/plugins/` folder: + +```pwsh +Copy-Item ` + DorkNet.ClientPatch/bin/Release/DorkNet.ClientPatch.dll ` + "$env:BepInExInterop\..\plugins\" +``` + +Or use the patcher (next section). + +## Integration with `patch-client.ps1` + +The patcher tool grew an `-InstallBepInEx` switch. Pass the path to a +freshly-extracted BepInEx 6 IL2CPP zip and it'll: + +1. Extract BepInEx into the client folder if not already present. +2. Build this plugin against the existing `BepInEx/interop` directory. +3. Copy the DLL into `BepInEx/plugins/`. +4. Append a `Photon CustomAuth integration` step to the patch summary. + +```pwsh +.\tools\patch-client.ps1 ` + -RecRoomPath "C:\…\RecRoom\Recroom_Release_Data" ` + -PhotonAppId cb0880d9-… ` + -InstallBepInEx "C:\Downloads\BepInEx_unix_x86_64_6.0.0-be.696_…zip" +``` + +If the interop folder doesn't exist yet (game never launched +post-BepInEx-install), the patcher will tell you to launch the game +once and re-run with `-InstallBepInEx` to finish the build step. + +## Verifying the plugin loaded + +Launch the client. Tail `BepInEx/LogOutput.log` and look for: + +``` +[Info : BepInEx] Loading [DorkNet ClientPatch 1.0.0] +[Info :DorkNet ClientPatch] DorkNet ClientPatch v1.0.0 loading… +[Info :DorkNet ClientPatch] Photon AuthValues injection patches applied. +``` + +After login + the first Photon connect, also expect: + +``` +[Info :DorkNet ClientPatch] [auth-injector] set Photon AuthValues — userid=1811750, LoginLock= +``` + +On the server side, the `[photon-auth] received query={...}` log line +should now contain `userid=1811750&LoginLock=`. + +## Server-side: flip strict validation back on + +Once the plugin is shipping LoginLock in production: + +1. Restore the strict-mode body of + [`PhotonCustomAuthController`](../DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs) + — accept the parameters, look up the canonical lock from + `PlayerPresenceService.ValidateLock(playerId, loginLock)`, and + return `ResultCode 3` on mismatch. +2. In the Photon dashboard, flip **"Reject if Auth Failed"** ON. +3. Now a duplicate-account login on a *different* machine fails Photon + auth → never reaches the dorm. + +## Troubleshooting + +**`Could not load file or assembly 'Il2CppAssembly-CSharp'`** +The interop folder isn't where the csproj expects. Set the +`BepInExInterop` env var to your install's interop path before `dotnet +build`. + +**`No interop assemblies found, Il2CppType.Of<…>().GetField fails`** +Game never launched post-BepInEx-install. Launch once, quit, re-build. + +**Plugin loads but `[auth-injector] skip — no LocalAccountId yet` on every connect** +The Photon connect happens before login completes. Inspect the log +order — if RecNet auth genuinely hasn't run yet, the plugin can't help +on that particular connect; it'll succeed on the next one (post-login +Photon reconnect when joining a room). + +**Game crashes on launch with BepInEx but works without** +Either the plugin threw on Load (check `LogOutput.log` for stack +trace) or the BepInEx version mismatches — confirm you grabbed the +IL2CPP, *not* Mono, build for Windows x86_64. diff --git a/DorkNet.Models/Auth/CachedLogin.cs b/DorkNet.Models/Auth/CachedLogin.cs new file mode 100644 index 0000000..b7d8f05 --- /dev/null +++ b/DorkNet.Models/Auth/CachedLogin.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Auth; + +/// +/// Login.CachedLogin — dump.cs:578388, Deserialize at RVA 0x1447BD0. +/// +/// Verified by disassembly: reads platform, platformId, accountId, +/// lastLoginTime (DateTime), requirePassword (default false). The Account +/// field on the C# class is NOT read from the JSON dict — it is set via +/// SetAccount(Account) after the fact, so the JSON shape doesn't need it. +/// +/// Returned by: +/// GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId} +/// GET api.rec.net/api/platformlogin/cached +/// GET accounts.rec.net/account/v1/savedlogins +/// as a List<CachedLogin>. Empty list = no remembered accounts. +/// +public class CachedLogin +{ + // PlatformManager.PlatformType (int) + [JsonPropertyName("platform")] + public int Platform { get; set; } + + [JsonPropertyName("platformId")] + public string PlatformId { get; set; } = string.Empty; + + [JsonPropertyName("accountId")] + public int AccountId { get; set; } + + [JsonPropertyName("lastLoginTime")] + public DateTime LastLoginTime { get; set; } = DateTime.UtcNow; + + [JsonPropertyName("requirePassword")] + public bool RequirePassword { get; set; } = false; +} diff --git a/DorkNet.Models/Auth/LoginRequest.cs b/DorkNet.Models/Auth/LoginRequest.cs new file mode 100644 index 0000000..6ae44ee --- /dev/null +++ b/DorkNet.Models/Auth/LoginRequest.cs @@ -0,0 +1,9 @@ +namespace DorkNet.Models.Auth; + +public class LoginRequest +{ + public string? Name { get; set; } + public long BuildTimestamp { get; set; } + public int Platform { get; set; } + public string? DeviceId { get; set; } +} diff --git a/DorkNet.Models/Auth/LoginResponse.cs b/DorkNet.Models/Auth/LoginResponse.cs new file mode 100644 index 0000000..a7d57bc --- /dev/null +++ b/DorkNet.Models/Auth/LoginResponse.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Auth; + +/// +/// Login.LoginResponse — dump.cs:578473 (private nested class), +/// Deserialize at RVA 0x1447D10. +/// +/// Verified by disassembly: this is a standard OAuth 2.0 RFC 6749 +/// password-grant token response. The Deserialize body has two branches: +/// +/// error = dict["error"]; +/// if (string.IsNullOrEmpty(error)) { +/// this.AccessToken = dict["access_token"]; +/// this.RefreshToken = dict["refresh_token"]; +/// } else { +/// this.ErrorDescription = dict["error_description"]; +/// } +/// +/// Returned by api/platformlogin/v5 (the 2020 build's login endpoint) +/// and api/platformlogin/refresh. +/// +/// All four keys are snake_case, NOT PascalCase. This is the standard +/// OAuth response format — Rec Room conforms to RFC 6749 here. +/// +public class LoginResponse +{ + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } + + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } = string.Empty; +} diff --git a/DorkNet.Models/Auth/RecNetResult.cs b/DorkNet.Models/Auth/RecNetResult.cs new file mode 100644 index 0000000..05e5715 --- /dev/null +++ b/DorkNet.Models/Auth/RecNetResult.cs @@ -0,0 +1,122 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Auth; + +// ===================================================================== +// JSON keys verified by disassembling each .Deserialize method body +// directly from GameAssembly.dll. Each key here is the literal string +// passed to Util.GetKey / Util.GetKeyOrDefault inside the client. Util.GetKey +// uses Dictionary.TryGetValue, which is case-sensitive — so +// the keys MUST match exactly. +// +// Convention summary (it's mixed): +// RecNetResult lowercase "success", "error" +// Account camelCase "accountId", "displayName", "isJunior"… +// SelfAccount camelCase "email", "birthday", "juniorState"… +// LoginResponse snake_case (OAuth) "access_token", "error_description"… +// RefreshLoginResp. lowercase "token" +// CachedLogin camelCase "platformId", "lastLoginTime"… +// CreateAccountResp uses "value" as the wrapper key for the Account dict +// +// NOTE: TreatAsJunior on the C# side maps to JSON key "isJunior" — they +// don't match. Account.Deserialize reads dict["isJunior"], not dict["treatAsJunior"]. +// ===================================================================== + +/// +/// RecNet.RecNetResult — dump.cs:584325, Deserialize at RVA 0x1146340. +/// Reads "success" (required) and "error" (optional). +/// +public class RecNetResult +{ + [JsonPropertyName("success")] + public bool Success { get; set; } = true; + + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +/// +/// RecNet.Account — dump.cs:567684, Deserialize at RVA 0xF9DE50. +/// JSON keys verified in the current 2020 IL2CPP build. Account.Deserialize +/// reads profileImage. +/// Other keys: accountId, username, displayName, isJunior, platforms. The C# Account class also has +/// RawUsername and HasBirthday properties but Deserialize does not read +/// them from JSON (they're set via MakeNameAdhereToPlatformRequirements +/// or other flows). We expose them with reasonable camelCase keys for +/// other endpoints that may consume them. +/// +public class RecNetAccount +{ + private string profileImage = string.Empty; + + [JsonPropertyName("accountId")] + public int AccountId { get; set; } + + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("profileImage")] + public string ProfileImage + { + get => profileImage; + set => profileImage = value ?? string.Empty; + } + + // Account.Deserialize reads "isJunior" — NOT "treatAsJunior". + // The C# property name and the JSON key name differ. + [JsonPropertyName("isJunior")] + public bool TreatAsJunior { get; set; } = false; + + // PlatformManager.PlatformMask: Steam=1, Oculus=2, etc. + [JsonPropertyName("platforms")] + public int Platforms { get; set; } = 1; + + // Not in Account.Deserialize body but kept for forward-compat. + [JsonPropertyName("rawUsername")] + public string RawUsername { get; set; } = string.Empty; + + // Likewise. + [JsonPropertyName("hasBirthday")] + public bool HasBirthday { get; set; } = true; +} + +/// +/// RecNet.SelfAccount : Account — dump.cs:567797, Deserialize at RVA 0xAF4FB0. +/// Calls base Account.Deserialize, then reads: +/// email, phone, birthday, juniorState, parentAccountId. +/// +public class RecNetSelfAccount : RecNetAccount +{ + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + [JsonPropertyName("phone")] + public string Phone { get; set; } = string.Empty; + + [JsonPropertyName("birthday")] + public DateTime? Birthday { get; set; } = new DateTime(2000, 1, 1); + + // RecNet.JuniorState enum (int) + [JsonPropertyName("juniorState")] + public int JuniorState { get; set; } = 0; + + [JsonPropertyName("parentAccountId")] + public int? ParentAccountId { get; set; } = null; +} + +/// +/// Accounts.CreateAccountResponse : RecNetResult — dump.cs:568167, +/// Deserialize at RVA 0xFAA200. +/// Calls base RecNetResult.Deserialize (reads "success", "error"), +/// then reads the Account from dict["value"] — NOT dict["account"]. +/// Verified by disassembly: the only string literal after the base +/// call is "value", passed to Util.GetObjectKey<object>. +/// +public class CreateAccountResponse : RecNetResult +{ + [JsonPropertyName("value")] + public RecNetAccount Account { get; set; } = new(); +} diff --git a/DorkNet.Models/Auth/RefreshLoginResponse.cs b/DorkNet.Models/Auth/RefreshLoginResponse.cs new file mode 100644 index 0000000..9b70921 --- /dev/null +++ b/DorkNet.Models/Auth/RefreshLoginResponse.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Auth; + +/// +/// Login.RefreshLoginResponse — dump.cs:578533 (private nested class), +/// Deserialize at RVA 0x145B1E0. +/// +/// Verified by disassembly: reads exactly one key — lowercase "token" — +/// via Util.GetKey<string>. Single-field response from +/// POST api/platformlogin/refresh: the new (rotated) bearer access token. +/// +public class RefreshLoginResponse +{ + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; +} diff --git a/DorkNet.Models/Avatar/AvatarData.cs b/DorkNet.Models/Avatar/AvatarData.cs new file mode 100644 index 0000000..dd4a658 --- /dev/null +++ b/DorkNet.Models/Avatar/AvatarData.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Avatar; + +public class AvatarData +{ + [JsonPropertyName("AccountId")] + public long AccountId { get; set; } + + [JsonPropertyName("AvatarItemInstances")] + public List AvatarItemInstances { get; set; } = []; +} + +public class AvatarItemInstance +{ + [JsonPropertyName("Id")] + public long Id { get; set; } + + [JsonPropertyName("ItemId")] + public long ItemId { get; set; } + + [JsonPropertyName("IsEquipped")] + public bool IsEquipped { get; set; } +} diff --git a/DorkNet.Models/Avatar/AvatarItem.cs b/DorkNet.Models/Avatar/AvatarItem.cs new file mode 100644 index 0000000..764efba --- /dev/null +++ b/DorkNet.Models/Avatar/AvatarItem.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Avatar; + +public class AvatarItem +{ + [JsonPropertyName("ItemId")] + public long ItemId { get; set; } + + [JsonPropertyName("Name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("Equipped")] + public bool Equipped { get; set; } +} diff --git a/DorkNet.Models/Config/RecRoomConfig.cs b/DorkNet.Models/Config/RecRoomConfig.cs new file mode 100644 index 0000000..4ce9e5e --- /dev/null +++ b/DorkNet.Models/Config/RecRoomConfig.cs @@ -0,0 +1,174 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Config; + +// Required keys per RecNet.RecRoomConfig.Deserialize disassembly (RVA 0x1146FA0). +// Required-throw keys: ShareBaseUrl, LevelProgressionMaps[].Level, [].RequiredXp. +// Required-object keys (GetObjectKey, accept null): ServerMaintenance, +// AutoMicMutingConfig. +// +// NOTE: the level entry uses "RequiredXp" (lowercase 'p') per the IL string +// literal — NOT "RequiredXP". A capital P here would throw KeyNotFoundException. +public class RecRoomConfig +{ + [JsonPropertyName("MessageOfTheDay")] + public string MessageOfTheDay { get; set; } = "Welcome to the private server!"; + + [JsonPropertyName("CdnBaseUri")] + public string CdnBaseUri { get; set; } = string.Empty; + + // Required by client (string, GetKey). Used for share-link generation. + [JsonPropertyName("ShareBaseUrl")] + public string ShareBaseUrl { get; set; } = "https://rec.net/"; + + // GetObjectKey. Required key. The deserializer + // accepts null without throwing, but the C# property getter on the + // client doesn't null-check before use during boot. Send a real DTO + // with StartsInMinutes far in the future = "no upcoming maintenance". + [JsonPropertyName("ServerMaintenance")] + public ServerMaintenanceDTO? ServerMaintenance { get; set; } = new(); + + // GetObjectKey. Required by the client AND + // dereferenced by AudioManager.Initialize during PostLogin boot — + // null causes: + // NullReferenceException at RecNet.Config.get_AutoMicMutingConfig + // at AudioManager.Initialize + // which kills the boot sequence with "Error initializing Core Systems". + // Must be a real object with all 8 float fields populated. + [JsonPropertyName("AutoMicMutingConfig")] + public AutoMicMutingConfig AutoMicMutingConfig { get; set; } = new(); + + [JsonPropertyName("PhotonConfig")] + public PhotonConfig PhotonConfig { get; set; } = new(); + + [JsonPropertyName("MatchmakingParams")] + public MatchmakingParams MatchmakingParams { get; set; } = new(); + + [JsonPropertyName("LevelProgressionMaps")] + public List LevelProgressionMaps { get; set; } = LevelProgressionEntry.Default(); + + [JsonPropertyName("DailyObjectives")] + public List> DailyObjectives { get; set; } = DailyObjective.DefaultSets(); + + [JsonPropertyName("ConfigTable")] + public Dictionary ConfigTable { get; set; } = []; + + [JsonPropertyName("ServiceUrls")] + public Dictionary ServiceUrls { get; set; } = []; +} + +public class PhotonConfig +{ + [JsonPropertyName("AppId")] + public string AppId { get; set; } = string.Empty; + + [JsonPropertyName("VoiceAppId")] + public string VoiceAppId { get; set; } = string.Empty; + + [JsonPropertyName("CloudRegion")] + public string CloudRegion { get; set; } = "us"; + + [JsonPropertyName("CrcCheckEnabled")] + public bool CrcCheckEnabled { get; set; } = false; + + [JsonPropertyName("EnableServerTracingAfterDisconnect")] + public bool EnableServerTracingAfterDisconnect { get; set; } = false; +} + +public class MatchmakingParams +{ + [JsonPropertyName("MaxPlayersPerRoom")] + public int MaxPlayersPerRoom { get; set; } = 8; + + [JsonPropertyName("MinPlayersToStart")] + public int MinPlayersToStart { get; set; } = 1; +} + +public class LevelProgressionEntry +{ + [JsonPropertyName("Level")] + public int Level { get; set; } + + // Note the lowercase 'p' — the client's RecRoomConfig.Deserialize calls + // Util.GetKey("RequiredXp", ...). PascalCase XP would miss. + [JsonPropertyName("RequiredXp")] + public int RequiredXp { get; set; } + + public static List Default() + { + var entries = new List(); + for (int i = 0; i <= 30; i++) + entries.Add(new LevelProgressionEntry { Level = i, RequiredXp = i * 500 }); + return entries; + } +} + +// Inner item shape for RecRoomConfig.DailyObjectives, verified by the +// disassembled iteration body — only "type" and "score" are read. Other +// fields the client doesn't read get ignored, so we only send these two. +public class DailyObjective +{ + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("score")] + public int Score { get; set; } + + public static List> DefaultSets() + { + var sets = new List>(); + for (int day = 0; day < 7; day++) + { + sets.Add( + [ + new DailyObjective { Type = 1, Score = 50 }, + new DailyObjective { Type = 2, Score = 30 }, + new DailyObjective { Type = 3, Score = 20 }, + ]); + } + return sets; + } +} + +/// +/// RecNet.AutoMicMutingConfig — dump.cs class, Deserialize at RVA 0xFA3AE0. +/// All 8 float fields are required (Util.GetKey<float>). Defaults are +/// "feature effectively disabled" — high thresholds, no force-mute. +/// +public class AutoMicMutingConfig +{ + [JsonPropertyName("MicSpamVolumeThreshold")] + public float MicSpamVolumeThreshold { get; set; } = 1.0f; + + [JsonPropertyName("MicVolumeSampleInterval")] + public float MicVolumeSampleInterval { get; set; } = 0.1f; + + [JsonPropertyName("MicVolumeSampleRollingWindowLength")] + public float MicVolumeSampleRollingWindowLength { get; set; } = 5.0f; + + [JsonPropertyName("MicSpamSamplePercentageForWarning")] + public float MicSpamSamplePercentageForWarning { get; set; } = 1.0f; + + [JsonPropertyName("MicSpamSamplePercentageForWarningToEnd")] + public float MicSpamSamplePercentageForWarningToEnd { get; set; } = 0.5f; + + [JsonPropertyName("MicSpamSamplePercentageForForceMute")] + public float MicSpamSamplePercentageForForceMute { get; set; } = 1.0f; + + [JsonPropertyName("MicSpamSamplePercentageForForceMuteToEnd")] + public float MicSpamSamplePercentageForForceMuteToEnd { get; set; } = 0.5f; + + [JsonPropertyName("MicSpamWarningStateVolumeMultiplier")] + public float MicSpamWarningStateVolumeMultiplier { get; set; } = 1.0f; +} + +/// +/// RecNet.ServerMaintenanceDTO — dump.cs, Deserialize at RVA 0xAF5450. +/// Single required key: StartsInMinutes (int). A large positive value +/// means "maintenance window is far away" = effectively never. +/// +public class ServerMaintenanceDTO +{ + [JsonPropertyName("StartsInMinutes")] + public int StartsInMinutes { get; set; } = int.MaxValue; +} diff --git a/DorkNet.Models/DorkNet.Models.csproj b/DorkNet.Models/DorkNet.Models.csproj new file mode 100644 index 0000000..125f4c9 --- /dev/null +++ b/DorkNet.Models/DorkNet.Models.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/DorkNet.Models/DorkNet.Models.csproj.lscache b/DorkNet.Models/DorkNet.Models.csproj.lscache new file mode 100644 index 0000000..996eb5e --- /dev/null +++ b/DorkNet.Models/DorkNet.Models.csproj.lscache @@ -0,0 +1,261 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=DorkNet.Models +CommandLineArgsForDesignTimeEvaluation=-langversion:13.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=13.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=DorkNet.Models +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../DorkNet.sln +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net9.0/DorkNet.Models.dll +TargetRefPath=obj/Debug/net9.0/ref/DorkNet.Models.dll +TemporaryDependencyNodeTargetIdentifier=net9.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:9 +/define:TRACE;DEBUG;NET;NET9_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net9.0\DorkNet.Models.dll +/refout:obj\Debug\net9.0\refint\DorkNet.Models.dll +/target:library +/warnaserror- +/utf8output +/deterministic+ +/langversion:13.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +Auth/ + CachedLogin.cs + LoginRequest.cs + LoginResponse.cs + RecNetResult.cs + RefreshLoginResponse.cs +Avatar/ + AvatarData.cs + AvatarItem.cs +Config/RecRoomConfig.cs +GameSessions/GameSession.cs +Notification/PushNotification.cs +obj/Debug/net9.0/ + .NETCoreApp,Version=v9.0.AssemblyAttributes.cs + DorkNet.Models.AssemblyInfo.cs + DorkNet.Models.GlobalUsings.g.cs +Players/ + ModerationBlockDetail.cs + PlayerProfile.cs + Progression.cs + Reputation.cs + UpdateProfileRequest.cs +Relationships/Relationship.cs + +[metadataReferences] +/packs/Microsoft.NETCore.App.Ref/9.0.16/ref/net9.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/9.0.16/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll + +[analyzerConfigFiles] +/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_9_default.globalconfig +obj/Debug/net9.0/DorkNet.Models.GeneratedMSBuildEditorConfig.editorconfig diff --git a/DorkNet.Models/GameSessions/GameSession.cs b/DorkNet.Models/GameSessions/GameSession.cs new file mode 100644 index 0000000..140d280 --- /dev/null +++ b/DorkNet.Models/GameSessions/GameSession.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.GameSessions; + +public class GameSession +{ + [JsonPropertyName("GameSessionId")] + public long GameSessionId { get; set; } + + [JsonPropertyName("RegionId")] + public string RegionId { get; set; } = "us"; + + [JsonPropertyName("RoomId")] + public string RoomId { get; set; } = string.Empty; + + [JsonPropertyName("RoomName")] + public string RoomName { get; set; } = string.Empty; + + [JsonPropertyName("ActivityLevelId")] + public string ActivityLevelId { get; set; } = string.Empty; + + [JsonPropertyName("EventId")] + public long? EventId { get; set; } + + [JsonPropertyName("Private")] + public bool Private { get; set; } + + [JsonPropertyName("GameInProgress")] + public bool GameInProgress { get; set; } + + [JsonPropertyName("MaxCapacity")] + public int MaxCapacity { get; set; } = 8; + + [JsonPropertyName("IsFull")] + public bool IsFull { get; set; } + + [JsonPropertyName("PlayerCount")] + public int PlayerCount { get; set; } + + [JsonPropertyName("PhotonRoomName")] + public string PhotonRoomName { get; set; } = string.Empty; +} + +public class JoinRandomGameSessionRequest +{ + [JsonPropertyName("RoomId")] + public string? RoomId { get; set; } + + [JsonPropertyName("ActivityLevelId")] + public string? ActivityLevelId { get; set; } + + [JsonPropertyName("RegionId")] + public string RegionId { get; set; } = "us"; +} + +public class JoinGameSessionResponse +{ + [JsonPropertyName("Result")] + public JoinGameErrorCode Result { get; set; } = JoinGameErrorCode.Success; + + [JsonPropertyName("GameSession")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public GameSession? GameSession { get; set; } +} + +public enum JoinGameErrorCode +{ + Success = 0, + Full = 1, + NotFound = 2, + Banned = 3, + Error = 4, +} diff --git a/DorkNet.Models/Notification/PushNotification.cs b/DorkNet.Models/Notification/PushNotification.cs new file mode 100644 index 0000000..827362b --- /dev/null +++ b/DorkNet.Models/Notification/PushNotification.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Notification; + +public class PushNotification +{ + [JsonPropertyName("Id")] + public PushNotificationId Id { get; set; } + + [JsonPropertyName("Msg")] + public object? Msg { get; set; } +} + +/// +/// Mirrors the client's RecNet.Notifications+PushNotificationId +/// enum exactly (decompiled at +/// Cpp2IL_CS/.../RecNet/Notifications.cs:52-89). Values are +/// non-contiguous because the client allocates them in groups (1–6 +/// core; 11–15 subscriptions; 20–25 moderation; 30–31 gifts; +/// 60–62 storefronts; 70–71 consumables; 80–85 events; 90 chat; +/// 95–96 community board; 100 inventions). +/// +/// Server NotifyAsync(playerId, id) pushes raise an event with +/// this int value over the SignalR hub. If the value doesn't match +/// what the client expects for a given concept, the push is silently +/// dropped — match the client byte-for-byte. +/// +public enum PushNotificationId +{ + RelationshipChanged = 1, + MessageReceived = 2, + MessageDeleted = 3, + PresenceHeartbeatResponse = 4, + RefreshLogin = 5, + Logout = 6, + SubscriptionUpdateProfile = 11, + SubscriptionUpdatePresence = 12, + SubscriptionUpdateGameSession = 13, + SubscriptionUpdateRoom = 15, + ModerationQuitGame = 20, + ModerationUpdateRequired = 21, + ModerationKick = 22, + ModerationKickAttemptFailed = 23, + ModerationRoomBan = 24, + ServerMaintenance = 25, + GiftPackageReceived = 30, + GiftPackageReceivedImmediate = 31, + ProfileJuniorStatusUpdate = 40, + RelationshipsInvalid = 50, + StorefrontBalanceAdd = 60, + StorefrontBalanceUpdate = 61, + StorefrontBalancePurchase = 62, + ConsumableMappingAdded = 70, + ConsumableMappingRemoved = 71, + PlayerEventCreated = 80, + PlayerEventUpdated = 81, + PlayerEventDeleted = 82, + PlayerEventResponseChanged = 83, + PlayerEventResponseDeleted = 84, + PlayerEventStateChanged = 85, + ChatMessageReceived = 90, + CommunityBoardUpdate = 95, + CommunityBoardAnnouncementUpdate = 96, + InventionModerationStateChanged = 100, +} diff --git a/DorkNet.Models/Players/ModerationBlockDetail.cs b/DorkNet.Models/Players/ModerationBlockDetail.cs new file mode 100644 index 0000000..b32b197 --- /dev/null +++ b/DorkNet.Models/Players/ModerationBlockDetail.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Players; + +/// +/// RecNet.ModerationBlockDetail — dump.cs:583626, Deserialize at RVA 0x1450830. +/// +/// Verified by disassembly. Required keys (Util.GetKey, throws if missing): +/// ReportCategory (int), Duration (int), GameSessionId (long), Message (string). +/// Optional keys (Util.GetKeyOrDefault): +/// IsHostKick (bool, default false), IsBan (bool, default false), +/// PlayerIdReporter (Nullable<int>, default null). +/// +/// Returned by: +/// GET api.rec.net/api/PlayerReporting/v1/moderationBlockDetails +/// (Single object, NOT a list — the client method GetModerationBlockDetails +/// returns IPromise<ModerationBlockDetail>.) +/// +/// To indicate "user is not blocked": ReportCategory=0, Duration=0, +/// GameSessionId=0, Message="", IsBan=false, IsHostKick=false, +/// PlayerIdReporter=null. +/// +public class ModerationBlockDetail +{ + // PlayerReporting.ReportCategory enum (int) + [JsonPropertyName("ReportCategory")] + public int ReportCategory { get; set; } = 0; + + [JsonPropertyName("Duration")] + public int Duration { get; set; } = 0; + + [JsonPropertyName("GameSessionId")] + public long GameSessionId { get; set; } = 0; + + [JsonPropertyName("Message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("IsHostKick")] + public bool IsHostKick { get; set; } = false; + + [JsonPropertyName("PlayerIdReporter")] + public int? PlayerIdReporter { get; set; } = null; + + [JsonPropertyName("IsBan")] + public bool IsBan { get; set; } = false; +} diff --git a/DorkNet.Models/Players/PlayerProfile.cs b/DorkNet.Models/Players/PlayerProfile.cs new file mode 100644 index 0000000..c260296 --- /dev/null +++ b/DorkNet.Models/Players/PlayerProfile.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Players; + +public class PlayerProfile +{ + private string? profileImage; + + [JsonPropertyName("AccountId")] + public long AccountId { get; set; } + + [JsonPropertyName("Username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("DisplayName")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("ProfileImage")] + public string? ProfileImage + { + get => profileImage; + set => profileImage = value; + } + + [JsonPropertyName("ProfileImageName")] + public string? ProfileImageName + { + get => profileImage; + set => profileImage = value; + } + + [JsonPropertyName("BannerImage")] + public string? BannerImage { get; set; } + + [JsonPropertyName("IsJunior")] + public bool IsJunior { get; set; } = false; + + [JsonPropertyName("Level")] + public int Level { get; set; } = 1; + + [JsonPropertyName("XP")] + public int XP { get; set; } = 0; + + [JsonPropertyName("Reputation")] + public int Reputation { get; set; } = 0; + + [JsonPropertyName("Bio")] + public string Bio { get; set; } = string.Empty; + + [JsonPropertyName("IsVerified")] + public bool IsVerified { get; set; } = false; + + [JsonPropertyName("IsDeveloper")] + public bool IsDeveloper { get; set; } = false; + + [JsonPropertyName("IsCommunityTeam")] + public bool IsCommunityTeam { get; set; } = false; + + [JsonPropertyName("CanReceiveInvites")] + public bool CanReceiveInvites { get; set; } = true; + + [JsonPropertyName("CreatedAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Models/Players/Progression.cs b/DorkNet.Models/Players/Progression.cs new file mode 100644 index 0000000..6ff46d2 --- /dev/null +++ b/DorkNet.Models/Players/Progression.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Players; + +/// +/// RecNet.Progression — dump.cs:582273, Deserialize at RVA 0x1142BB0. +/// +/// Verified by disassembly: reads "PlayerId", "Level", "XP" via +/// Util.GetKey<int>. All three keys are required (non-default) — missing +/// any of them throws KeyNotFoundException at the client. +/// +/// Note: the JSON uses "PlayerId" not "AccountId" even though the C# property +/// is AccountId on most other types. Returned by: +/// GET api.rec.net/api/players/v1/progression/{accountId} +/// +public class Progression +{ + [JsonPropertyName("PlayerId")] + public int PlayerId { get; set; } + + [JsonPropertyName("Level")] + public int Level { get; set; } = 1; + + [JsonPropertyName("XP")] + public int XP { get; set; } = 0; +} diff --git a/DorkNet.Models/Players/Reputation.cs b/DorkNet.Models/Players/Reputation.cs new file mode 100644 index 0000000..8a2f877 --- /dev/null +++ b/DorkNet.Models/Players/Reputation.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Players; + +/// +/// RecNet.Reputation — dump.cs:584087, Deserialize at RVA 0x114A830. +/// +/// Verified by disassembly. ALL Cheer*/Subscriber* keys are required +/// (Util.GetKey<int>), missing any throws KeyNotFoundException. +/// "SelectedCheer" is the only optional one (Util.GetKeyOrDefault<Nullable<int>>). +/// +/// CRITICAL TYPO: the JSON key is "Noteriety" — the dev mistyped "Notoriety". +/// The C# property is named Notoriety in dump.cs (line 584117) but the dict +/// lookup uses "Noteriety". Server response MUST match the typo. +/// +/// Returned by: +/// GET api.rec.net/api/playerReputation/v1/{accountId} +/// +public class Reputation +{ + [JsonPropertyName("AccountId")] + public int AccountId { get; set; } + + // sic: see class XML doc — typo in the binary's Util.GetKey call. + [JsonPropertyName("Noteriety")] + public float Notoriety { get; set; } = 0f; + + [JsonPropertyName("CheerGeneral")] + public int CheerGeneral { get; set; } + + [JsonPropertyName("CheerHelpful")] + public int CheerHelpful { get; set; } + + [JsonPropertyName("CheerGreatHost")] + public int CheerGreatHost { get; set; } + + [JsonPropertyName("CheerSportsman")] + public int CheerSportsman { get; set; } + + [JsonPropertyName("CheerCreative")] + public int CheerCreative { get; set; } + + [JsonPropertyName("CheerCredit")] + public int CheerCredit { get; set; } + + [JsonPropertyName("SubscriberCount")] + public int SubscriberCount { get; set; } + + [JsonPropertyName("SubscribedCount")] + public int SubscribedCount { get; set; } + + // PlayerCheering.CheerCategory enum (int) — Nullable; null means "no + // active cheer set". This is the only optional key. + [JsonPropertyName("SelectedCheer")] + public int? SelectedCheer { get; set; } = null; +} diff --git a/DorkNet.Models/Players/UpdateProfileRequest.cs b/DorkNet.Models/Players/UpdateProfileRequest.cs new file mode 100644 index 0000000..3c4d6ae --- /dev/null +++ b/DorkNet.Models/Players/UpdateProfileRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Players; + +public class UpdateProfileRequest +{ + [JsonPropertyName("DisplayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("Bio")] + public string? Bio { get; set; } +} diff --git a/DorkNet.Models/Relationships/Relationship.cs b/DorkNet.Models/Relationships/Relationship.cs new file mode 100644 index 0000000..f7c3f90 --- /dev/null +++ b/DorkNet.Models/Relationships/Relationship.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.Models.Relationships; + +public class RelationshipData +{ + [JsonPropertyName("AccountId")] + public long AccountId { get; set; } + + [JsonPropertyName("OtherAccountId")] + public long OtherAccountId { get; set; } + + [JsonPropertyName("Status")] + public RelationshipStatus Status { get; set; } + + [JsonPropertyName("CreatedAt")] + public DateTime CreatedAt { get; set; } +} + +public enum RelationshipStatus +{ + None = 0, + Friend = 1, + PendingSent = 2, + PendingReceived = 3, + Blocked = 4, +} diff --git a/DorkNet.Server/Auth/AdminOnlyAttribute.cs b/DorkNet.Server/Auth/AdminOnlyAttribute.cs new file mode 100644 index 0000000..59b401a --- /dev/null +++ b/DorkNet.Server/Auth/AdminOnlyAttribute.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Auth; + +/// +/// Action / controller filter that 403s any request whose +/// JWT-resolved player doesn't have IsAdmin == true. Does not +/// itself authenticate — pair it with [Authorize] to ensure a +/// valid bearer is present before this runs. +/// +/// Usage: +/// +/// [Authorize] +/// [AdminOnly] +/// [Route("api/admin/v1")] +/// public class AdminController { ... } +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +public class AdminOnlyAttribute : Attribute, IAsyncAuthorizationFilter +{ + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + // AuthorizationFilterContext.HttpContext is always populated and + // exposes the same ClaimsPrincipal a controller would see, so + // there's no reason to round-trip through IHttpContextAccessor + // (which isn't even registered in this app's DI container — the + // earlier lookup crashed every admin request with + // "No service for type IHttpContextAccessor has been registered"). + if (ControllerBaseExtensions.CurrentPlayerId(context.HttpContext.User) is not long id) + { + // No JWT → defer to [Authorize]'s 401. + context.Result = new UnauthorizedResult(); + return; + } + + var db = context.HttpContext.RequestServices + .GetRequiredService(); + var isAdmin = await db.Players + .Where(p => p.Id == id) + .Select(p => p.IsAdmin) + .FirstOrDefaultAsync(); + if (!isAdmin) + context.Result = new ForbidResult(); + } +} diff --git a/DorkNet.Server/Auth/BanCheckMiddleware.cs b/DorkNet.Server/Auth/BanCheckMiddleware.cs new file mode 100644 index 0000000..46dad44 --- /dev/null +++ b/DorkNet.Server/Auth/BanCheckMiddleware.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; +using System.Security.Claims; +using System.Text.Json; + +namespace DorkNet.Server.Auth; + +/// +/// Short-circuits any authenticated request whose JWT-resolved player has +/// a non-null BannedUntil in the future. Sits AFTER +/// UseAuthentication/UseAuthorization in the pipeline so the +/// claims principal is populated before we look at it. +/// +/// Anonymous requests (no/invalid bearer) pass through unchanged — this +/// middleware never short-circuits them. Whether they're allowed is the +/// per-endpoint [Authorize] decision, not ours. +/// +/// Lookup is one query per request against the Players table. Cached for +/// the request lifetime via the scoped DbContext, so repeated bearer +/// checks within one request hit the EF first-level cache. +/// +public class BanCheckMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext ctx, DorkNetDbContext db) + { + // Cheap early-out for anonymous traffic — no DB hit. + var sub = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!long.TryParse(sub, out var playerId)) + { + await next(ctx); + return; + } + + // Project just the BannedUntil column — no need to materialise the + // full player entity for every request. + var bannedUntil = await db.Players + .Where(p => p.Id == playerId) + .Select(p => p.BannedUntil) + .FirstOrDefaultAsync(); + + if (bannedUntil.HasValue && bannedUntil.Value > DateTime.UtcNow) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + ctx.Response.ContentType = "application/json"; + await ctx.Response.WriteAsync(JsonSerializer.Serialize(new + { + Error = "banned", + Until = bannedUntil.Value.ToString("o"), + })); + return; + } + + await next(ctx); + } +} diff --git a/DorkNet.Server/Auth/ControllerBaseExtensions.cs b/DorkNet.Server/Auth/ControllerBaseExtensions.cs new file mode 100644 index 0000000..c405d7e --- /dev/null +++ b/DorkNet.Server/Auth/ControllerBaseExtensions.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace DorkNet.Server.Auth; + +/// +/// Single canonical place for "who is the bearer-resolved player on this +/// request." Replaces the per-controller copy-paste of: +/// +/// private long CurrentPlayerId => +/// long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); +/// +/// which historically existed in 10 controllers and silently NPE-bombed +/// the action when the bearer was missing instead of returning 401. +/// +public static class ControllerBaseExtensions +{ + /// + /// Returns the bearer-resolved player id, or null when the caller is + /// anonymous / the bearer is malformed. Use on optional-auth endpoints. + /// + public static long? CurrentPlayerId(this ControllerBase controller) => + CurrentPlayerId(controller.User); + + /// Overload for non-controller callers (filters, + /// middleware) that hold a directly. + public static long? CurrentPlayerId(ClaimsPrincipal user) + { + var claim = user.FindFirstValue(ClaimTypes.NameIdentifier); + return long.TryParse(claim, out var id) ? id : null; + } + + /// + /// Returns the bearer-resolved player id; throws + /// when missing. Use on + /// [Authorize]-decorated endpoints where a missing id is a + /// programming error (the framework should have already 401'd). + /// + public static long RequireCurrentPlayerId(this ControllerBase controller) => + controller.CurrentPlayerId() + ?? throw new UnauthorizedAccessException( + "Bearer token claim 'NameIdentifier' is missing or non-numeric. " + + "If this fired on an [Authorize] endpoint, JWT validation upstream " + + "is misconfigured."); +} diff --git a/DorkNet.Server/Auth/IpBanCheckMiddleware.cs b/DorkNet.Server/Auth/IpBanCheckMiddleware.cs new file mode 100644 index 0000000..9cd648f --- /dev/null +++ b/DorkNet.Server/Auth/IpBanCheckMiddleware.cs @@ -0,0 +1,92 @@ +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; +using System.Net; +using System.Net.Sockets; + +namespace DorkNet.Server.Auth; + +/// +/// Short-circuits any incoming request whose remote IP falls under an +/// active row +/// with 403. Sits BEFORE authentication so banned IPs can't spend a +/// round-trip negotiating a JWT — the connection terminates as early +/// as possible. +/// +/// Lookup is one query against IpBans per request. The table is small +/// (a few rows in practice) and the simple "Cidr matches?" filter is +/// done in C# rather than SQL because CIDR maths in SQLite is awkward. +/// +public class IpBanCheckMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext ctx, DorkNetDbContext db) + { + var remote = ctx.Connection.RemoteIpAddress; + if (remote is null) + { + await next(ctx); + return; + } + + // Materialise the active-ban list once per request. Could be + // cached for ~30s with a hosted service if it ever becomes a + // hot path, but right now this is unmeasurable. + var bans = await db.IpBans + .Where(b => b.Until == null || b.Until > DateTime.UtcNow) + .Select(b => b.Cidr) + .ToListAsync(); + + foreach (var cidr in bans) + { + if (CidrMatches(cidr, remote)) + { + ctx.Response.StatusCode = StatusCodes.Status403Forbidden; + await ctx.Response.WriteAsync("ip_banned"); + return; + } + } + + await next(ctx); + } + + /// Match a single IP against a CIDR-or-bare-IP string. + /// Bare IPs match exactly; CIDRs match if the prefix bits agree. + /// Falls back to false on any parse error. + private static bool CidrMatches(string cidr, IPAddress remote) + { + try + { + var slash = cidr.IndexOf('/'); + if (slash < 0) + { + return IPAddress.TryParse(cidr, out var single) && + single.Equals(remote); + } + + var addrPart = cidr[..slash]; + var prefixPart = cidr[(slash + 1)..]; + if (!IPAddress.TryParse(addrPart, out var net) || + !int.TryParse(prefixPart, out var prefix)) + return false; + + // Only IPv4 networks for now — IPv6 CIDR matching is + // straightforward but we'd need to mask 128-bit values + // and the user's setup is single-host IPv4 anyway. + if (net.AddressFamily != AddressFamily.InterNetwork || + remote.AddressFamily != AddressFamily.InterNetwork) + return false; + + var netBytes = net.GetAddressBytes(); + var remoteBytes = remote.GetAddressBytes(); + int fullBytes = prefix / 8; + int extraBits = prefix % 8; + + for (int i = 0; i < fullBytes; i++) + if (netBytes[i] != remoteBytes[i]) return false; + if (extraBits == 0) return true; + + int mask = 0xFF << (8 - extraBits) & 0xFF; + return (netBytes[fullBytes] & mask) == (remoteBytes[fullBytes] & mask); + } + catch { return false; } + } +} diff --git a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs new file mode 100644 index 0000000..7bf38b4 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs @@ -0,0 +1,267 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Account; + +/// +/// api.rec.net/api/account/* — same endpoints as accounts.rec.net but routed +/// through the API service. The Accounts class in dump.cs doesn't expose its +/// base URL constant so the game may use either host; we cover both. +/// +[ApiController] +public class AccountApiController( + PlayerService playerService, + AuthService authService, + DorkNetDbContext db, + NotificationService notifications, + ServerSettingsService settings, + ILogger logger) : ControllerBase +{ + [HttpPost("api/account/v1/create")] + [HttpPost("api/account/v1")] + [HttpPost("api/account/v2/create")] + [HttpPost("api/createaccount")] + public async Task CreateAccount( + [FromForm] int platform, + [FromForm] string? platformId, + [FromForm] string? deviceId, + [FromForm] string? Username, + [FromForm] string? DisplayName) + { + if (await settings.AreSignupsDisabledAsync()) + { + logger.LogInformation( + "[api-account] api/account/v1/create refused — signups disabled (device={Device} platform={Platform})", + deviceId, platform); + return Ok(new CreateAccountResponse + { + Success = false, + Error = "Account creation is currently disabled by the server admin.", + }); + } + + var player = await playerService.GetOrCreateByDeviceAsync( + deviceId: deviceId, + platform: platform, + platformId: platformId, + displayName: DisplayName ?? Username); + + return Ok(new CreateAccountResponse + { + 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, + }, + }); + } + + [HttpGet("api/account/v1/me")] + [HttpGet("api/account/v2/me")] + public async Task GetMe() + { + 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, + }); + } + + /// + /// Resolves the player tied to the bearer token on the request. Returns + /// null when no/invalid bearer is supplied — caller decides whether + /// that's a 401 or a softer fallback. Anonymous-account creation was + /// previously the fallback path; deleted in Phase 1c because every + /// caller has gone through `/account/create` (deviceId-bound) before + /// reaching us. + /// + private async Task GetCurrentPlayerAsync() + { + var auth = Request.Headers.Authorization.ToString(); + if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; + var token = auth["Bearer ".Length..].Trim(); + var id = authService.ValidateToken(token); + if (id is not long playerId) return null; + return await playerService.GetByIdAsync(playerId); + } + + // Modification endpoints — POSTs land on api.* host with form body + // carrying the new value. AuthAccountController on auth.* handles + // the same shape with real persistence; we mirror that here so + // either host works. + + [HttpPost("api/account/v1/displayname")] + public async Task SetDisplayName([FromForm(Name = "displayName")] string? displayName, + [FromForm(Name = "value")] string? value) + { + var p = await GetCurrentPlayerAsync(); + if (p is null) return Unauthorized(); + var name = (displayName ?? value ?? string.Empty).Trim(); + if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" }); + await playerService.UpdateDisplayNameAsync(p.Id, name); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + [HttpPost("api/account/v1/bio")] + public async Task SetBio([FromForm(Name = "bio")] string? bio, + [FromForm(Name = "value")] string? value) + { + var p = await GetCurrentPlayerAsync(); + if (p is null) return Unauthorized(); + await playerService.UpdateBioAsync(p.Id, (bio ?? value ?? string.Empty)); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + [HttpPost("api/account/v1/username")] + public async Task SetUsername([FromForm(Name = "username")] string? username, + [FromForm(Name = "value")] string? value) + { + var p = await GetCurrentPlayerAsync(); + if (p is null) return Unauthorized(); + var newName = (username ?? value ?? string.Empty).Trim(); + if (newName.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" }); + var taken = await db.Players.AnyAsync(x => x.Username == newName && x.Id != p.Id); + if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" }); + p.Username = newName; + await db.SaveChangesAsync(); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + [HttpPost("api/account/v1/birthday")] + public async Task SetBirthday([FromForm(Name = "birthday")] string? birthday, + [FromForm(Name = "value")] string? value) + { + var p = await GetCurrentPlayerAsync(); + if (p is null) return Unauthorized(); + var raw = birthday ?? value ?? string.Empty; + if (DateTime.TryParse(raw, out var parsed)) + { + p.Birthday = DateTime.SpecifyKind(parsed, DateTimeKind.Utc); + await db.SaveChangesAsync(); + } + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + [HttpPost("api/account/v1/email")] + public async Task SetEmail([FromForm(Name = "email")] string? email, + [FromForm(Name = "value")] string? value) + { + var p = await GetCurrentPlayerAsync(); + if (p is null) return Unauthorized(); + var addr = (email ?? value ?? string.Empty).Trim(); + if (addr.Length > 0) p.Email = addr; + await db.SaveChangesAsync(); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + [HttpPost("api/account/v1/phone")] + [HttpPost("api/account/v1/confirmphone")] + public async Task SetPhone([FromForm(Name = "phone")] string? phone, + [FromForm(Name = "value")] string? value) + { + var p = await GetCurrentPlayerAsync(); + if (p is null) return Unauthorized(); + var num = (phone ?? value ?? string.Empty).Trim(); + if (num.Length > 0) p.Phone = num; + await db.SaveChangesAsync(); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + [HttpPost("api/account/v1/profileimage")] + [HttpPost("api/account/me/profileimage")] + [HttpPost("account/me/profileimage")] + [HttpPost("profileimage")] + [HttpPut("api/account/v1/profileimage")] + [HttpPut("api/account/me/profileimage")] + [HttpPut("account/me/profileimage")] + [HttpPut("profileimage")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task SetProfileImage( + [FromForm(Name = "imageName")] string? imageName, + [FromForm(Name = "fileName")] string? fileName, + [FromForm(Name = "image")] string? imageAlt) + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + + var name = imageName ?? fileName ?? imageAlt + ?? Request.Query["imageName"].ToString() + ?? Request.Query["fileName"].ToString(); + player.ProfileImageName = string.IsNullOrWhiteSpace(name) ? null : name.Trim(); + await db.SaveChangesAsync(); + logger.LogInformation( + "[api-account] profile image set: player={PlayerId} -> {Name}", + player.Id, + player.ProfileImageName ?? ""); + await NotifyAccountImageChangedAsync(player); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + private Task NotifyAccountImageChangedAsync(PlayerEntity player) + { + var account = new RecNetAccount + { + AccountId = (int)player.Id, + RawUsername = player.Username, + Username = player.Username, + DisplayName = player.DisplayName ?? player.Username, + ProfileImage = player.ProfileImageName ?? string.Empty, + TreatAsJunior = player.IsJunior, + HasBirthday = true, + Platforms = 1, + }; + 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, + Email = player.Email ?? string.Empty, + Phone = player.Phone ?? string.Empty, + Birthday = player.Birthday, + JuniorState = player.IsJunior ? 1 : 0, + ParentAccountId = null, + }; + return Task.WhenAll( + notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account), + notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount)); + } + + [HttpGet("api/account/v1/namegen")] + [HttpGet("api/account/v1/namegeneration")] + public IActionResult NameGen() => Ok(new + { + Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" }, + Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" }, + }); +} diff --git a/DorkNet.Server/Controllers/API/Activities/ActivitiesController.cs b/DorkNet.Server/Controllers/API/Activities/ActivitiesController.cs new file mode 100644 index 0000000..a82877c --- /dev/null +++ b/DorkNet.Server/Controllers/API/Activities/ActivitiesController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DorkNet.Server.Controllers.API.Activities; + +/// +/// api.{rec.net,localhost}/api/activities/* — per-activity content +/// (charades words, party-game prompts, etc.). The 2020 watch +/// fetches these as a static list at room-join time; we ship an +/// empty list so the activity's bundled defaults kick in. +/// +[ApiController] +public class ActivitiesController : ControllerBase +{ + [HttpGet("api/activities/charades/v1/words")] + public IActionResult CharadesWords() => Ok(Array.Empty()); +} diff --git a/DorkNet.Server/Controllers/API/Announcements/AnnouncementsController.cs b/DorkNet.Server/Controllers/API/Announcements/AnnouncementsController.cs new file mode 100644 index 0000000..37a9dcd --- /dev/null +++ b/DorkNet.Server/Controllers/API/Announcements/AnnouncementsController.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Announcements; + +/// +/// api.{rec.net,localhost}/api/announcement/v{1,2}/get — fetches the +/// current server announcement (shown on the watch's home tab). The +/// payload is sourced from so the +/// admin SPA's "edit announcement" form is the single place to +/// update what players see. +/// +/// Wire shape per RecNet/AnnouncementDTO.cs — AnnouncementId, +/// AnnouncementType, LinkType, Platform are all required via +/// Util.GetKey (throws on miss); Title/Body/ImageName/LinkName/ +/// LinkUri/CreatedAt are GetKeyOrDefault. +/// +[ApiController] +public class AnnouncementsController(CommunityBoardService communityBoard) : ControllerBase +{ + [HttpGet("api/announcement/v1/get")] + [HttpGet("api/announcement/v2/get")] + public async Task Current() + { + var board = await communityBoard.GetAsync(); + var ann = board.CurrentAnnouncement; + if (ann is null) return Ok(Array.Empty()); + return Ok(new[] + { + new + { + AnnouncementId = 1L, + AnnouncementType = 0, + Title = "Server Announcement", + Body = ann.Message ?? string.Empty, + ImageName = "", + LinkType = 0, + LinkName = "", + LinkUri = ann.MoreInfoUrl ?? string.Empty, + Platform = 0, + CreatedAt = DateTime.UtcNow, + } + }); + } +} diff --git a/DorkNet.Server/Controllers/API/Auth/PhotonAuthController.cs b/DorkNet.Server/Controllers/API/Auth/PhotonAuthController.cs new file mode 100644 index 0000000..a309050 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Auth/PhotonAuthController.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Auth; + +/// +/// Photon custom-auth webhook (api.rec.net). +/// +/// Configure in Photon dashboard: +/// Authentication → Custom → URL = https://api.rec.net/api/auth/v1/photon +/// Query params sent by client: token=<our_jwt> +/// +/// Photon ResultCode: 1 = success, 2 = fail, 3 = param error +/// +[ApiController] +public class PhotonAuthController(AuthService authService, PlayerService playerService) : ControllerBase +{ + [HttpGet("api/auth/v1/photon")] + [HttpPost("api/auth/v1/photon")] + public async Task Authenticate([FromQuery] string? token, [FromQuery] string? Token) + { + var jwt = token ?? Token; + + if (string.IsNullOrWhiteSpace(jwt)) + return Ok(new { ResultCode = 3, Message = "Missing token parameter" }); + + var playerId = authService.ValidateToken(jwt); + if (playerId is null) + return Ok(new { ResultCode = 2, Message = "Invalid or expired token" }); + + var player = await playerService.GetByIdAsync(playerId.Value); + if (player is null) + return Ok(new { ResultCode = 2, Message = "Player not found" }); + + return Ok(new + { + ResultCode = 1, + Message = "ok", + UserId = player.Id.ToString(), + Nickname = player.DisplayName ?? player.Username, + }); + } +} diff --git a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs new file mode 100644 index 0000000..d7b212a --- /dev/null +++ b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs @@ -0,0 +1,281 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Avatar.V2; + +/// +/// api.rec.net/api/avatar/v2/gifts — wardrobe gift inbox. Mirrors +/// the client's RecNet.Avatars.DowloadGiftPackages + +/// LocalRequestGiftPackage + LocalConsumeGiftPackage +/// flow. Wire DTO matches RecNet.Avatars+GiftPackage +/// (Cpp2IL_CS/.../RecNet/Avatars.cs:192-555) field-for-field +/// so the watch's deserialiser doesn't crash. +/// +/// Server semantics: +/// - GET v2/gifts returns unconsumed gifts for caller. +/// - POST v2/gifts/generate rolls a random unlocked-avatar +/// item and stamps a for caller — +/// used by drop tiles and "free daily" prompts. +/// - POST v2/gifts/consume/{id} marks consumed and appends +/// the +/// to the player's so it +/// shows up in their backpack on next load. +/// +[ApiController] +[Authorize] +public class AvatarGiftsController( + DorkNetDbContext db, + NotificationService notifications, + LevelService level) : ControllerBase +{ + [HttpGet("api/avatar/v2/gifts")] + [HttpGet("api/avatar/v3/gifts")] + [HttpGet("api/avatar/v4/gifts")] + public async Task List() + { + var pid = this.RequireCurrentPlayerId(); + var rows = await db.GiftPackages + .Where(g => g.RecipientPlayerId == pid && !g.Consumed) + .OrderByDescending(g => g.CreatedAt) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + public sealed record GenerateGiftRequest(int? GiftContext); + + /// POST api/avatar/v2/gifts/generate — server picks + /// a random unlocked-avatar item and queues a gift package. The + /// 2020 client posts to this when the user clicks a free-gift + /// tile or hits a context the client maps to GiftContext. + [HttpPost("api/avatar/v2/gifts/generate")] + public async Task Generate([FromBody] GenerateGiftRequest? req) + { + var pid = this.RequireCurrentPlayerId(); + // Pick a random wardrobe StoreItem (Slug starts with + // "wardrobe-" — that's the convention StoreService uses for + // catalogue rows derived from outfits_assets bundles). + var pool = await db.StoreItems + .Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-")) + .Select(s => new { s.Slug, s.DisplayName }) + .ToListAsync(); + if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue"); + + var rng = new Random(); + var pick = pool[rng.Next(pool.Count)]; + var guid = pick.Slug["wardrobe-".Length..]; + + // Bare GUIDs crash AvatarItem.FromRecNetString — append `,,,` + // so the watch's parse-and-iterate doesn't blow up in + // GiftManager.DequeueGift. See AvatarItemsController.cs:31-40 + // for the canonical wire-format note. + var gift = new GiftPackageEntity + { + RecipientPlayerId = pid, + FromPlayerId = null, + AvatarItemType = 0, // Outfit + AvatarItemDescOrHairDyeDesc = guid + ",,,", + CurrencyType = 0, // Invalid (no currency in this gift) + GiftContext = req?.GiftContext ?? 0, + GiftRarity = RollRarity(rng), + Message = $"Enjoy your new {pick.DisplayName}!", + Platform = -1, + PackageVariant = "Standard", + PackageMaterial = string.Empty, + IsValid = true, + SupportsCurrentPlatform = true, + }; + db.GiftPackages.Add(gift); + await db.SaveChangesAsync(); + + // Push so the watch shows the new-gift toast. + await notifications.NotifyAsync(pid, + PushNotificationId.GiftPackageReceived, ToWire(gift)); + return Ok(ToWire(gift)); + } + + [HttpPost("api/avatar/v2/gifts/consume/{id:long}")] + public Task ConsumeViaPath(long id) => ConsumeImpl(id); + + /// POST /api/avatar/v2/gifts/consume/ (note trailing + /// slash, no id in path). The watch's LocalConsumeGiftPackage + /// (verified at Cpp2IL_ISIL/.../RecNet/Avatars_NestedType__LocalConsumeGiftPackage_d__19.txt:276-301) + /// posts to this exact URL with a form-urlencoded body containing + /// Id and UnlockedLevel fields. The previous + /// implementation took [FromBody] ConsumeGiftRequest which + /// only accepts application/json — the watch's form-encoded + /// body got 415 Unsupported Media Type + /// (output_log.txt:'Failed to consume gift … HTTP Error 415'), + /// which left the gift unconsumed → the avatar item never landed + /// in the player's inventory → mirror-unequip rendered a missing + /// item and crashed. + /// + /// Accept both JSON and form-urlencoded by reading the raw body + /// and pulling Id from whichever container we got. + [HttpPost("api/avatar/v2/gifts/consume")] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data", "text/plain")] + public async Task ConsumeViaBody() + { + long id = 0; + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + var raw = form["Id"].ToString(); + if (string.IsNullOrWhiteSpace(raw)) raw = form["id"].ToString(); + long.TryParse(raw, out id); + } + else + { + using var reader = new System.IO.StreamReader(Request.Body); + var body = await reader.ReadToEndAsync(); + if (!string.IsNullOrWhiteSpace(body)) + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("Id", out var v) || + doc.RootElement.TryGetProperty("id", out v)) + { + if (v.ValueKind == System.Text.Json.JsonValueKind.Number) + v.TryGetInt64(out id); + else if (v.ValueKind == System.Text.Json.JsonValueKind.String) + long.TryParse(v.GetString(), out id); + } + } + catch (System.Text.Json.JsonException) { /* fall through; id stays 0 */ } + } + } + if (id == 0) + { + var raw = Request.Query["Id"].ToString(); + if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["id"].ToString(); + long.TryParse(raw, out id); + } + if (id == 0) return BadRequest("missing Id"); + return await ConsumeImpl(id); + } + + private async Task ConsumeImpl(long id) + { + var pid = this.RequireCurrentPlayerId(); + var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id); + if (gift is null) return NotFound(); + if (gift.RecipientPlayerId != pid) return Forbid(); + if (gift.Consumed) return Ok(ToWire(gift)); // idempotent + + // Append avatar item to player's inventory list (JSON list of + // GUID strings). Defensive parse — corrupted JSON resets to + // empty list rather than 500. + if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc)) + { + var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid); + if (avatar is null) + { + avatar = new AvatarEntity { PlayerId = pid }; + db.Avatars.Add(avatar); + } + try + { + var list = System.Text.Json.JsonSerializer + .Deserialize>(avatar.InventoryJson) ?? new(); + if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc)) + { + list.Add(gift.AvatarItemDescOrHairDyeDesc); + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list); + avatar.UpdatedAt = DateTime.UtcNow; + } + } + catch + { + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize( + new[] { gift.AvatarItemDescOrHairDyeDesc }); + avatar.UpdatedAt = DateTime.UtcNow; + } + } + + // Apply currency / XP / level rewards bundled with the gift. + // The previous implementation only handled the avatar item path, + // so token gifts from the admin SPA marked themselves consumed + // but never moved the player's wallet — the player tapped the + // gift, the box played its animation, and the balance stayed + // unchanged. CurrencyType is the canonical RecNet enum + // (1 = Tokens, 2 = Coins; 0 means "no currency in this gift"). + if (gift.CurrencyType > 0 && gift.Currency > 0) + { + await level.GrantCurrencyAsync(pid, gift.CurrencyType, gift.Currency, + $"gift_consumed:{gift.Id}"); + } + if (gift.Xp > 0) + { + await level.AwardXpAsync(pid, gift.Xp, $"gift_consumed:{gift.Id}"); + } + // Direct level grants bypass XP curves — same pattern + // /api/admin/v1/players/{id}/levelup uses. We don't surface + // that as a separate helper; bump the column inline. + if (gift.Level > 0) + { + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid); + if (player is not null) player.Level += gift.Level; + } + + gift.Consumed = true; + gift.ConsumedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + // Profile-update push so the watch's wallet readout refreshes + // without waiting for the next /balance poll. Mirrors what + // /api/admin/v1/players/{id}/gift does after the inbox push so + // the toast number animates up the moment the box closes. + if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0) + { + await notifications.NotifyAsync(pid, + PushNotificationId.SubscriptionUpdateProfile, + new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level }); + } + + return Ok(ToWire(gift)); + } + + /// Weighted rarity roll — Common 65, Uncommon 25, + /// Rare 8, Epic 1.8, Legendary 0.2 percent. + private static int RollRarity(Random rng) => rng.NextDouble() switch + { + < 0.65 => 0, // Common + < 0.90 => 10, // Uncommon + < 0.98 => 20, // Rare + < 0.998 => 30, // Epic + _ => 50, // Legendary + }; + + private static object ToWire(GiftPackageEntity g) => new + { + g.Id, + g.FromPlayerId, + g.ConsumableItemDesc, + g.AvatarItemType, + AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc, + g.AvatarItemDescOrHairDyeDesc, + g.EquipmentPrefabName, + g.EquipmentModificationGuid, + g.CurrencyType, + g.Currency, + g.Xp, + g.Level, + g.GiftContext, + g.GiftRarity, + g.Message, + g.Platform, + g.PackageMaterial, + g.PackageVariant, + g.Consumed, + g.IsValid, + g.ErrorMessage, + g.SupportsCurrentPlatform, + g.IsGifted, + }; +} diff --git a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarV2Controller.cs b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarV2Controller.cs new file mode 100644 index 0000000..4da8db9 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarV2Controller.cs @@ -0,0 +1,193 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Serialization; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.Avatar.V2; + +/// +/// api.rec.net/api/avatar/v2 — older Avatar settings endpoint used by +/// the 2020 client (the V3 endpoint is for newer builds and uses a +/// different shape entirely). +/// +/// Wire shape verified by disassembling RecNet.Avatar.Deserialize at +/// RVA 0xFA3C30. Required keys (Util.GetKey<string>): OutfitSelections, +/// HairColor, SkinColor, FaceFeatures. Empty strings satisfy the +/// deserializer; the client interprets them as "use defaults" and falls +/// back to its built-in starter avatar. +/// +/// This implementation persists each set/get to +/// instead of stubbing — round-trip across sessions, multi-user-safe +/// (one row per player keyed by PlayerId). +/// +[ApiController] +[Authorize] +public class AvatarV2Controller(DorkNetDbContext db, ILogger logger) : ControllerBase +{ + [HttpGet("api/avatar/v2")] + public async Task> GetAvatar() + { + var pid = this.RequireCurrentPlayerId(); + var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid); + return Ok(ToDto(a)); + } + + /// + /// POST /api/avatar/v2/set — replace the player's avatar settings. + /// + /// CRITICAL: do NOT use [FromBody] AvatarV2Dto here. The + /// watch's RecNet.Avatar.Serialize() emits each of + /// OutfitSelections / HairColor / SkinColor / FaceFeatures as + /// dictionary values whose runtime types are determined by the + /// IRecNetObject serializer it uses — FaceFeatures in particular + /// sometimes ships as a nested JSON OBJECT instead of a JSON + /// string, and the watch may also omit fields entirely when + /// they're empty. + /// + /// System.Text.Json's strict type binder responds to either of those + /// with a 400 (model state invalid → ApiController auto-400) and + /// the watch then retries every frame, accumulating "Failed to + /// upload Rec Room player avatar changes: HTTP Error 400" errors + /// (output_log.txt:852+, 1000+ repeats) until the player object + /// gets destroyed. + /// + /// Parse the body manually as JsonElement so we accept any shape + /// the watch sends: string-or-object for FaceFeatures (re-encode + /// the object as a string before persisting), missing fields (keep + /// existing values), null values (treat as empty). + /// + [HttpPost("api/avatar/v2")] + [HttpPost("api/avatar/v2/set")] + [Consumes("application/json", "application/x-www-form-urlencoded", "text/plain")] + public async Task> SetAvatar() + { + var pid = this.RequireCurrentPlayerId(); + + // Read the raw body so we control parsing — bypasses the + // model binder's strict type matching that was causing 400s. + string raw; + using (var reader = new System.IO.StreamReader(Request.Body)) + raw = await reader.ReadToEndAsync(); + + string? outfitSelections = null; + string? hairColor = null; + string? skinColor = null; + string? faceFeatures = null; + + if (!string.IsNullOrWhiteSpace(raw)) + { + try + { + using var doc = JsonDocument.Parse(raw); + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object) + { + outfitSelections = ReadStringField(root, "OutfitSelections"); + hairColor = ReadStringField(root, "HairColor"); + skinColor = ReadStringField(root, "SkinColor"); + faceFeatures = ReadStringField(root, "FaceFeatures"); + } + } + catch (JsonException ex) + { + // Fall through with all-null updates if the body isn't + // JSON. Log so we can see what the watch actually sends. + logger.LogWarning( + "[avatar-set] could not parse body as JSON ({Msg}); raw={Raw}", + ex.Message, raw.Length > 500 ? raw.Substring(0, 500) + "…" : raw); + } + } + + var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid); + if (a is null) + { + a = new AvatarEntity { PlayerId = pid }; + db.Avatars.Add(a); + } + // Only overwrite fields the watch actually sent; missing keys + // keep the previously-stored value so a partial PATCH-style + // update doesn't accidentally wipe HairColor/SkinColor/etc. + if (outfitSelections is not null) a.OutfitSelections = outfitSelections; + if (hairColor is not null) a.HairColor = hairColor; + if (skinColor is not null) a.SkinColor = skinColor; + if (faceFeatures is not null) a.FaceFeatures = faceFeatures; + a.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToDto(a)); + } + + /// Read a field that should be a string but may arrive + /// as null, a JSON object/array (re-encoded back to a JSON + /// string), or be missing entirely (return null → caller keeps + /// the existing DB value). + private static string? ReadStringField(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out var v)) return null; + return v.ValueKind switch + { + JsonValueKind.String => v.GetString() ?? string.Empty, + JsonValueKind.Null => string.Empty, + JsonValueKind.Object => v.GetRawText(), + JsonValueKind.Array => v.GetRawText(), + JsonValueKind.Number => v.GetRawText(), + JsonValueKind.True or JsonValueKind.False => v.GetRawText(), + _ => null, + }; + } + + /// Public lookup of another player's equipped avatar — the + /// client uses this to render remote players' avatars in shared + /// rooms. No auth requirement on read so anonymous calls in the + /// boot path don't 401. + [HttpGet("api/avatar/v2/equipped/{playerId:long}")] + [HttpGet("api/avatar/v3/equipped/{playerId:long}")] + [HttpGet("api/avatar/v4/equipped/{playerId:long}")] + [AllowAnonymous] + public async Task> GetEquippedFor(long playerId) + { + var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId); + return Ok(ToDto(a)); + } + + /// Bare api/avatar/v{2,3}/equipped returns the + /// caller's own equipped avatar. Without the bare path the watch's + /// "what am I wearing" query falls through to the catch-all and + /// gets []. + [HttpGet("api/avatar/v2/equipped")] + [HttpGet("api/avatar/v3/equipped")] + [HttpGet("api/avatar/v4/equipped")] + public async Task> GetEquippedSelf() + { + var pid = this.RequireCurrentPlayerId(); + var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid); + return Ok(ToDto(a)); + } + + private static AvatarV2Dto ToDto(AvatarEntity? a) => new() + { + OutfitSelections = a?.OutfitSelections ?? string.Empty, + FaceFeatures = a?.FaceFeatures ?? string.Empty, + HairColor = a?.HairColor ?? string.Empty, + SkinColor = a?.SkinColor ?? string.Empty, + }; +} + +public class AvatarV2Dto +{ + [JsonPropertyName("OutfitSelections")] + public string OutfitSelections { get; set; } = string.Empty; + + [JsonPropertyName("HairColor")] + public string HairColor { get; set; } = string.Empty; + + [JsonPropertyName("SkinColor")] + public string SkinColor { get; set; } = string.Empty; + + [JsonPropertyName("FaceFeatures")] + public string FaceFeatures { get; set; } = string.Empty; +} diff --git a/DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs b/DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs new file mode 100644 index 0000000..7a81f7f --- /dev/null +++ b/DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs @@ -0,0 +1,103 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using System.Text.Json.Serialization; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.Avatar.V3; + +/// +/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire +/// type RecNet.SavedOutfit (per agent ISIL extraction): +/// Slot(int), PreviewImageName(string), OutfitSelections(string), +/// FaceFeatures(string), SkinColor(string), HairColor(string). +/// +/// State persists in as +/// a JSON list of records keyed by +/// Slot. Caps at 20 entries per player to keep the column under +/// the SQLite text-blob comfort zone. +/// +[ApiController] +[Authorize] +public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase +{ + private const int MaxSlots = 20; + + [HttpGet("api/avatar/v2/saved")] + [HttpGet("api/avatar/v3/saved")] + [HttpGet("api/avatar/v4/saved")] + public async Task List() + { + var pid = this.RequireCurrentPlayerId(); + var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid); + var list = ParseList(avatar?.SavedOutfitsJson); + return Ok(list); + } + + /// POST v3/saved/set — upsert one outfit by Slot. + /// Body is the SavedOutfit shape directly. + [HttpPost("api/avatar/v3/saved/set")] + [HttpPost("api/avatar/v2/saved/set")] + public async Task Set([FromBody] SavedOutfitDto body) + { + 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) + { + avatar = new AvatarEntity { PlayerId = pid }; + db.Avatars.Add(avatar); + } + var list = ParseList(avatar.SavedOutfitsJson); + var idx = list.FindIndex(o => o.Slot == body.Slot); + if (idx >= 0) list[idx] = body; + else + { + if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player"); + list.Add(body); + } + avatar.SavedOutfitsJson = JsonSerializer.Serialize(list); + avatar.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(body); + } + + [HttpDelete("api/avatar/v3/saved/{slot:int}")] + [HttpDelete("api/avatar/v2/saved/{slot:int}")] + public async Task Delete(int slot) + { + var pid = this.RequireCurrentPlayerId(); + var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid); + if (avatar is null) return NotFound(); + var list = ParseList(avatar.SavedOutfitsJson); + var removed = list.RemoveAll(o => o.Slot == slot); + if (removed == 0) return NotFound(); + avatar.SavedOutfitsJson = JsonSerializer.Serialize(list); + avatar.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(); + } + + private static List ParseList(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(); + try + { + return JsonSerializer.Deserialize>(json) ?? new(); + } + catch { return new(); } + } +} + +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; + [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; +} diff --git a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs new file mode 100644 index 0000000..91b6a5d --- /dev/null +++ b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs @@ -0,0 +1,411 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using System.Text.Json; +using System.Text.Json.Serialization; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Avatar.V4; + +/// +/// api.rec.net/api/avatar/v{2,3,4}/items — list of avatar items the +/// caller has UNLOCKED. The watch's +/// RecNet.Avatars+<DowloadUnlockedAvatarItems> coroutine +/// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149 +/// hits "{api/avatar/}v4/items") parses the result as +/// List<UnlockedAvatarItem>; the wardrobe UI then +/// renders a "buy" button for any item NOT in this list and an +/// "equip" affordance for items that are. +/// +/// Wire shape per +/// Cpp2IL_ISIL/.../Avatars_NestedType_UnlockedAvatarItem.txt:186-212: +/// "AvatarItemType" (int — Outfit=0 / HairDye=1, NOT slot-based), +/// "AvatarItemDesc" (string — the item GUID with three empty +/// customizations appended, see below), +/// "PlatformMask" (int), "FriendlyName" (string), +/// "Tooltip" (string), "Rarity" (int). +/// +/// IMPORTANT — AvatarItemDesc format: the client passes this +/// string to AvatarItem.FromRecNetString(string) +/// (Cpp2IL_ISIL/.../RecRoom/Avatar/Data/Runtime/AvatarItem.txt:81-257). +/// The desc is String.Split(',') into guid + customization[0..3] +/// — the inner overload then indexes customization[0], +/// customization[1], customization[2] and throws +/// ArgumentException at ArraySegment.get_Current if the +/// segment is shorter than 3. Every desc we ship MUST therefore have +/// at least 4 comma-separated parts, so we suffix bare GUIDs with +/// ",,," (three empty customizations). +/// +/// CATALOG SOURCE — data/avatar_item_lookup.json. Generated by +/// the manual/CI-only BepInEx patch under +/// tools/AvatarCatalogDumpPatch. That patch reads the watch's bundled +/// AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup +/// SerializedDictionary at boot and writes every (guid, AvatarItemData) +/// pair to JSON. THIS is the authoritative safe list — any GUID NOT +/// in this file throws InvalidOperationException("Missing data +/// for guid …") inside the watch's wardrobe parser, which aborts +/// the entire coroutine on the first bad item and leaves the drawer +/// empty. Items extracted from outfits_assets_*.bundle +/// MonoBehaviours (the older data/wardrobe_items.json) are +/// MASK + SWATCH asset GUIDs, NOT AvatarItem GUIDs, so that file +/// can't be used as a safelist. +/// +/// PER-PLAYER ownership: the watch intersects each store tile's +/// AvatarItemDesc against THIS list to flip the buy-button +/// to "Already Owned." Returning the global wardrobe here therefore +/// makes every store item appear owned. We instead intersect the +/// caller's with the master +/// catalog so the watch sees only items the player actually owns. +/// Anonymous calls return []. +/// +[ApiController] +public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase +{ + /// Master catalog keyed by GUID. Loaded once on first + /// request from data/avatar_item_lookup.json. Same + /// lazy-load idiom as before but the catalog itself is now a + /// dump of the watch's bundled + /// avatarItemDataLookup instead of the asset-bundle + /// MonoBehaviour scan in the previous wardrobe_items.json. + private static readonly Lazy _catalog = new(LoadCatalog); + + [HttpGet("api/avatar/v2/items")] + [HttpGet("api/avatar/v3/items")] + [HttpGet("api/avatar/v4/items")] + [Authorize] + public async Task>> Get() + { + // Keep this switch as a fast kill-switch, but default it on now that + // the response is bounded below. The risky path is not the parser + // itself; it is feeding the 2020 watch too many BrowsableAvatarItem / + // DynamicAvatarItemImposter rows at once. + if (!config.GetValue("Avatar:EnableUnlockedItems", true)) + return Ok(new List()); + + var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120); + var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20); + + var pid = this.CurrentPlayerId(); + if (pid is not long me) return Ok(new List()); + + var avatar = await db.Avatars + .Where(a => a.PlayerId == me) + .Select(a => new { a.InventoryJson, a.OutfitSelections }) + .FirstOrDefaultAsync(); + + var owned = ParseInventory(avatar?.InventoryJson); + var ownedHairDyeSlugs = await db.PlayerInventory + .Where(i => i.PlayerId == me && i.Quantity > 0) + .Select(i => i.ItemSlug) + .ToListAsync(); + var ownedHairDyes = ownedHairDyeSlugs + .Where(slug => StoreService.TryGetHairDyePayload(slug, out _)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List()); + + var catalog = _catalog.Value; + var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections); + var ordered = owned + .OrderByDescending(guid => equipped.Contains(guid)) + .ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue) + .ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase); + + var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count); + var perSlot = new Dictionary(); + var result = new List(Math.Min(owned.Count, maxItems)); + foreach (var guid in ordered) + { + if (result.Count >= maxOutfitItems) break; + // Intersect against the safelist. Items not in the bundled + // lookup would crash the watch's wardrobe parser, so they're + // silently dropped here. + if (!catalog.Items.TryGetValue(guid, out var item)) continue; + + var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1; + perSlot.TryGetValue(slot, out var count); + if (count >= maxPerSlot && !equipped.Contains(guid)) continue; + + perSlot[slot] = count + 1; + result.Add(item); + if (result.Count >= maxItems) break; + } + + foreach (var slug in ownedHairDyes) + { + if (result.Count >= maxItems) break; + if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue; + StoreService.TryGetHairDyeName(slug, out var friendlyName); + result.Add(new UnlockedAvatarItemDto + { + AvatarItemType = AvatarItemType.HairDye, + AvatarItemDesc = hairColorGuid, + PlatformMask = unchecked((int)0xFFFFFFFF), + FriendlyName = friendlyName, + Tooltip = string.Empty, + Rarity = 0, + }); + } + return Ok(result); + } + + /// The set of GUIDs the bundled + /// AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup + /// contains. Exposed so other controllers (StoreService seed, + /// PlayerService starter inventory) can validate their seed + /// data against the same safe list. + public static IReadOnlySet SafeGuids => _catalog.Value.SafeGuids; + + /// 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 + /// check. + public static IReadOnlyDictionary Catalog + => _catalog.Value.Items; + + /// Per-OutfitType slot index → list of GUIDs in that slot. + /// Used by to + /// build a starter loadout (one per slot) and a starter inventory + /// (a few per slot). + public static IReadOnlyDictionary> BySlot + => _catalog.Value.BySlot; + + public static bool TryGetOutfitType(string guid, out int outfitType) => + _catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType); + + private static List ParseInventory(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(); + try + { + var strings = JsonSerializer.Deserialize>(json); + if (strings is not null) return strings; + } + catch { } + + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return new(); + + var result = new List(); + foreach (var element in doc.RootElement.EnumerateArray()) + { + string? value = null; + if (element.ValueKind == JsonValueKind.String) + value = element.GetString(); + else if (element.ValueKind == JsonValueKind.Object && + element.TryGetProperty("itemId", out var itemId) && + itemId.ValueKind == JsonValueKind.String) + value = itemId.GetString(); + + if (string.IsNullOrWhiteSpace(value)) continue; + const string wardrobePrefix = "wardrobe-"; + if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal)) + value = value[wardrobePrefix.Length..]; + var comma = value.IndexOf(','); + if (comma >= 0) + value = value[..comma]; + result.Add(value); + } + + return result + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch { return new(); } + } + + private static HashSet ParseEquippedItemGuids(string? outfitSelections) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(outfitSelections)) return result; + + foreach (var selection in outfitSelections.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var comma = selection.IndexOf(','); + var guid = comma >= 0 ? selection[..comma] : selection; + if (!string.IsNullOrWhiteSpace(guid)) + result.Add(guid.Trim()); + } + + return result; + } + + private sealed record CatalogData( + IReadOnlyDictionary Items, + IReadOnlySet SafeGuids, + IReadOnlyDictionary> BySlot, + IReadOnlyDictionary SlotByGuid); + + private static CatalogData LoadCatalog() + { + // Try both lowercase 'data/' (csproj None+Link target) and + // uppercase 'Data/' (git-tracked source folder) so case-sensitivity + // mismatches between Windows (where the dev wrote the path) and + // Linux (where Coolify builds the container) can't lose the lookup + // file. Without this fallback, a single missed file → empty + // SafeGuids → StoreService.SeedAsync skips its stale-row cleanup + // → 418 swatch-GUID rows ship live → every store tile renders as + // Hat_Angler. + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, "data", "avatar_item_lookup.json"), + Path.Combine(AppContext.BaseDirectory, "Data", "avatar_item_lookup.json"), + Path.Combine(Directory.GetCurrentDirectory(), "data", "avatar_item_lookup.json"), + Path.Combine(Directory.GetCurrentDirectory(), "Data", "avatar_item_lookup.json"), + }; + var path = candidates.FirstOrDefault(System.IO.File.Exists); + Console.WriteLine(path is null + ? $"[avatar-catalog] avatar_item_lookup.json not found in any of: {string.Join(", ", candidates)}" + : $"[avatar-catalog] loading from {path}"); + if (path is null) return new CatalogData( + new Dictionary(), + new HashSet(), + new Dictionary>(), + new Dictionary()); + + using var fs = System.IO.File.OpenRead(path); + var dump = JsonSerializer.Deserialize(fs, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new LookupFile(); + + var items = new Dictionary(dump.Items.Count, StringComparer.OrdinalIgnoreCase); + var safe = new HashSet(StringComparer.OrdinalIgnoreCase); + var bySlot = new Dictionary>(); + var slotByGuid = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var row in dump.Items) + { + if (string.IsNullOrEmpty(row.Guid)) continue; + safe.Add(row.Guid); + // Rec Room's bundled item names are wrapped in parens like + // "(Hat_Wizard)" — strip those and the underscores for a + // friendlier UI label. + var pretty = PrettyName(row.Name); + items[row.Guid] = new UnlockedAvatarItemDto + { + // AvatarItemType is the wire enum the watch parses + // (Outfit=0 / HairDye=1), not the slot. Rows in + // avatarItemDataLookup are wearable avatar items, including + // hair meshes that *use* hair color. They must still be sent + // as Outfit; sending HairDye makes the client treat the item + // GUID as a color GUID and log "Can not find hair dye color". + AvatarItemType = AvatarItemType.Outfit, + AvatarItemDesc = row.Guid + ",,,", + PlatformMask = unchecked((int)0xFFFFFFFF), + FriendlyName = pretty, + Tooltip = string.Empty, + Rarity = 0, + }; + if (!bySlot.TryGetValue(row.OutfitType, out var lst)) + bySlot[row.OutfitType] = lst = new List(); + lst.Add(row.Guid); + slotByGuid[row.Guid] = row.OutfitType; + } + + return new CatalogData(items, safe, bySlot, slotByGuid); + } + + private static string PrettyName(string raw) + { + if (string.IsNullOrEmpty(raw)) return string.Empty; + // Strip leading/trailing parens that the bundled config wraps + // names in, then turn underscores into spaces. Keep it simple — + // the watch shows whatever string we send. + var trimmed = raw.Trim().TrimStart('(').TrimEnd(')'); + // Drop common asset-name suffixes. Items in the runtime config + // use the raw asset name (e.g. "Hat_Wizard", "shirt_sherlock"); + // the watch never normalises these, so we do it server-side. + var s = trimmed.Replace('_', ' ').Trim(); + return s; + } + + /// Shape of data/avatar_item_lookup.json as written + /// by tools/AvatarCatalogDumpPatch. + private sealed class LookupFile + { + [JsonPropertyName("items")] + public List Items { get; set; } = new(); + + // Auxiliary GUID lists (prefab/mask/swatch/decal). Not used + // server-side yet — they'd matter for resolving asset + // references the watch might subsequently request, but the + // wire protocol only ever ships the AvatarItem GUID. + [JsonPropertyName("prefab_guids")] + public List PrefabGuids { get; set; } = new(); + [JsonPropertyName("mask_guids")] + public List MaskGuids { get; set; } = new(); + [JsonPropertyName("swatch_guids")] + public List SwatchGuids { get; set; } = new(); + [JsonPropertyName("decal_guids")] + public List DecalGuids { get; set; } = new(); + } + + private sealed class LookupItem + { + [JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty; + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("outfit_type")] public int OutfitType { get; set; } + [JsonPropertyName("outfit_set")] public int OutfitSet { get; set; } + [JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; } + [JsonPropertyName("hides_hair")] public bool HidesHair { get; set; } + [JsonPropertyName("hides_hand")] public bool HidesHand { get; set; } + [JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; } + [JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; } + [JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; } + } + + /// Mirrors RecNet.Avatars+AvatarItemType + /// (Avatars.cs:186-190). + private static class AvatarItemType + { + public const int Outfit = 0; + public const int HairDye = 1; + } + + /// OutfitType slot enum mirrored from + /// RecRoom.Avatar.Data.Shared.OutfitType. Used by other + /// callers (PlayerService, StoreService) for slot-aware seeding. + public static class Slot + { + public const int Hat = 0; + public const int BackHead = 1; + public const int Hair = 2; + public const int Eye = 10; + public const int Mouth = 20; + public const int Neck = 100; + public const int Shirt = 101; + public const int Belt = 102; + public const int Pocket = 103; + public const int TeamShirt = 104; + public const int Wrist = 200; + public const int Glove = 201; + public const int Watch = 202; + public const int TeamWrist = 203; + } +} + +public class UnlockedAvatarItemDto +{ + [JsonPropertyName("AvatarItemType")] + public int AvatarItemType { get; set; } + + [JsonPropertyName("AvatarItemDesc")] + public string AvatarItemDesc { get; set; } = string.Empty; + + [JsonPropertyName("PlatformMask")] + public int PlatformMask { get; set; } + + [JsonPropertyName("FriendlyName")] + public string FriendlyName { get; set; } = string.Empty; + + [JsonPropertyName("Tooltip")] + public string Tooltip { get; set; } = string.Empty; + + [JsonPropertyName("Rarity")] + public int Rarity { get; set; } +} diff --git a/DorkNet.Server/Controllers/API/BugReporting/BugReportsController.cs b/DorkNet.Server/Controllers/API/BugReporting/BugReportsController.cs new file mode 100644 index 0000000..1bef8b8 --- /dev/null +++ b/DorkNet.Server/Controllers/API/BugReporting/BugReportsController.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.BugReporting; + +/// +/// api.rec.net/api/bugreporting/* — in-game "Report a Bug" UI. +/// Wire request DTO matches RecNet.BugReporting+BugReportDTO +/// (Cpp2IL_CS/.../RecNet/BugReporting.cs): Summary, +/// Description, TestCaseKey, BuildVersion, BuildTimestamp, +/// BundleVersionCode. +/// +/// Plus POST /api/bugreporting/v1/submit (legacy v1 path) and +/// POST /api/bugreporting/v2/submit (renamed v2). The +/// ReportBug client API also includes screenshot + log byte +/// arrays as multipart parts; we accept-and-discard those (no +/// storage flow yet) but persist the text fields. +/// +[ApiController] +[Authorize] +public class BugReportsController(DorkNetDbContext db) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + public sealed class BugReportRequest + { + public string? Summary { get; set; } + public string? Description { get; set; } + public string? TestCaseKey { get; set; } + public string? BuildVersion { get; set; } + public long? BuildTimestamp { get; set; } + public int? BundleVersionCode { get; set; } + } + + [HttpPost("api/bugreporting/v1/submit")] + [HttpPost("api/bugreporting/v2/submit")] + [HttpPost("api/bugreporting/v1/reportbug")] + [HttpPost("api/bugreporting/v2/reportbug")] + [Consumes("application/json")] + public async Task SubmitJson([FromBody] BugReportRequest req) + => await PersistAsync(req); + + /// Multipart form variant — the watch posts screenshot + /// + log as separate parts. We pull the text-only fields and + /// drop the binary attachments. + [HttpPost("api/bugreporting/v1/submit-multipart")] + [HttpPost("api/bugreporting/v2/reportbug-multipart")] + [Consumes("multipart/form-data")] + public async Task SubmitMultipart( + [FromForm(Name = "Summary")] string? summary, + [FromForm(Name = "Description")] string? description, + [FromForm(Name = "TestCaseKey")] string? testCaseKey, + [FromForm(Name = "BuildVersion")] string? buildVersion, + [FromForm(Name = "BuildTimestamp")] long? buildTimestamp) + => await PersistAsync(new BugReportRequest + { + Summary = summary, + Description = description, + TestCaseKey = testCaseKey, + BuildVersion = buildVersion, + BuildTimestamp = buildTimestamp, + }); + + private async Task PersistAsync(BugReportRequest req) + { + var pid = Me; + var summary = (req.Summary ?? string.Empty).Trim(); + var body = (req.Description ?? string.Empty).Trim(); + if (summary.Length == 0 && body.Length == 0) + return BadRequest(new { Error = "summary or description required" }); + + db.BugReports.Add(new BugReportEntity + { + ReporterPlayerId = pid, + Title = summary[..Math.Min(128, summary.Length)], + Body = body[..Math.Min(4000, body.Length)], + ClientVersion = req.BuildVersion ?? string.Empty, + Platform = HttpContext.Request.Headers.UserAgent.ToString()[..Math.Min(32, HttpContext.Request.Headers.UserAgent.ToString().Length)], + Category = string.IsNullOrEmpty(req.TestCaseKey) ? "bug" : $"bug:{req.TestCaseKey}", + }); + await db.SaveChangesAsync(); + return Ok(new { Submitted = true }); + } +} diff --git a/DorkNet.Server/Controllers/API/Cards/CardsController.cs b/DorkNet.Server/Controllers/API/Cards/CardsController.cs new file mode 100644 index 0000000..7290576 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Cards/CardsController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Controllers.API.Cards; + +/// +/// api.{rec.net,localhost}/api/cards/v1/all — home-screen card panel +/// (announcements, promos, daily-bonus tiles). Returns global cards +/// PLUS any cards targeted at the caller, sorted by Priority then +/// CreatedAt. Expired cards filtered out server-side. +/// +[ApiController] +public class CardsController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/cards/v1/all")] + public async Task All() + { + var pid = this.CurrentPlayerId(); + var now = DateTime.UtcNow; + var rows = await db.Cards + .Where(c => (c.PlayerId == null || c.PlayerId == pid) && + (c.ExpiresAt == null || c.ExpiresAt > now)) + .OrderByDescending(c => c.Priority).ThenByDescending(c => c.CreatedAt) + .Take(50) + .Select(c => new + { + c.Id, + c.Title, + c.Body, + c.ImageName, + c.ActionUrl, + c.Category, + c.CreatedAt, + }) + .ToListAsync(); + return Ok(rows); + } +} diff --git a/DorkNet.Server/Controllers/API/CommunityBoard/CommunityBoardController.cs b/DorkNet.Server/Controllers/API/CommunityBoard/CommunityBoardController.cs new file mode 100644 index 0000000..48a1225 --- /dev/null +++ b/DorkNet.Server/Controllers/API/CommunityBoard/CommunityBoardController.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.CommunityBoard; + +/// +/// api.rec.net/api/communityboard/v1/current — backs the dorm's +/// community board. The watch's +/// RecNet.CommunityBoard.GetCurrent hits +/// "{api/communityboard/}v1/current" +/// (Cpp2IL_ISIL/.../CommunityBoard.txt:902) and parses the response as +/// a CommunityBoardDTO with FeaturedPlayer / FeaturedRoomGroup / +/// CurrentAnnouncement / InstagramImages / Videos +/// (CommunityBoard_NestedType_CommunityBoardDTO.txt:460-480). +/// +/// State lives in as a single +/// DB row keyed at CommunityBoardRows.Id = 1. Admins update it +/// via POST /api/admin/v1/communityboard from the admin UI. +/// +[ApiController] +public class CommunityBoardController( + CommunityBoardService board, + DorkNetDbContext db, + DomainConfig domain) : ControllerBase +{ + [HttpGet("/api/communityboard/v1/current")] + [HttpGet("/api/communityboard/v2/current")] + public async Task Current() + { + var state = await board.GetAsync(); + + // InstagramImage URL backfill — the watch's + // InstagramImageDTO.Deserialize calls a string-typed GetKey for + // both ImageName AND ImageUrl + // (CommunityBoard_NestedType_InstagramImageDTO.txt:85-91); an + // empty URL yields a broken tile. The admin SPA persists only + // ImageName, so we derive the URL on read against the configured + // deployment apex (DORKNET_DOMAIN). + var instagram = state.InstagramImages.Select(img => new + { + ImageName = img.ImageName, + ImageUrl = string.IsNullOrEmpty(img.ImageUrl) && !string.IsNullOrEmpty(img.ImageName) + ? $"https://{domain.Sub("img")}/{img.ImageName}" + : img.ImageUrl, + }).ToArray(); + + // FeaturedRoomGroup expansion — the watch's FeaturedRoomGroupDTO + // (Rooms_NestedType_FeaturedRoomGroupDTO.txt:75-93) reads + // FeaturedRooms as List<FeaturedRoom> where each + // FeaturedRoom is {RoomName, RoomId, ImageName} with + // RoomId required (Util.GetKey at FeaturedRoom.txt:415-417). + // The admin SPA persists the list as a bare List<long> + // of room ids — which the watch can't deserialize, throwing + // KeyNotFoundException for "RoomId" on each item. With the list + // throwing, GetCurrentCommunityBoardData fails and the whole + // community-board panel renders empty. Expand the ids to full + // FeaturedRoom shapes here so the wire payload deserialises. + object? featuredRoomGroup = null; + if (state.FeaturedRoomGroup is { } group) + { + var ids = group.FeaturedRooms.ToList(); + var rooms = ids.Count == 0 + ? new() + : await db.Rooms + .Where(r => ids.Contains(r.Id)) + .Select(r => new { r.Id, r.Name, r.ImageName }) + .ToDictionaryAsync(r => r.Id); + featuredRoomGroup = new + { + Name = group.Name ?? string.Empty, + FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r) + ? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty } + : new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty }) + .ToArray(), + }; + } + + return Ok(new + { + FeaturedPlayer = state.FeaturedPlayer, + FeaturedRoomGroup = featuredRoomGroup, + CurrentAnnouncement = state.CurrentAnnouncement, + InstagramImages = instagram, + Videos = state.Videos, + }); + } +} diff --git a/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs b/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs new file mode 100644 index 0000000..9042609 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Config.V1; + +[ApiController] +[Route("api/[controller]/v1")] +public class ConfigController(ConfigService configService) : ControllerBase +{ + [HttpGet("motd")] + public ActionResult GetMotd() + { + var cfg = configService.GetConfig($"{Request.Scheme}://{Request.Host}"); + return Ok(cfg.MessageOfTheDay); + } + + [HttpGet("objectives")] + public ActionResult> GetObjectives() => Ok(new List()); + + // AmplitudeConfig.Deserialize expects key "AmplitudeKey" + [HttpGet("amplitude")] + public ActionResult GetAmplitude() => Ok(new { AmplitudeKey = string.Empty }); + + /// + /// `Config.GetCohortNUXButtonConfigs(cohortId)` — returns the four + /// "What would you like to do first?" buttons shown in the OOBE + /// (RegistrationSceneNUX → ChooseCohortScreen). Each button maps to + /// a room the new player can teleport into. + /// + /// Wire shape per CohortNUXButtonConfig (TypeDefIndex 11516) — list of: + /// Version (int) + /// ButtonNumber (int, 0..3 → Button1..Button4) + /// Override (int, 0=None, 1=Hotlist, 2=Custom) + /// CustomRoomName (string) + /// CustomTitle (string) + /// DefaultRoomName (string) + /// DefaultTitle (string) + /// + /// The screen picks `CustomRoomName` if Override=Custom, else the + /// `DefaultRoomName` (resolved by Rooms.GetByName). For simplicity all + /// four use Override=None and route to Rec Room Original rooms we + /// already seeded — picking the most newcomer-friendly ones. + /// + [HttpGet("cohortnux/{cohortId:int}")] + public IActionResult GetCohortNux(int cohortId) => Ok(new[] + { + new { + Version = 1, ButtonNumber = 0, Override = 0, + CustomRoomName = "", CustomTitle = "", + DefaultRoomName = "RecCenter", + DefaultTitle = "Hang out at the Rec Center", + }, + new { + Version = 1, ButtonNumber = 1, Override = 0, + CustomRoomName = "", CustomTitle = "", + DefaultRoomName = "Paintball", + DefaultTitle = "Play Paintball", + }, + new { + Version = 1, ButtonNumber = 2, Override = 0, + CustomRoomName = "", CustomTitle = "", + DefaultRoomName = "Dodgeball", + DefaultTitle = "Play Dodgeball", + }, + new { + Version = 1, ButtonNumber = 3, Override = 0, + CustomRoomName = "", CustomTitle = "", + DefaultRoomName = "GoldenTrophy", + DefaultTitle = "Quest for the Golden Trophy", + }, + }); +} diff --git a/DorkNet.Server/Controllers/API/Config/V1/ServiceUrlsController.cs b/DorkNet.Server/Controllers/API/Config/V1/ServiceUrlsController.cs new file mode 100644 index 0000000..fe6b770 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Config/V1/ServiceUrlsController.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Config.V1; + +/// +/// Returns the full Rec Room service URL map. Some builds fetch this standalone +/// at /api/config/v1/services to discover where the other subdomains live. +/// Apex comes from (DORKNET_DOMAIN), so a single +/// env var controls every URL emitted. +/// +[ApiController] +[Route("api/config/v1")] +public class ServiceUrlsController(DomainConfig domain) : ControllerBase +{ + [HttpGet("services")] + public ActionResult> Get() + => Ok(ConfigService.BuildServiceUrlMap(domain.Apex)); +} diff --git a/DorkNet.Server/Controllers/API/Config/V2/ConfigController.cs b/DorkNet.Server/Controllers/API/Config/V2/ConfigController.cs new file mode 100644 index 0000000..69d359a --- /dev/null +++ b/DorkNet.Server/Controllers/API/Config/V2/ConfigController.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Models.Config; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Config.V2; + +[ApiController] +[Route("api/[controller]/v2")] +public class ConfigController(ConfigService configService) : ControllerBase +{ + [HttpGet] + public ActionResult GetConfig() + { + var baseUrl = $"{Request.Scheme}://{Request.Host}"; + return Ok(configService.GetConfig(baseUrl)); + } +} diff --git a/DorkNet.Server/Controllers/API/Events/V3/EventsController.cs b/DorkNet.Server/Controllers/API/Events/V3/EventsController.cs new file mode 100644 index 0000000..fac36d7 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Events/V3/EventsController.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DorkNet.Server.Controllers.API.Events.V3; + +[ApiController] +[Route("api/[controller]/v3")] +public class EventsController : ControllerBase +{ + [HttpGet("list")] + public ActionResult> GetActiveEvents() => Ok(new List()); +} diff --git a/DorkNet.Server/Controllers/API/GameConfigurations/GameConfigurationsController.cs b/DorkNet.Server/Controllers/API/GameConfigurations/GameConfigurationsController.cs new file mode 100644 index 0000000..156acbe --- /dev/null +++ b/DorkNet.Server/Controllers/API/GameConfigurations/GameConfigurationsController.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DorkNet.Server.Controllers.API.GameConfigurations; + +[ApiController] +public class GameConfigurationsController : ControllerBase +{ + // Actual path used by the 2020 build + [HttpGet("api/gameconfigs/v1/all")] + [HttpGet("api/gameconfigs/v1")] + // Legacy path we originally assumed + [HttpGet("api/gameconfigurations/v1/all")] + [HttpGet("api/gameconfigurations/v1")] + public ActionResult> GetAll() => Ok(new List()); +} diff --git a/DorkNet.Server/Controllers/API/GameSessions/V2/GameSessionsController.cs b/DorkNet.Server/Controllers/API/GameSessions/V2/GameSessionsController.cs new file mode 100644 index 0000000..21cf53a --- /dev/null +++ b/DorkNet.Server/Controllers/API/GameSessions/V2/GameSessionsController.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using DorkNet.Models.GameSessions; +using DorkNet.Server.Auth; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.GameSessions.V2; + +[ApiController] +[Route("api/[controller]/v2")] +[Authorize] +public class GameSessionsController(GameSessionService sessionService) : ControllerBase +{ + private long CurrentPlayerId => this.RequireCurrentPlayerId(); + + [HttpPost("joinRandom")] + public async Task> JoinRandom([FromBody] JoinRandomGameSessionRequest req) + { + var session = await sessionService.JoinOrCreateAsync(req.RoomId, req.ActivityLevelId, req.RegionId); + return Ok(new JoinGameSessionResponse + { + Result = JoinGameErrorCode.Success, + GameSession = session, + }); + } + + [HttpPost("joinById")] + public async Task> JoinById([FromBody] JoinByIdRequest req) + { + var session = await sessionService.GetByIdAsync(req.GameSessionId); + if (session is null) + { + return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.NotFound }); + } + + if (session.IsFull) + { + return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Full }); + } + + return Ok(new JoinGameSessionResponse + { + Result = JoinGameErrorCode.Success, + GameSession = session, + }); + } + + [HttpPost("leave")] + public async Task Leave([FromBody] long sessionId) + { + await sessionService.PlayerLeftAsync(sessionId); + return Ok(); + } + + [HttpGet("{id:long}")] + public async Task> GetSession(long id) + { + var session = await sessionService.GetByIdAsync(id); + if (session is null) return NotFound(); + return Ok(session); + } +} + +public class JoinByIdRequest +{ + public long GameSessionId { get; set; } +} diff --git a/DorkNet.Server/Controllers/API/Groups/GroupsController.cs b/DorkNet.Server/Controllers/API/Groups/GroupsController.cs new file mode 100644 index 0000000..df6b165 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Groups/GroupsController.cs @@ -0,0 +1,240 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.Groups; + +/// +/// api.rec.net/api/groups/* — clubs / groups. Wire types from +/// RecNet.Group + RecNet.GroupMembership: +/// +/// • Group: GroupId, Name, Description, CreatedAt, ImageName, +/// BanStatus, CreatorId, NumMembers, Members. +/// • GroupMembership: GroupId, PlayerId, Permissions +/// (GroupMembershipPermissions flags). +/// +/// URL surface (verb + template) verified against +/// Cpp2IL_ISIL/.../RecNet/Groups.txt: +/// GET api/groups/v1/{id} +/// GET api/groups/v1/name/{name} +/// GET api/groups/v1/memberships/{playerId} +/// POST api/groups/v1 (create — body CreateGroupRequest) +/// POST api/groups/v1/delete/{id} +/// +[ApiController] +public class GroupsController(DorkNetDbContext db) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + private long? MeOrNull => this.CurrentPlayerId(); + + [HttpGet("api/groups/v1/{id:long}")] + public async Task GetById(long id) + { + var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id); + if (g is null) return NotFound(); + var members = await db.ClubMemberships + .Where(m => m.ClubId == id) + .ToListAsync(); + return Ok(ToWire(g, members)); + } + + [HttpGet("api/groups/v1/name/{name}")] + public async Task GetByName(string name) + { + var g = await db.Clubs.FirstOrDefaultAsync(c => c.Name == name); + if (g is null) return NotFound(); + var members = await db.ClubMemberships + .Where(m => m.ClubId == g.Id) + .ToListAsync(); + return Ok(ToWire(g, members)); + } + + [HttpGet("api/groups/v1/memberships/{playerId:long}")] + public async Task Memberships(long playerId) + { + var rows = await (from m in db.ClubMemberships + join c in db.Clubs on m.ClubId equals c.Id + where m.PlayerId == playerId + select new { m, c }).ToListAsync(); + return Ok(rows.Select(r => new + { + GroupId = r.c.Id, + PlayerId = (int)r.m.PlayerId, + Permissions = r.m.Permissions, + Group = ToWire(r.c, new List()), + })); + } + + public sealed class CreateGroupRequest + { + public string? Name { get; set; } + public string? Description { get; set; } + public string? ImageName { get; set; } + } + + [HttpPost("api/groups/v1")] + [Authorize] + public async Task Create([FromBody] CreateGroupRequest req) + { + if (string.IsNullOrWhiteSpace(req.Name)) + return BadRequest(new { Result = 1, Error = "name required" }); + var pid = Me; + var dupe = await db.Clubs.AnyAsync(c => c.Name == req.Name); + if (dupe) return Conflict(new { Result = 2, Error = "name taken" }); + + var club = new ClubEntity + { + Name = req.Name.Trim(), + Description = req.Description ?? string.Empty, + ImageName = req.ImageName ?? string.Empty, + CreatorPlayerId = pid, + BanStatus = 0, + }; + db.Clubs.Add(club); + await db.SaveChangesAsync(); + + // Owner membership uses GroupMembershipPermissions.Owner = 127. + db.ClubMemberships.Add(new ClubMembershipEntity + { + ClubId = club.Id, + PlayerId = pid, + Permissions = 127, + }); + await db.SaveChangesAsync(); + return Ok(new + { + Result = 0, + Group = ToWire(club, new List + { + new() { ClubId = club.Id, PlayerId = pid, Permissions = 127 }, + }), + }); + } + + public sealed class UpdateGroupRequest + { + public string? Name { get; set; } + public string? Description { get; set; } + public string? ImageName { get; set; } + } + + /// POST api/groups/v1/{id} — owner-only update of + /// description / image (per RecNet.Groups.UpdateGroup body + /// shape). Mirrors the create response so the watch's + /// CreateModifyGroupResponse deserialiser parses both. + [HttpPost("api/groups/v1/{id:long}")] + [Authorize] + public async Task Update(long id, [FromBody] UpdateGroupRequest req) + { + var pid = Me; + var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id); + if (g is null) return NotFound(); + if (g.CreatorPlayerId != pid) return Forbid(); + if (req.Description is not null) g.Description = req.Description; + if (req.ImageName is not null) g.ImageName = req.ImageName; + // Name updates are routed to the dedicated endpoint below + // (uniqueness check + name index has to round-trip). + g.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync(); + return Ok(new { Result = 0, Group = ToWire(g, members) }); + } + + public sealed class UpdateGroupNameRequest { public string? Name { get; set; } } + + /// POST api/groups/v1/name/{id} — owner-only + /// rename. Unique-name collision returns Result=2 to match the + /// client's CreateModifyGroupStatus enum. + [HttpPost("api/groups/v1/name/{id:long}")] + [Authorize] + public async Task UpdateName(long id, [FromBody] UpdateGroupNameRequest req) + { + if (string.IsNullOrWhiteSpace(req.Name)) + return BadRequest(new { Result = 1, Error = "name required" }); + var pid = Me; + var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id); + if (g is null) return NotFound(); + if (g.CreatorPlayerId != pid) return Forbid(); + var dupe = await db.Clubs.AnyAsync(c => c.Id != id && c.Name == req.Name); + if (dupe) return Conflict(new { Result = 2, Error = "name taken" }); + g.Name = req.Name.Trim(); + g.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync(); + return Ok(new { Result = 0, Group = ToWire(g, members) }); + } + + [HttpPost("api/groups/v1/delete/{id:long}")] + [HttpDelete("api/groups/v1/delete/{id:long}")] + [Authorize] + public async Task Delete(long id) + { + var pid = Me; + var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id); + if (g is null) return NotFound(); + if (g.CreatorPlayerId != pid) return Forbid(); + var memberships = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync(); + db.ClubMemberships.RemoveRange(memberships); + db.Clubs.Remove(g); + await db.SaveChangesAsync(); + return Ok(new { Deleted = true }); + } + + [HttpPost("api/groups/v1/{id:long}/join")] + [Authorize] + public async Task Join(long id) + { + var pid = Me; + var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id); + if (g is null) return NotFound(); + var existing = await db.ClubMemberships + .FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid); + if (existing is not null) return Ok(new { Joined = true }); + db.ClubMemberships.Add(new ClubMembershipEntity + { + ClubId = id, + PlayerId = pid, + Permissions = 0, // Member + }); + await db.SaveChangesAsync(); + return Ok(new { Joined = true }); + } + + [HttpPost("api/groups/v1/{id:long}/leave")] + [Authorize] + public async Task Leave(long id) + { + var pid = Me; + var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id); + if (g is null) return NotFound(); + if (g.CreatorPlayerId == pid) + return BadRequest(new { Error = "owner cannot leave; transfer ownership first" }); + var row = await db.ClubMemberships + .FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid); + if (row is null) return NotFound(); + db.ClubMemberships.Remove(row); + await db.SaveChangesAsync(); + return Ok(new { Left = true }); + } + + private static object ToWire(ClubEntity g, List members) => new + { + GroupId = g.Id, + g.Name, + g.Description, + g.CreatedAt, + g.ImageName, + BanStatus = g.BanStatus, + CreatorId = (int)g.CreatorPlayerId, + NumMembers = members.Count, + Members = members.Select(m => new + { + GroupId = g.Id, + PlayerId = (int)m.PlayerId, + Permissions = m.Permissions, + }), + }; +} diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs new file mode 100644 index 0000000..8ebcd29 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs @@ -0,0 +1,849 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Http; +using System.Text.Json; +using DorkNet.Models.Auth; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Images; + +/// +/// api.rec.net/api/images/v* — in-game camera photo endpoints. The 2020 +/// watch's camera UI ("save photo to album", "share photo") posts here. +/// +/// Wire shape (observed from server logs — the 2020 watch hits these +/// without a corresponding storage.rec.net/upload, meaning the bytes +/// come straight in this request body): +/// POST api/images/v4/uploadsaved +/// - body: multipart/form-data with the image file plus optional +/// fields (Caption, Description, RoomId, IsPublic, TaggedPlayerIds). +/// On the 2020 client the field name varies — we accept "File", +/// "Image", and any *.png/*.jpg upload for safety. +/// - response: an ImageInfo-shaped object (PascalCase keys). The +/// watch's success path calls a JSON parser whose strict keys are +/// AccountId, ImageName, CreatedAt — so all three are required. +/// Returning [] (the previous catch-all) made the watch silently +/// drop the photo. +/// +/// Saved photos are stored as rows with +/// IsPublic=false by default. A separate Share button (POST +/// api/images/v4/share/{id}) flips IsPublic=true and surfaces them on +/// feed.rec.net. +/// +[ApiController] +public class ImagesController( + DorkNetDbContext db, + PlayerPresenceService presence, + LevelService level, + IObjectStorage storage, + ImageSignatureService signatures, + NotificationService notifications, + DomainConfig domain, + ILogger logger) : ControllerBase +{ + private static readonly string ImageDir = + Path.Combine(AppContext.BaseDirectory, "data", "images"); + + private static readonly byte[] TransparentPng = new byte[] + { + 0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D, + 0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01, + 0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4,0x89,0x00,0x00,0x00, + 0x0D,0x49,0x44,0x41,0x54,0x78,0x9C,0x63,0x00,0x01,0x00,0x00, + 0x05,0x00,0x01,0x0D,0x0A,0x2D,0xB4,0x00,0x00,0x00,0x00,0x49, + 0x45,0x4E,0x44,0xAE,0x42,0x60,0x82, + }; + + /// POST api/images/v{2-5}/uploadsaved — receive an image + /// from the in-game camera "save to album" button. Persists the + /// bytes in and creates a + /// row keyed by the uploader. The photo + /// defaults to IsPublic=false (saved-only) so it doesn't surface + /// on the public feed unless the user explicitly hits Share. + [HttpPost("api/images/v2/uploadsaved")] + [HttpPost("api/images/v3/uploadsaved")] + [HttpPost("api/images/v4/uploadsaved")] + [HttpPost("api/images/v5/uploadsaved")] + [Authorize] + public async Task UploadSaved() + { + var pid = this.RequireCurrentPlayerId(); + var bytes = await ReadImageBytesAsync(); + if (bytes is null || bytes.Length == 0) + { + logger.LogWarning("[images] uploadsaved by {Pid}: no file bytes received", pid); + return BadRequest(new { error = "missing_file" }); + } + + // Pull optional metadata from the form (the 2020 watch sometimes + // includes a caption / room context, sometimes posts bytes only). + var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null; + var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty; + var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty; + long roomId = 0; + if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid; + if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0; + var isPublic = string.Equals(form?["IsPublic"].ToString(), "true", StringComparison.OrdinalIgnoreCase); + var isProfileThumbnail = IsProfileThumbnail(form); + LogUploadForm("uploadsaved", pid, form, isProfileThumbnail); + + // Detect format. The watch uses PNG; if a JPEG slips through we + // tag it accordingly so the cdn serves the right MIME. + var extension = SniffExtension(bytes); + var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}"; + + // Dual-write during the migration window: S3 is the canonical + // store going forward, but we keep the DB Bytes column populated + // so reads from older code paths still work. PR 4 drops the + // column once every blob has a confirmed S3 copy. + try + { + using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4)); + var (bucket, key) = BlobRouter.Route(blobName); + await storage.PutAsync( + bucket, key, bytes, + extension == "png" ? "image/png" : "image/jpeg", + s3Timeout.Token); + } + catch (Exception ex) + { + logger.LogError( + ex, + "[images] S3 upload failed for {Blob}; keeping DB fallback so the client does not fail", + blobName); + } + + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = 0, + BlobName = blobName, + UploadedByPlayerId = pid, + UploadedAt = DateTime.UtcNow, + ReferencedFilenamesCsv = string.Empty, + }); + + var photo = new PhotoEntity + { + UploaderPlayerId = pid, + BlobName = blobName, + Caption = caption.Trim(), + TaggedPlayerIdsCsv = NormaliseTaggedIds(taggedIds, exceptId: pid), + RoomId = roomId, + IsPublic = isPublic, + }; + db.Photos.Add(photo); + + if (isProfileThumbnail) + { + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid); + if (player is not null) + { + player.ProfileImageName = blobName; + logger.LogInformation( + "[images] profile thumbnail upload set profile image: player={PlayerId} blob={Blob}", + pid, + blobName); + } + } + + await db.SaveChangesAsync(); + + if (isProfileThumbnail) + { + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid); + if (player is not null) + await NotifyAccountImageChangedAsync(player); + } + + // Small XP bump for taking a photo — encourages camera use. + await level.AwardXpAsync(pid, LevelService.RoomVisitXp, $"photo_saved:{photo.Id}"); + + logger.LogInformation( + "[images] uploaded saved photo {Id} by {Pid} ({Bytes} bytes) → {Blob}", + photo.Id, pid, bytes.Length, blobName); + + return Ok(BuildImageInfo(photo, pid)); + } + + /// POST api/images/v{1-5}/profile — direct profile image upload. + /// Older clients use this instead of tagging an uploadsaved request as a + /// ProfileThumbnail, so this route must always update Account.ProfileImage. + [HttpPost("api/images/v1/profile")] + [HttpPost("api/images/v2/profile")] + [HttpPost("api/images/v3/profile")] + [HttpPost("api/images/v4/profile")] + [HttpPost("api/images/v5/profile")] + [Authorize] + public async Task UploadProfile() + { + var pid = this.RequireCurrentPlayerId(); + var bytes = await ReadImageBytesAsync(); + if (bytes is null || bytes.Length == 0) + { + logger.LogWarning("[images] profile upload by {Pid}: no file bytes received", pid); + return BadRequest(new { error = "missing_file" }); + } + + var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null; + LogUploadForm("profile", pid, form, isProfile: true); + + var extension = SniffExtension(bytes); + var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}"; + var contentType = extension == "png" ? "image/png" : "image/jpeg"; + + try + { + using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4)); + var (bucket, key) = BlobRouter.Route(blobName); + await storage.PutAsync(bucket, key, bytes, contentType, s3Timeout.Token); + } + catch (Exception ex) + { + logger.LogError( + ex, + "[images] profile S3 upload failed for {Blob}; keeping DB fallback so the client does not fail", + blobName); + } + + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = 0, + BlobName = blobName, + UploadedByPlayerId = pid, + UploadedAt = DateTime.UtcNow, + ReferencedFilenamesCsv = string.Empty, + }); + + var photo = new PhotoEntity + { + UploaderPlayerId = pid, + BlobName = blobName, + Caption = string.Empty, + TaggedPlayerIdsCsv = string.Empty, + RoomId = presence.GetRoom(pid)?.RoomId ?? 0, + IsPublic = false, + }; + db.Photos.Add(photo); + + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid); + if (player is not null) + player.ProfileImageName = blobName; + + await db.SaveChangesAsync(); + + if (player is not null) + await NotifyAccountImageChangedAsync(player); + + logger.LogInformation( + "[images] uploaded profile photo {Id} by {Pid} ({Bytes} bytes) -> {Blob}", + photo.Id, pid, bytes.Length, blobName); + + return Ok(new + { + ImageName = blobName, + AccountId = (int)pid, + CreatedAt = photo.CreatedAt, + }); + } + + /// GET api/images/v{2-5}/saved — list the caller's saved + /// photos. The watch's "My Photos" tab calls this to populate the + /// album view. + [HttpGet("api/images/v2/saved")] + [HttpGet("api/images/v3/saved")] + [HttpGet("api/images/v4/saved")] + [HttpGet("api/images/v5/saved")] + [Authorize] + public async Task GetSaved() + { + var pid = this.RequireCurrentPlayerId(); + var rows = await db.Photos + .Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null) + .OrderByDescending(p => p.CreatedAt) + .Take(200) + .ToListAsync(); + return Ok(rows.Select(p => BuildImageInfo(p, pid))); + } + + /// GET api/images/v2/named — the watch's + /// Images.DownloadNamedImageMappings endpoint. Returns a + /// list of NamedImageDTO entries; an empty list is a valid + /// response (the watch's ExpectListResponse rejects an + /// object body but tolerates an empty array). + [HttpGet("api/images/v2/named")] + public IActionResult NamedImages() => Ok(Array.Empty()); + + /// GET api/images/v1/slideshow — the watch's lobby + /// slideshow info. Response shape per SlideshowInfoDTO.Deserialize: + /// {ValidTill (DateTime, required), Images (list, required)}. + /// Far-future ValidTill stops the watch from re-polling. + [HttpGet("api/images/v1/slideshow")] + public IActionResult SlideshowInfo() => Ok(new + { + ValidTill = new DateTime(9999, 12, 31, 0, 0, 0, DateTimeKind.Utc), + Images = Array.Empty(), + }); + + /// Binary image fallback for clients that request image bytes + /// through api/images/{name} instead of img.rec.net/{name}. Specific JSON + /// endpoints above still win route matching; this only handles CDN-shaped + /// image paths. + [HttpGet("api/images/{*path}", Order = 100)] + public async Task ServeImageFromApiHost(string? path) + { + var fileName = Path.GetFileName(path ?? string.Empty); + // Strip the leading '$' sigil that ImageData.image_name uses for + // hash-addressed assets. Watch sends "%24.jpg"; route + // decode gives us "$.jpg"; the bytes are stored at + // ".jpg". Without the strip, placed in-world polaroids + // miss in S3 and the watch renders the "?" fallback. + if (!string.IsNullOrEmpty(fileName) && fileName[0] == '$') fileName = fileName[1..]; + if (string.IsNullOrWhiteSpace(fileName) || !IsImageName(fileName)) + return NotFound(); + + var result = await LoadImageBytesAsync(fileName); + if (result.Bytes is { Length: > 0 }) + { + logger.LogInformation( + "[images] api image hit host={Host} path={Path} file={File} source={Source} bytes={Bytes} query='{Query}'", + Request.Host.Host, + path, + fileName, + result.Source, + result.Bytes.Length, + Request.QueryString); + Response.Headers.CacheControl = "public, max-age=300"; + signatures.AddContentSignature(Response, result.Bytes); + return File(result.Bytes, MimeFromName(fileName)); + } + + logger.LogWarning( + "[images] api image MISS host={Host} path={Path} file={File} query='{Query}' -> transparent png", + Request.Host.Host, + path, + fileName, + Request.QueryString); + Response.Headers.CacheControl = "public, max-age=60"; + signatures.AddContentSignature(Response, TransparentPng); + return File(TransparentPng, "image/png"); + } + + /// GET api/images/v{2-5}/byaccount/{accountId} — list a + /// player's PUBLIC photos. Drives the "their album" tab in + /// the watch's player profile. + [HttpGet("api/images/v2/byaccount/{accountId:long}")] + [HttpGet("api/images/v3/byaccount/{accountId:long}")] + [HttpGet("api/images/v4/byaccount/{accountId:long}")] + [HttpGet("api/images/v5/byaccount/{accountId:long}")] + public async Task GetByAccount(long accountId, + [FromQuery] int take = 50, [FromQuery] int skip = 0) + { + take = Math.Clamp(take, 1, 200); + skip = Math.Max(0, skip); + var rows = await db.Photos + .Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null) + .OrderByDescending(p => p.CreatedAt) + .Skip(skip).Take(take) + .ToListAsync(); + return Ok(rows.Select(p => BuildImageInfo(p, accountId))); + } + + /// POST api/images/v{2-5}/share/{id} — flip a saved photo + /// to public so it shows up on feed.rec.net. The watch's "Share" + /// button hits this; if the watch posts to a different URL we'll + /// add it when we see it in logs. + [HttpPost("api/images/v2/share/{id:long}")] + [HttpPost("api/images/v3/share/{id:long}")] + [HttpPost("api/images/v4/share/{id:long}")] + [HttpPost("api/images/v5/share/{id:long}")] + [Authorize] + public async Task Share(long id) + { + var pid = this.RequireCurrentPlayerId(); + var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id); + if (photo is null) return NotFound(); + if (photo.UploaderPlayerId != pid) return Forbid(); + photo.IsPublic = true; + await db.SaveChangesAsync(); + return Ok(BuildImageInfo(photo, pid)); + } + + /// DELETE api/images/v{2-5}/{id} — uploader (or admin) + /// soft-deletes a saved photo. + [HttpDelete("api/images/v2/{id:long}")] + [HttpDelete("api/images/v3/{id:long}")] + [HttpDelete("api/images/v4/{id:long}")] + [HttpDelete("api/images/v5/{id:long}")] + [Authorize] + public async Task Delete(long id) + { + var pid = this.RequireCurrentPlayerId(); + var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id); + if (photo is null) return NotFound(); + if (photo.UploaderPlayerId != pid) + { + var isAdmin = await db.Players.Where(x => x.Id == pid).Select(x => x.IsAdmin).FirstOrDefaultAsync(); + if (!isAdmin) return Forbid(); + } + photo.DeletedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(new { deleted = id }); + } + + // ── Legacy v1 ops (real persistence, was Ack-only stubs) ───────── + + public sealed class CheerImageRequest + { + public long ImageId { get; set; } + public long PhotoId { get; set; } + } + + /// POST api/images/v1/cheer — cheer a photo. + /// Was previously Ack-only; now persists a CheerEntity row + /// keyed on TargetPhotoId + bumps Photos.CheerCount. + /// Idempotent per (player, photo). + [HttpPost("api/images/v1/cheer")] + [Authorize] + public async Task CheerV1([FromBody] CheerImageRequest? body, + [FromForm(Name = "ImageId")] long? imageIdForm, + [FromForm(Name = "PhotoId")] long? photoIdForm) + { + var pid = this.RequireCurrentPlayerId(); + var photoId = body?.PhotoId ?? body?.ImageId + ?? photoIdForm ?? imageIdForm ?? 0; + if (photoId <= 0) return BadRequest(new { error = "missing photoId" }); + + var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId && p.DeletedAt == null); + if (photo is null) return NotFound(); + + var existing = await db.Cheers.FirstOrDefaultAsync(c => + c.FromPlayerId == pid && c.TargetPhotoId == photoId); + if (existing is null) + { + db.Cheers.Add(new CheerEntity + { + FromPlayerId = pid, + TargetPhotoId = photoId, + CheeredAt = DateTime.UtcNow, + }); + photo.CheerCount += 1; + await db.SaveChangesAsync(); + } + return Ok(new { photo.Id, photo.CheerCount }); + } + + /// GET api/images/v1/listsaved — the 2020 client + /// deserializes this as SavedImagesListDTO { Images: string[] }. + /// Do not return the richer v2/v4 photo-object list here. + [HttpGet("api/images/v1/listsaved")] + [Authorize] + public async Task ListSavedV1() + { + var pid = this.RequireCurrentPlayerId(); + var names = await db.Photos + .Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null) + .OrderByDescending(p => p.CreatedAt) + .Select(p => p.BlobName) + .Take(200) + .ToListAsync(); + return Ok(new { Images = names }); + } + + public sealed class ModifyAccessibilityRequest + { + public long PhotoId { get; set; } + public long ImageId { get; set; } + public string? ImageName { get; set; } + public bool IsPublic { get; set; } + } + + /// POST api/images/v1/modifyaccessibility — flip + /// the IsPublic flag on a saved photo. Was Ack-only; now writes + /// the column. + [HttpPost("api/images/v1/modifyaccessibility")] + [Authorize] + public async Task ModifyAccessibility( + [FromBody] ModifyAccessibilityRequest? body, + [FromForm(Name = "PhotoId")] long? photoIdForm, + [FromForm(Name = "ImageId")] long? imageIdForm, + [FromForm(Name = "ImageName")] string? imageNameForm, + [FromForm(Name = "IsPublic")] bool? isPublicForm) + { + var pid = this.RequireCurrentPlayerId(); + var photoId = body?.PhotoId ?? body?.ImageId + ?? photoIdForm ?? imageIdForm ?? 0; + var imageName = body?.ImageName ?? imageNameForm; + if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName)) + return BadRequest(new { error = "missing photoId" }); + var isPublic = body?.IsPublic ?? isPublicForm ?? true; + + var photo = photoId > 0 + ? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId) + : await db.Photos.FirstOrDefaultAsync(p => p.BlobName == imageName); + if (photo is null) return NotFound(); + if (photo.UploaderPlayerId != pid) return Forbid(); + photo.IsPublic = isPublic; + await db.SaveChangesAsync(); + return Ok(new { photo.Id, photo.IsPublic }); + } + + public sealed class DeleteSavedRequest + { + public long PhotoId { get; set; } + public long ImageId { get; set; } + public string? ImageName { get; set; } + } + + /// POST api/images/v1/deletesaved + DELETE alias — + /// soft-delete a saved photo. Was Ack-only; now flips + /// DeletedAt. + [HttpPost("api/images/v1/deletesaved")] + [HttpDelete("api/images/v1/deletesaved/{id:long}")] + [Authorize] + public async Task DeleteSavedV1( + long? id, + [FromBody] DeleteSavedRequest? body, + [FromForm(Name = "PhotoId")] long? photoIdForm, + [FromForm(Name = "ImageId")] long? imageIdForm, + [FromForm(Name = "ImageName")] string? imageNameForm) + { + var photoId = id ?? body?.PhotoId ?? body?.ImageId + ?? photoIdForm ?? imageIdForm ?? 0; + var imageName = body?.ImageName ?? imageNameForm; + if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName)) + return BadRequest(new { error = "missing photoId" }); + if (photoId <= 0) + { + var pid = this.RequireCurrentPlayerId(); + var photo = await db.Photos.FirstOrDefaultAsync(p => + p.UploaderPlayerId == pid && p.BlobName == imageName && p.DeletedAt == null); + if (photo is null) return NotFound(); + photo.DeletedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + return await Delete(photoId); + } + + public sealed class SendLinkRequest + { + public long PhotoId { get; set; } + public long ImageId { get; set; } + public List? RecipientIds { get; set; } + } + + /// POST api/images/v1/sendlink — send a link to a + /// photo as a DM to one or more recipients. Was Ack-only; now + /// inserts one per recipient. + [HttpPost("api/images/v1/sendlink")] + [Authorize] + public async Task SendLink( + [FromBody] SendLinkRequest? body, + [FromForm(Name = "PhotoId")] long? photoIdForm, + [FromForm(Name = "ImageId")] long? imageIdForm, + [FromForm(Name = "RecipientIds")] string? recipientIdsForm) + { + var pid = this.RequireCurrentPlayerId(); + var photoId = body?.PhotoId ?? body?.ImageId ?? photoIdForm ?? imageIdForm ?? 0; + if (photoId <= 0) return BadRequest(new { error = "missing photoId" }); + var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId); + if (photo is null) return NotFound(); + + var recipients = body?.RecipientIds ?? ParseIdList(recipientIdsForm) ?? new(); + if (recipients.Count == 0) return Ok(new { Sent = 0 }); + + var preview = $"shared a photo (id {photoId})"; + foreach (var rid in recipients.Distinct()) + { + db.Messages.Add(new MessageEntity + { + SenderPlayerId = pid, + RecipientPlayerId = rid, + Body = $"{preview} → /img/{photo.BlobName}", + }); + } + await db.SaveChangesAsync(); + return Ok(new { Sent = recipients.Count }); + } + + private static List? ParseIdList(string? csv) + { + if (string.IsNullOrWhiteSpace(csv)) return null; + return csv.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => long.TryParse(s.Trim(), out var v) ? v : 0L) + .Where(v => v > 0) + .ToList(); + } + + // ── Helpers ────────────────────────────────────────────────────── + + /// Read raw image bytes from the request — supports both + /// multipart/form-data (the typical case, with field "File" or + /// "Image") and a raw octet-stream body (some 2020 client paths). + private async Task ReadImageBytesAsync() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + // Try common field names; fall back to the first uploaded file. + var file = form.Files["File"] + ?? form.Files["Image"] + ?? form.Files["image"] + ?? form.Files["file"] + ?? form.Files.FirstOrDefault(); + if (file is null || file.Length == 0) return null; + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + return ms.ToArray(); + } + + // Raw body fallback — when the watch posts the bytes directly. + if (Request.ContentLength is > 0) + { + using var ms = new MemoryStream(); + await Request.Body.CopyToAsync(ms); + return ms.ToArray(); + } + return null; + } + + /// Detect PNG vs JPEG by magic bytes — anything else + /// defaults to .png since the 2020 client's camera always saves + /// PNG. + private static string SniffExtension(byte[] bytes) + { + if (bytes.Length >= 4 && + bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) + return "png"; + if (bytes.Length >= 3 && + bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) + return "jpg"; + return "png"; + } + + private async Task<(byte[]? Bytes, string Source)> LoadImageBytesAsync(string fileName) + { + var (bucket, key) = BlobRouter.Route(fileName); + try + { + using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var s3Bytes = await storage.GetAsync(bucket, key, s3Timeout.Token); + if (s3Bytes is { Length: > 0 }) + return (s3Bytes, $"s3:{bucket}/{key}"); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "[images] API image S3 lookup threw for {File} bucket={Bucket} key={Key}", + fileName, bucket, key); + } + + // S3 is the only canonical store. Disk-on-server is a legacy + // surface used by tools/fetch-room-images.py'd thumbnails that + // ship with the server — keep that as a last resort, but DB + // bytes are gone. + var full = Path.Combine(ImageDir, fileName); + if (System.IO.File.Exists(full)) + return (await System.IO.File.ReadAllBytesAsync(full), "disk"); + + return (null, "miss"); + } + + + private static bool TryGetPlayerId(string fileName, string prefix, out long playerId) + { + playerId = 0; + if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false; + var start = prefix.Length; + var end = fileName.IndexOf('_', start); + return end > start && long.TryParse(fileName[start..end], out playerId); + } + + private static bool IsImageName(string name) + { + var ext = Path.GetExtension(name).ToLowerInvariant(); + return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp" or ".avif"; + } + + private static string MimeFromName(string name) + { + var ext = Path.GetExtension(name).ToLowerInvariant(); + return ext switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".avif" => "image/avif", + _ => "image/png", + }; + } + + private void LogUploadForm(string endpoint, long playerId, IFormCollection? form, bool isProfile) + { + if (form is null) + { + logger.LogInformation( + "[images] {Endpoint} metadata player={PlayerId} form=false profile={Profile}", + endpoint, + playerId, + isProfile); + return; + } + + var fileNames = string.Join( + ",", + form.Files.Select(f => $"{f.Name}:{f.FileName}:{f.Length}")); + var fieldNames = string.Join(",", form.Keys); + var meta = form["imgMeta"].ToString(); + if (string.IsNullOrWhiteSpace(meta)) + meta = form["imageMeta"].ToString(); + if (meta.Length > 512) + meta = meta[..512]; + + logger.LogInformation( + "[images] {Endpoint} metadata player={PlayerId} files={Files} fields={Fields} profile={Profile} imgMeta={ImgMeta}", + endpoint, + playerId, + fileNames, + fieldNames, + isProfile, + meta); + } + + private static bool IsProfileThumbnail(IFormCollection? form) + { + if (form is null) return false; + + static bool IsProfileValue(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + return value.Trim().Equals("4", StringComparison.OrdinalIgnoreCase) + || value.Contains("ProfileThumbnail", StringComparison.OrdinalIgnoreCase); + } + + if (IsProfileValue(form["SavedImageType"].ToString()) + || IsProfileValue(form["savedImageType"].ToString()) + || IsProfileValue(form["ImageType"].ToString()) + || IsProfileValue(form["imageType"].ToString())) + return true; + + var meta = form["imgMeta"].ToString(); + if (string.IsNullOrWhiteSpace(meta)) + meta = form["imageMeta"].ToString(); + if (string.IsNullOrWhiteSpace(meta)) + return false; + + if (IsProfileValue(meta)) + return true; + + try + { + using var doc = JsonDocument.Parse(meta); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return false; + foreach (var name in new[] { "savedImageType", "SavedImageType", "imageType", "ImageType" }) + { + if (!doc.RootElement.TryGetProperty(name, out var value)) + continue; + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n) && n == 4) + return true; + if (value.ValueKind == JsonValueKind.String && IsProfileValue(value.GetString())) + return true; + } + } + catch (JsonException) + { + } + + return false; + } + + /// Build the response shape every images/v* endpoint + /// returns. PascalCase keys + redundant aliases so whichever JSON + /// parser the 2020 watch uses, it finds the field it wants. The + /// most important keys are AccountId, ImageName, CreatedAt — those + /// are what the watch's strict deserialiser reads. Url + /// is built against the configured deployment apex (DORKNET_DOMAIN) + /// so every callsite for the URL string flows through one source + /// of truth. + private object BuildImageInfo(PhotoEntity p, long accountId) + { + var cdnHost = domain.Sub("cdn"); + return new + { + // Primary keys the 2020 watch's ImageInfo deserialiser reads. + ImageName = p.BlobName, + AccountId = (int)accountId, + ImageType = 0, + CreatedAt = p.CreatedAt, + // Bonus fields — useful for other clients (and our own feed UI). + Id = p.Id, + Filename = p.BlobName, + Url = $"https://{cdnHost}/{p.BlobName}", + Caption = p.Caption, + Description = p.Caption, + RoomId = p.RoomId, + IsPublic = p.IsPublic, + CheerCount = p.CheerCount, + ViewCount = p.ViewCount, + TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(), + }; + } + + private static IEnumerable ParseTagged(string? csv) => + string.IsNullOrWhiteSpace(csv) + ? Enumerable.Empty() + : csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => long.TryParse(s, out var v) ? v : 0L) + .Where(v => v > 0); + + private Task NotifyAccountImageChangedAsync(PlayerEntity player) + { + var account = new DorkNet.Models.Auth.RecNetAccount + { + AccountId = (int)player.Id, + RawUsername = player.Username, + Username = player.Username, + DisplayName = player.DisplayName ?? player.Username, + ProfileImage = player.ProfileImageName ?? string.Empty, + TreatAsJunior = player.IsJunior, + HasBirthday = true, + Platforms = 1, + }; + var selfAccount = new DorkNet.Models.Auth.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, + Email = player.Email ?? string.Empty, + Phone = player.Phone ?? string.Empty, + Birthday = player.Birthday, + JuniorState = player.IsJunior ? 1 : 0, + ParentAccountId = null, + }; + return Task.WhenAll( + notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account), + notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount)); + } + + private static string NormaliseTaggedIds(string? csv, long exceptId) + { + if (string.IsNullOrWhiteSpace(csv)) return string.Empty; + var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => long.TryParse(s, out var v) ? v : 0L) + .Where(v => v > 0 && v != exceptId) + .Distinct() + .ToList(); + return string.Join(",", ids); + } +} diff --git a/DorkNet.Server/Controllers/API/Images/V2/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/V2/ImagesController.cs new file mode 100644 index 0000000..0b7d4c3 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Images/V2/ImagesController.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace DorkNet.Server.Controllers.API.Images.V2; + +[ApiController] +[Route("api/[controller]/v2")] +[Authorize] +public class ImagesController : ControllerBase +{ + private static readonly string ImageDir = + Path.Combine(AppContext.BaseDirectory, "data", "images"); + + [HttpPost("upload")] + public async Task> Upload(IFormFile file) + { + Directory.CreateDirectory(ImageDir); + + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (ext is not (".png" or ".jpg" or ".jpeg")) + return BadRequest("Only PNG/JPG accepted"); + + var name = $"{Guid.NewGuid():N}{ext}"; + var path = Path.Combine(ImageDir, name); + + await using var stream = System.IO.File.Create(path); + await file.CopyToAsync(stream); + + var baseUrl = $"{Request.Scheme}://{Request.Host}"; + return Ok(new UploadImageResponse { ImageName = name, Url = $"{baseUrl}/img/{name}" }); + } +} + +public class UploadImageResponse +{ + public string ImageName { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; +} diff --git a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs new file mode 100644 index 0000000..4b671ea --- /dev/null +++ b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs @@ -0,0 +1,549 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Inventions; + +/// +/// api.rec.net/api/inventions/* — Maker-Pen creations the player can +/// re-spawn or share. URL surface + verbs verified against +/// Cpp2IL_ISIL/.../RecNet/Inventions.txt: the "update*" +/// routes use Core.Get (GET) despite being mutations, so we +/// expose them as [HttpGet]; only cheer, report, +/// settags, save, addversion, and batch +/// are POST. delete, publish, unpublish, +/// download are also GET. +/// +/// Wire DTO matches RecNet.Invention +/// (Cpp2IL_CS/.../RecNet/Invention.cs) byte-for-byte: keys +/// InventionId, ReplicationId, CreatorPlayerId, Name, Description, +/// ImageName, CurrentVersionNumber, IsPublished, ModifiedAt, +/// CreatedAt, FirstPublishedAt, CreationRoomId, +/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount, +/// CreatorPermission, GeneralPermission, IsAgInvention. +/// +[ApiController] +public class InventionsController( + DorkNetDbContext db, + LevelService level, + NotificationService notifications) : ControllerBase +{ + private long? CurrentPlayerIdOrNull => this.CurrentPlayerId(); + private long CurrentPlayerId => this.RequireCurrentPlayerId(); + + // ── Browse ─────────────────────────────────────────────────────────── + + [HttpGet("api/inventions/v3/popular")] + public async Task Popular([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var rows = await db.Inventions + .Where(i => !i.IsDeleted && i.IsPublished) + .OrderByDescending(i => i.CheerCount) + .Take(take) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpGet("api/inventions/v3/saved")] + [Authorize] + public async Task Saved([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var pid = CurrentPlayerId; + var rows = await db.Inventions + .Where(i => !i.IsDeleted && i.CreatorPlayerId == pid) + .OrderByDescending(i => i.UpdatedAt) + .Take(take) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [HttpGet("api/inventions/v3/created")] + [Authorize] + public Task Created([FromQuery] int take = 50) => Saved(take); + + /// GetLocalPlayerInventions — same as Saved but + /// at v1 path. Watch hits both depending on tab. + [HttpGet("api/inventions/v1/mine")] + [Authorize] + public Task Mine() => Saved(100); + + [HttpGet("api/inventions/v1/search")] + public async Task Search([FromQuery] string value = "") + { + if (string.IsNullOrWhiteSpace(value)) + return Ok(Array.Empty()); + + // The watch's @-prefix means "search by player". Two callers + // use this with different payload shapes — both end up here: + // * PlayerDetailsWatchUIFlow.SetAccount → @ (numeric) + // loads the Inventions tab on a player profile. Verified + // in Cpp2IL — see PlayerDetailsWatchUIFlow.txt:1867,1950. + // * Inventions search UI → @ (text) free-text + // prefix match on Username. + if (value.StartsWith('@')) + { + var tail = value[1..]; + List creators; + if (long.TryParse(tail, out var accountId)) + { + creators = new List { accountId }; + } + else + { + creators = await db.Players + .Where(p => p.Username.StartsWith(tail)) + .Select(p => p.Id).Take(50).ToListAsync(); + } + var byCreator = await db.Inventions + .Where(i => !i.IsDeleted && i.IsPublished + && creators.Contains(i.CreatorPlayerId)) + .OrderByDescending(i => i.CheerCount) + .Take(50) + .ToListAsync(); + return Ok(byCreator.Select(ToWire)); + } + + var v = value.ToLowerInvariant(); + var rows = await db.Inventions + .Where(i => !i.IsDeleted && i.IsPublished + && (i.Name.ToLower().Contains(v) + || i.TagsCsv.ToLower().Contains(v))) + .OrderByDescending(i => i.CheerCount) + .Take(50) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + public sealed record InventionBatchRequest(List InventionIds); + + [HttpPost("api/inventions/v1/batch")] + public async Task Batch([FromBody] InventionBatchRequest req) + { + if (req?.InventionIds is null || req.InventionIds.Count == 0) + return Ok(Array.Empty()); + var ids = req.InventionIds.Take(200).ToList(); + var rows = await db.Inventions + .Where(i => !i.IsDeleted && ids.Contains(i.Id)) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + /// GET api/inventions/v1/tagfilters — drives + /// RecNet.Inventions.GetTags. The watch deserialises the + /// response as RecNet.GetFiltersResponse (an + /// IRecNetObject with PinnedFilters + + /// PopularFilters string-list properties), then a + /// Func<GetFiltersResponse, List<string>> + /// projects whichever list the caller wanted. Verified at + /// Cpp2IL_ISIL/.../RecNet/Inventions.txt:4834-4852 — the URL + /// literal is followed by a typeof(Func`2<GetFiltersResponse, + /// List`1<String>>). Returning a bare string array + /// (the previous shape) tripped the LitJson importer's + /// List → Dictionary cast inside Util.Deserialize<T> + /// and surfaced as "Failed to get tags: Malformed Response" + /// (output_log.txt:1602+1639) — and that bubbles up as the + /// dorm-load destabilisation we've been hunting. + [HttpGet("api/inventions/v1/tagfilters")] + public IActionResult TagFilters() + { + var tags = new[] + { + "sport", "game", "vehicle", "weapon", "decor", "tool", + "art", "music", "puzzle", "combat", "build", "race", + }; + return Ok(new + { + PinnedFilters = tags, + PopularFilters = tags, + }); + } + + [HttpGet("api/inventions/v1/creatorIds")] + public IActionResult CreatorIdsInRoom([FromQuery] long roomId) + { + // We don't yet track per-room invention placements; return + // empty list (correct shape: List). + _ = roomId; + return Ok(Array.Empty()); + } + + // ── Single fetch ───────────────────────────────────────────────────── + + [HttpGet("api/inventions/v1")] + public async Task SingleByQuery([FromQuery] long inventionId) + { + var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); + if (i is null) return NotFound(); + if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull) + return Forbid(); + return Ok(ToWire(i)); + } + + [HttpGet("api/inventions/v1/details")] + public async Task Details([FromQuery] long inventionId) + { + var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); + if (i is null) return NotFound(); + if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull) + return Forbid(); + var versions = await db.InventionVersions + .Where(v => v.InventionId == inventionId) + .OrderByDescending(v => v.VersionNumber) + .ToListAsync(); + return Ok(new + { + Invention = ToWire(i), + Versions = versions.Select(ToVersionWire), + }); + } + + [HttpGet("api/inventions/v1/personaldetails/{id:long}")] + [Authorize] + public async Task PersonalDetails(long id) + { + var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted); + if (i is null) return NotFound(); + return Ok(new + { + Invention = ToWire(i), + CanEdit = i.CreatorPlayerId == CurrentPlayerId, + }); + } + + [HttpGet("api/inventions/v1/versions")] + public async Task Versions([FromQuery] long inventionId) + { + var rows = await db.InventionVersions + .Where(v => v.InventionId == inventionId) + .OrderByDescending(v => v.VersionNumber) + .ToListAsync(); + return Ok(rows.Select(ToVersionWire)); + } + + [HttpGet("api/inventions/v1/version")] + public async Task Version( + [FromQuery] long inventionId, [FromQuery] int version) + { + var v = await db.InventionVersions + .FirstOrDefaultAsync(x => x.InventionId == inventionId && x.VersionNumber == version); + if (v is null) return NotFound(); + return Ok(ToVersionWire(v)); + } + + // ── Create / version ───────────────────────────────────────────────── + + public sealed record SaveInventionRequest( + string Name, string? Description, string? ImageName, + int? Permission, string? Tags, string BlobName, + long? CreationRoomId, int? InstantiationCost, int? LightsCost); + + [HttpPost("api/inventions/v3/save")] + [Authorize] + public async Task Save([FromBody] SaveInventionRequest req) + { + var pid = CurrentPlayerId; + if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("missing name"); + if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName"); + + var inv = new InventionEntity + { + CreatorPlayerId = pid, + ReplicationId = Guid.NewGuid().ToString("D"), + Name = req.Name.Trim(), + Description = req.Description ?? string.Empty, + ImageName = req.ImageName ?? string.Empty, + Permission = Math.Clamp(req.Permission ?? 0, 0, 2), + CurrentBlobName = req.BlobName, + TagsCsv = req.Tags ?? string.Empty, + CreationRoomId = req.CreationRoomId, + CreatorPermission = 100, // Unlimited for the creator + GeneralPermission = 0, // Unassigned (private) until publish + IsPublished = false, + CurrentVersionNumber = 1, + }; + db.Inventions.Add(inv); + await db.SaveChangesAsync(); + + // Snapshot the v1 version row. + var v1 = new InventionVersionEntity + { + InventionId = inv.Id, + ReplicationId = Guid.NewGuid().ToString("D"), + VersionNumber = 1, + BlobName = req.BlobName, + InstantiationCost = req.InstantiationCost ?? 0, + LightsCost = req.LightsCost ?? 0, + }; + db.InventionVersions.Add(v1); + await db.SaveChangesAsync(); + + await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}"); + return Ok(ToWire(inv)); + } + + public sealed record AddVersionRequest( + long InventionId, string BlobName, + int? InstantiationCost, int? LightsCost); + + [HttpPost("api/inventions/v3/addversion")] + [Authorize] + public async Task AddVersion([FromBody] AddVersionRequest req) + { + var pid = CurrentPlayerId; + var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted); + if (inv is null) return NotFound(); + if (inv.CreatorPlayerId != pid) return Forbid(); + if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName"); + + var nextVer = inv.CurrentVersionNumber + 1; + inv.CurrentBlobName = req.BlobName; + inv.CurrentVersionNumber = nextVer; + inv.UpdatedAt = DateTime.UtcNow; + + db.InventionVersions.Add(new InventionVersionEntity + { + InventionId = inv.Id, + ReplicationId = Guid.NewGuid().ToString("D"), + VersionNumber = nextVer, + BlobName = req.BlobName, + InstantiationCost = req.InstantiationCost ?? 0, + LightsCost = req.LightsCost ?? 0, + }); + await db.SaveChangesAsync(); + return Ok(ToWire(inv)); + } + + // ── GET-based mutations (Core.Get pattern) ─────────────────────────── + + /// Watch sends ONE of name/description/imgName/permission + /// per request — `UpdateInvention*` ISIL builds different + /// query strings. Pull whichever ones came in and apply. + [HttpGet("api/inventions/v1/update")] + [Authorize] + public async Task Update( + [FromQuery] long inventionId, + [FromQuery] string? name, + [FromQuery] string? description, + [FromQuery] string? imgName, + [FromQuery] int? permission) + { + var pid = CurrentPlayerId; + var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); + if (inv is null) return NotFound(); + if (inv.CreatorPlayerId != pid) return Forbid(); + + if (!string.IsNullOrWhiteSpace(name)) inv.Name = name.Trim(); + if (description is not null) inv.Description = description; + if (imgName is not null) inv.ImageName = imgName; + if (permission.HasValue) + { + inv.GeneralPermission = ClampInventionPermission(permission.Value); + if (inv.GeneralPermission > 0 && !inv.IsPublished) + { + inv.IsPublished = true; + inv.FirstPublishedAt = DateTime.UtcNow; + } + // Mirror legacy 0/1/2 column for back-compat. + inv.Permission = inv.GeneralPermission >= 60 ? 2 : (inv.GeneralPermission >= 20 ? 1 : 0); + } + inv.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(inv)); + } + + [HttpGet("api/inventions/v1/delete")] + [Authorize] + public async Task Delete([FromQuery] long inventionId) + { + var pid = CurrentPlayerId; + var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId); + if (inv is null) return NotFound(); + if (inv.CreatorPlayerId != pid) return Forbid(); + inv.IsDeleted = true; + inv.GeneralPermission = 0; + inv.IsPublished = false; + inv.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(inv)); + } + + [HttpGet("api/inventions/v2/publish")] + [Authorize] + public async Task Publish( + [FromQuery] long inventionId, [FromQuery] int permissionLevel) + { + var pid = CurrentPlayerId; + var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); + if (inv is null) return NotFound(); + if (inv.CreatorPlayerId != pid) return Forbid(); + var perm = ClampInventionPermission(permissionLevel); + if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)"); + inv.GeneralPermission = perm; + inv.IsPublished = true; + inv.FirstPublishedAt ??= DateTime.UtcNow; + inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0); + inv.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(inv)); + } + + [HttpGet("api/inventions/v1/unpublish")] + [Authorize] + public async Task Unpublish([FromQuery] long inventionId) + { + var pid = CurrentPlayerId; + var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); + if (inv is null) return NotFound(); + if (inv.CreatorPlayerId != pid) return Forbid(); + inv.GeneralPermission = 0; + inv.IsPublished = false; + inv.Permission = 0; + inv.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(inv)); + } + + [HttpGet("api/inventions/v1/download")] + public async Task Download([FromQuery] long inventionId) + { + var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); + if (inv is null) return NotFound(); + inv.SpawnCount += 1; + var pid = CurrentPlayerIdOrNull; + if (pid is long me && me != inv.CreatorPlayerId) + { + // NumPlayersHaveUsedInRoom — increment per-distinct-player. + // Approximate by distinct cheer/check; for now bump once + // per download by non-creator. + inv.NumPlayersHaveUsedInRoom += 1; + } + inv.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(inv)); + } + + // ── POST mutations ─────────────────────────────────────────────────── + + public sealed record SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags); + + [HttpPost("api/inventions/v1/settags")] + [Authorize] + public async Task SetTags([FromBody] SetTagsRequest req) + { + var pid = CurrentPlayerId; + var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted); + if (inv is null) return NotFound(); + if (inv.CreatorPlayerId != pid) return Forbid(); + var combined = string.Join(',', new[] { req.AutoTags, req.PlayerAddedTags } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(','); + inv.TagsCsv = combined; + inv.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(inv)); + } + + public sealed record CheerRequest(long InventionId); + + [HttpPost("api/inventions/v1/cheer")] + [Authorize] + public async Task Cheer([FromBody] CheerRequest req) + { + var pid = CurrentPlayerId; + var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted); + if (inv is null) return NotFound(); + + // Idempotent: one cheer per (player, invention). + var existing = await db.Cheers.FirstOrDefaultAsync(c => + c.FromPlayerId == pid && c.TargetInventionId == req.InventionId); + if (existing is null) + { + db.Cheers.Add(new CheerEntity + { + FromPlayerId = pid, + TargetInventionId = req.InventionId, + CheeredAt = DateTime.UtcNow, + }); + inv.CheerCount += 1; + await db.SaveChangesAsync(); + + if (inv.CreatorPlayerId != pid) + await notifications.NotifyAsync(inv.CreatorPlayerId, + PushNotificationId.InventionModerationStateChanged, + new { Type = "cheer", inv.Id, From = pid, inv.CheerCount }); + } + return Ok(new { inv.Id, inv.CheerCount }); + } + + public sealed record ReportInventionRequest( + long InventionId, int? ReportCategory, string? Details); + + [HttpPost("api/inventions/v1/report")] + [Authorize] + public async Task Report([FromBody] ReportInventionRequest req) + { + var pid = CurrentPlayerId; + var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted); + if (inv is null) return NotFound(); + + db.Reports.Add(new ReportEntity + { + ReporterPlayerId = pid, + TargetPlayerId = inv.CreatorPlayerId, + TargetInventionId = req.InventionId, + Category = req.ReportCategory ?? 5 /* Other */, + Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)], + }); + await db.SaveChangesAsync(); + return Ok(new { Reported = true }); + } + + // ── Wire serializers ───────────────────────────────────────────────── + + private static int ClampInventionPermission(int v) => v switch + { + 0 or 10 or 20 or 40 or 60 or 80 or 100 => v, + _ => 0, + }; + + private static object ToWire(InventionEntity i) => new + { + InventionId = i.Id, + ReplicationId = string.IsNullOrEmpty(i.ReplicationId) + ? Guid.Empty.ToString("D") : i.ReplicationId, + // Wire field is int — IDs in this server are <2^31 so safe. + CreatorPlayerId = (int)i.CreatorPlayerId, + i.Name, + i.Description, + i.ImageName, + i.CurrentVersionNumber, + i.IsPublished, + ModifiedAt = i.UpdatedAt, + i.CreatedAt, + i.FirstPublishedAt, + i.CreationRoomId, + i.NumPlayersHaveUsedInRoom, + NumDownloads = i.SpawnCount, + i.CheerCount, + i.CreatorPermission, + i.GeneralPermission, + i.IsAgInvention, + }; + + private static object ToVersionWire(InventionVersionEntity v) => new + { + v.InventionId, + ReplicationId = string.IsNullOrEmpty(v.ReplicationId) + ? Guid.Empty.ToString("D") : v.ReplicationId, + v.VersionNumber, + v.InstantiationCost, + v.LightsCost, + v.BlobName, + }; +} diff --git a/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs new file mode 100644 index 0000000..4ce2f94 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs @@ -0,0 +1,204 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Inventory; + +/// +/// api.rec.net/api/{equipment,consumables}/* — backs the watch's +/// Backpack tab. Wire DTOs match the decompiled client classes: +/// +/// • RecNet.Equipment — fields: PrefabName, ModificationGuid, +/// PlatformMask, IsPlatformLocked, FriendlyName, Tooltip, Rarity, +/// Favorited. +/// • RecNet.Consumable — fields: Id(long), ConsumableType, +/// PlatformMask, Count, InitialCount, UnlockedLevel, CreatedAt, +/// ActiveDurationMinutes(int?), ConsumableItem(nested), Category, +/// IsActive, IsPlatformLocked. +/// +/// Persistence: one row per +/// (player, item). For equipment, Quantity=1 + IsActive tracks +/// favourite. For consumables, Quantity is the stack count + IsActive +/// tracks "currently activated". +/// +[ApiController] +[Authorize] +public class InventoryController( + DorkNetDbContext db, + NotificationService notifications) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + // Categories the StoreItem table uses for equipment vs + // consumables. The seed data tags items per these strings. + private static readonly HashSet EquipmentCategories = new() + { + "equipment", "tool", "maker_pen", "weapon", + }; + private static readonly HashSet ConsumableCategories = new() + { + "consumable", + }; + + // ── Equipment ──────────────────────────────────────────────────────── + + [HttpGet("api/equipment/v1/getUnlocked")] + [HttpGet("api/equipment/v2/getUnlocked")] + [HttpGet("api/equipment/v3/getUnlocked")] + public async Task GetUnlockedEquipment() + { + var pid = Me; + var rows = await (from inv in db.PlayerInventory + join item in db.StoreItems on inv.ItemSlug equals item.Slug + where inv.PlayerId == pid + && EquipmentCategories.Contains(item.Category) + select new { inv, item }).ToListAsync(); + return Ok(rows.Select(r => ToEquipmentWire(r.inv, r.item))); + } + + public sealed class EquipmentUpdateRequest + { + public string PrefabName { get; set; } = string.Empty; + public string ModificationGuid { get; set; } = string.Empty; + public bool Favorited { get; set; } + } + + /// POST api/equipment/v1/update — toggle the + /// "favorited" flag on a piece of equipment (the watch lets the + /// player pin commonly-used tools). + [HttpPost("api/equipment/v1/update")] + public async Task UpdateEquipment([FromBody] EquipmentUpdateRequest req) + { + var pid = Me; + // The slug for equipment is the modification GUID (or prefab + // name fallback when no mod is applied). + var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid) + ? req.ModificationGuid + : req.PrefabName; + if (string.IsNullOrWhiteSpace(slug)) return BadRequest("missing prefab/mod identifier"); + + var row = await db.PlayerInventory + .FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug); + if (row is null) + { + // Player doesn't own this; reject rather than silently + // creating ownership (avoids letting clients grant items + // to themselves). + return Forbid(); + } + row.IsActive = req.Favorited; + await db.SaveChangesAsync(); + return Ok(new { Updated = true }); + } + + // ── Consumables ────────────────────────────────────────────────────── + + [HttpGet("api/consumables/v1/getUnlocked")] + public async Task GetUnlockedConsumables() + { + var pid = Me; + var rows = await (from inv in db.PlayerInventory + join item in db.StoreItems on inv.ItemSlug equals item.Slug + where inv.PlayerId == pid + && ConsumableCategories.Contains(item.Category) + select new { inv, item }).ToListAsync(); + return Ok(rows.Select(r => ToConsumableWire(r.inv, r.item))); + } + + public sealed class ConsumableItemRequest + { + public long Id { get; set; } + public int DeltaCount { get; set; } + } + + [HttpPost("api/consumables/v1/consume")] + public async Task Consume([FromBody] ConsumableItemRequest req) + { + var pid = Me; + var row = await db.PlayerInventory + .FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id); + if (row is null) return NotFound(); + var delta = Math.Max(1, Math.Abs(req.DeltaCount)); + row.Quantity = Math.Max(0, row.Quantity - delta); + if (row.Quantity == 0) db.PlayerInventory.Remove(row); + await db.SaveChangesAsync(); + + await notifications.NotifyAsync(pid, + row.Quantity > 0 + ? PushNotificationId.ConsumableMappingAdded + : PushNotificationId.ConsumableMappingRemoved, + new { row.Id, row.ItemSlug, row.Quantity }); + return Ok(new { row.Id, Count = row.Quantity }); + } + + public sealed class ActivateConsumableRequest + { + public long Id { get; set; } + public bool IsActive { get; set; } + } + + [HttpPost("api/consumables/v1/updateActive")] + public async Task UpdateActive([FromBody] ActivateConsumableRequest req) + { + var pid = Me; + var row = await db.PlayerInventory + .FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id); + if (row is null) return NotFound(); + row.IsActive = req.IsActive; + await db.SaveChangesAsync(); + return Ok(new { row.Id, row.IsActive }); + } + + // ── Wire serializers ───────────────────────────────────────────────── + + private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item) => new + { + PrefabName = item.Slug, // for store-derived items the slug doubles as prefab name + ModificationGuid = inv.ItemSlug, + PlatformMask = -1, // All + IsPlatformLocked = false, + FriendlyName = item.DisplayName, + Tooltip = item.Description, + Rarity = 0, // Common (no rarity column on StoreItem yet) + Favorited = inv.IsActive, + }; + + private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item) + { + var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc) + ? consumableDesc + : item.Slug; + var category = StoreService.TryGetHairDyePayload(item.Slug, out _) + ? 6 + : 0; + + return new + { + inv.Id, + ConsumableItemDesc = type, + ConsumableType = type, + PlatformMask = -1, + Count = inv.Quantity, + InitialCount = inv.Quantity, // we don't track original purchase count + UnlockedLevel = 0, + CreatedAt = inv.AcquiredAt, + ActiveDurationMinutes = (int?)null, + ConsumableItem = new + { + Id = inv.Id, + Type = type, + FriendlyName = item.DisplayName, + Tooltip = item.Description, + ImageName = item.ImageName, + }, + Category = category, + inv.IsActive, + IsPlatformLocked = false, + }; + } +} diff --git a/DorkNet.Server/Controllers/API/Leaderboard/LeaderboardController.cs b/DorkNet.Server/Controllers/API/Leaderboard/LeaderboardController.cs new file mode 100644 index 0000000..5dc7d2c --- /dev/null +++ b/DorkNet.Server/Controllers/API/Leaderboard/LeaderboardController.cs @@ -0,0 +1,301 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Leaderboard; + +/// +/// leaderboard.{rec.net,localhost}/leaderboard/* — per-stat-channel +/// leaderboard reads + writes. URLs verified against +/// Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:1934, 2416, 2700: +/// +/// POST leaderboard/SetStat body: SetStatRequestDTO +/// {StatChannel(int), RoomId(long), StatValue(int)} +/// POST leaderboard/GetPlayerRank body: GetPlayerRankData +/// {PlayerId(int), StatChannel(int), RoomId(long)} +/// +/// **Subdomain matters**: the client uses +/// Core.SendRequest(method, Service.Leaderboard, requestUri) +/// where Service.Leaderboard = 9 resolves via the +/// RecRoomConfig.ServiceUrls map (we serve via +/// api/config/v2) to https://leaderboard.{apex}. So +/// the actual outbound URL is leaderboard.localhost/leaderboard/SetStat +/// — not api.localhost/api/leaderboard/.... +/// +/// Response shapes: +/// SetStat → RequestResult ack {success:true,error:""} +/// GetPlayerRank → FullLeaderboard.Entry — camelCase per +/// FullLeaderboard_NestedType_Entry.txt:111-121: +/// {playerId(int), score(long), rank(int)}. +/// +/// Without explicit subdomain routing the request fell through to +/// which returns []; +/// the client's Util.Deserialize<Entry> tried to cast +/// list-as-dictionary → +/// InvalidCastException: Unable to cast 'List' to 'Dictionary' +/// → "Failed to retrieve leaderboard: Malformed Response". +/// +/// We bind both the leaderboard subdomain (canonical) AND the api +/// host (defensive — some 2020 builds may proxy via api/) so either +/// path lands here. +/// +[ApiController] +public class LeaderboardController( + DorkNetDbContext db, + PlayerPresenceService playerPresence) : ControllerBase +{ + public sealed class SetStatRequest + { + public int StatChannel { get; set; } + public long RoomId { get; set; } + public int StatValue { get; set; } + } + + /// POST /leaderboard/SetStat on the leaderboard + /// subdomain (canonical client path) AND /api/leaderboard/SetStat + /// on the api host (legacy / proxy fallback). + /// + /// **Wire semantics**: the watch's + /// Leaderboards.ConditionalSetStat_ReturningNewValue + /// (Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:645) handles the + /// per-channel direction (high-score vs low-time-better) LOCALLY + /// via a SetMode enum and only POSTs to SetStat when the + /// new value should replace the cached one. The wire request + /// itself carries no direction hint — it's just + /// {StatChannel, RoomId, StatValue}. So the server must + /// trust the client and assign unconditionally; an earlier + /// Math.Max here silently kept the LARGEST value, which + /// broke channels like StuntRunner (channel 7, low elapsed time + /// wins) where the watch posts the new best and we'd discard it + /// for any prior worse value. + [HttpPost("/leaderboard/SetStat")] + [HttpPost("/api/leaderboard/SetStat")] + [Authorize] + public async Task SetStat([FromBody] SetStatRequest req) + { + var pid = this.RequireCurrentPlayerId(); + var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => + s.PlayerId == pid && s.StatChannel == req.StatChannel); + if (row is null) + { + row = new LeaderboardStatEntity + { + PlayerId = pid, + StatChannel = req.StatChannel, + Value = req.StatValue, + }; + db.LeaderboardStats.Add(row); + } + else + { + row.Value = req.StatValue; + row.UpdatedAt = DateTime.UtcNow; + } + + // Auto-attach the channel to whichever room the score came + // from so every room implicitly gets its own leaderboards. + // The watch's SetStatRequestDTO carries + // RoomId (see Cpp2IL_ISIL .../SetStatRequestDTO); + // when it's zero (rare — direct stat report outside a room + // context) fall back to the player's current presence so a + // stray score still gets bucketed somewhere visible. + var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0); + if (roomId > 0) + { + var meta = await db.LeaderboardChannelMeta + .FirstOrDefaultAsync(c => c.Channel == req.StatChannel); + if (meta is null) + { + // First time this channel reports. Stamp a meta row so + // the admin SPA's per-room Leaderboards tab surfaces it + // immediately, with a placeholder name the admin can + // rename later. Direction defaults to higher-is-better; + // admin flips the toggle for time-trial channels. + db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity + { + Channel = req.StatChannel, + RoomId = roomId, + Name = $"Channel {req.StatChannel}", + LowerIsBetter = false, + ValueFormat = "count", + }); + } + else if (meta.RoomId == 0) + { + // Channel registered globally (e.g. from the hardcoded + // KnownStatChannels fallback or an unscoped admin + // entry). Bind it to this room now that we know which + // room it came from. Don't re-bind if it already + // points at a different room — the admin must be the + // one to reassign once a channel has a home. + meta.RoomId = roomId; + meta.UpdatedAt = DateTime.UtcNow; + } + } + + await db.SaveChangesAsync(); + return Ok(new { success = true, error = "" }); + } + + public sealed class GetPlayerRankRequest + { + public int PlayerId { get; set; } + public int StatChannel { get; set; } + public long RoomId { get; set; } + } + + [HttpPost("/leaderboard/GetPlayerRank")] + [HttpPost("/api/leaderboard/GetPlayerRank")] + public async Task GetPlayerRank([FromBody] GetPlayerRankRequest req) + { + var pid = req.PlayerId > 0 + ? (long)req.PlayerId + : this.CurrentPlayerId() ?? 0; + // Empty/no-row case: return a zero entry. The client tolerates + // this — `Entry.Deserialize` reads playerId/score/rank as + // primitives with defaults. + if (pid == 0) + return Ok(new { playerId = 0, score = 0L, rank = 0 }); + + var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => + s.PlayerId == pid && s.StatChannel == req.StatChannel); + if (row is null) + return Ok(new { playerId = (int)pid, score = 0L, rank = 0 }); + + var higherCount = await db.LeaderboardStats + .CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value); + return Ok(new + { + playerId = (int)pid, + score = row.Value, + rank = higherCount + 1, + }); + } + + public sealed class GetNearbyScoresRequest + { + public int PlayerId { get; set; } + public int StatChannel { get; set; } + public long RoomId { get; set; } + public int WindowSize { get; set; } + } + + /// POST /leaderboard/GetNearbyScores — returns the + /// N entries on either side of the caller's rank. Wire shape per + /// GetNearbyScoresRequestDTO.cs: PlayerId/StatChannel/RoomId + /// inherited from GetRankRequestDTO + WindowSize. + [HttpPost("/leaderboard/GetNearbyScores")] + [HttpPost("/api/leaderboard/GetNearbyScores")] + public async Task GetNearbyScores([FromBody] GetNearbyScoresRequest req) + { + var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0; + var window = Math.Clamp(req.WindowSize, 1, 50); + + var all = await db.LeaderboardStats + .Where(s => s.StatChannel == req.StatChannel) + .OrderByDescending(s => s.Value) + .Select(s => new { s.PlayerId, s.Value }) + .ToListAsync(); + if (all.Count == 0) + { + // Empty case must still come back as a SingleLeaderboard + // Dictionary, not a bare array — the 2020 watch's + // SingleLeaderboard.Deserialize reads dict["rows"] + // (Cpp2IL_ISIL/.../RecNet/SingleLeaderboard.txt:60). + // Returning `[]` cast-fails as List→Dictionary inside + // RecNet.Util.Deserialize, logged repeatedly as + // "Received malformed RecNet response: '[]'" and + // eventually starves the StuntRunner UI of a usable + // leaderboard. + return Ok(new { rows = Array.Empty() }); + } + + var ranked = all.Select((r, i) => new + { + playerId = (int)r.PlayerId, + score = r.Value, + rank = i + 1, + }).ToList(); + + var myIdx = ranked.FindIndex(r => r.playerId == (int)pid); + if (myIdx < 0) + { + return Ok(new { rows = ranked.Take(window * 2 + 1).ToArray() }); + } + var lo = Math.Max(0, myIdx - window); + var hi = Math.Min(ranked.Count - 1, myIdx + window); + return Ok(new { rows = ranked.GetRange(lo, hi - lo + 1).ToArray() }); + } + + public sealed class GetRanksRequest + { + public int PlayerId { get; set; } + public int StatChannel { get; set; } + public long RoomId { get; set; } + public int RankStart { get; set; } + public int RankEnd { get; set; } + } + + /// POST /leaderboard/GetRanks — returns a slice of + /// the leaderboard between RankStart and RankEnd (inclusive, + /// 1-indexed). Wire shape per GetRanksRequestDTO.cs. + [HttpPost("/leaderboard/GetRanks")] + [HttpPost("/api/leaderboard/GetRanks")] + public async Task GetRanks([FromBody] GetRanksRequest req) + { + var start = Math.Max(1, req.RankStart); + var end = Math.Max(start, req.RankEnd); + var skip = start - 1; + var take = Math.Clamp(end - skip, 1, 200); + + var rows = await db.LeaderboardStats + .Where(s => s.StatChannel == req.StatChannel) + .OrderByDescending(s => s.Value) + .Skip(skip).Take(take) + .ToListAsync(); + var entries = rows.Select((s, i) => new + { + playerId = (int)s.PlayerId, + score = s.Value, + rank = start + i, + }).ToArray(); + // SingleLeaderboard wrapper — see GetNearbyScores above. + return Ok(new { rows = entries }); + } + + /// Top-N for a channel. Used by the admin tooling and + /// by the watch's full-leaderboard scoreboard view. + [HttpGet("/leaderboard/Top")] + [HttpGet("/api/leaderboard/Top")] + public async Task Top([FromQuery] int channel, [FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 200); + var rows = await db.LeaderboardStats + .Where(s => s.StatChannel == channel) + .OrderByDescending(s => s.Value) + .Take(take) + .ToListAsync(); + // FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic / + // FriendsOverall / FriendsPeriodic + NextResetUTC. Use the + // same Entry list for all four buckets — friends-vs-global + // can come later. + var entries = rows.Select((s, i) => new + { + playerId = (int)s.PlayerId, + score = s.Value, + rank = i + 1, + }).ToArray(); + return Ok(new + { + GlobalOverall = entries, + GlobalPeriodic = entries, + FriendsOverall = Array.Empty(), + FriendsPeriodic = Array.Empty(), + NextResetUTC = DateTime.UtcNow.AddDays(7), + }); + } +} diff --git a/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs b/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs new file mode 100644 index 0000000..f1e7e4f --- /dev/null +++ b/DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs @@ -0,0 +1,586 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Messages.V2; + +/// +/// api.rec.net/api/messages/v2/* — direct-message inbox API. Backed +/// by . Sends fan out a SignalR push +/// (MessageReceived) so the recipient's watch refreshes +/// immediately. +/// +[ApiController] +[Route("api/[controller]/v2")] +[Authorize] +public class MessagesController( + DorkNetDbContext db, + NotificationService notifications, + PrivateInstanceService privateInstances, + OnlinePresenceService onlinePresence, + ILogger logger) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + /// List the caller's inbox, newest-first. Response is a + /// flat array of ; the watch groups by + /// sender locally. v1/v3 paths share the same handler — the watch + /// uses whichever URL its RecNet.Messages static helper + /// built from GameConfig.MessageServiceVersion. + [HttpGet("get")] + [HttpGet("/api/messages/v1/get")] + [HttpGet("/api/messages/v3/get")] + [HttpGet("/api/messages/v1/onlineStatus")] + public async Task>> GetMessages([FromQuery] int take = 100) + { + take = Math.Clamp(take, 1, 200); + var rows = await db.Messages + .Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me) + // Defensive filter for the empty-body ghost rows that + // accumulated before SendFormMessage rejected them. The + // watch's MessagesScreenItem renders TextMessage (type 30) + // as "Message : {body}", so an empty body becomes a + // useless "Message :" entry the user has to scroll past. + // Control-channel types (10 RequestGameInvite, 20 + // FriendStatusOnline, etc.) have their own labels and + // legitimately carry no body, so we keep them. + .Where(m => m.Type != 30 || (m.Body != null && m.Body != "")) + .OrderByDescending(m => m.SentAt) + .Take(take) + .ToListAsync(); + return Ok(await ToDtosAsync(rows)); + } + + /// GET api/messages/v1/unread — list of unread + /// messages addressed to the caller. The watch uses the count for + /// the inbox badge and the entries themselves for the toast + /// preview. + [HttpGet("/api/messages/v1/unread")] + public async Task>> Unread() + { + var rows = await db.Messages + .Where(m => m.RecipientPlayerId == Me && m.ReadAt == null) + // Same ghost-row filter as GetMessages above — keep the + // unread badge count honest by hiding empty TextMessages. + .Where(m => m.Type != 30 || (m.Body != null && m.Body != "")) + .OrderByDescending(m => m.SentAt) + .Take(100) + .ToListAsync(); + return Ok(await ToDtosAsync(rows)); + } + + public sealed class DeleteRequest + { + public long? Id { get; set; } + public long? MessageId { get; set; } + public List? MessageIds { get; set; } + } + + /// POST api/messages/v3/delete — hard-delete one or + /// more messages addressed to the caller. The 2020 watch's inbox + /// "delete" action posts JSON {"MessageIds":[8]} (plural + /// array, verified from the live trace where the body was + /// {"MessageIds":[8]} against /api/messages/v3/delete); + /// some older / SPA call sites still post id or + /// messageId as a form/query. Accept all three shapes and + /// scope every delete to RecipientPlayerId == Me so the + /// sender can't unsend. + [HttpPost("/api/messages/v3/delete")] + [HttpPost("/api/messages/v1/delete")] + [Consumes("application/json")] + public async Task DeleteJson([FromBody] DeleteRequest body) + { + var ids = new List(); + if (body.Id is long a && a > 0) ids.Add(a); + if (body.MessageId is long b && b > 0) ids.Add(b); + if (body.MessageIds is { Count: > 0 }) + ids.AddRange(body.MessageIds.Where(v => v > 0)); + return await DeleteByIds(ids); + } + + [HttpPost("/api/messages/v3/delete")] + [HttpPost("/api/messages/v1/delete")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task DeleteForm( + [FromForm(Name = "id")] long? formId, + [FromForm(Name = "messageId")] long? formMessageId, + [FromForm(Name = "messageIds")] string? messageIdsCsv) + { + var ids = new List(); + if (formId is long a && a > 0) ids.Add(a); + if (formMessageId is long b && b > 0) ids.Add(b); + if (long.TryParse(Request.Query["id"], out var qi) && qi > 0) ids.Add(qi); + if (!string.IsNullOrWhiteSpace(messageIdsCsv)) + { + foreach (var s in messageIdsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries)) + if (long.TryParse(s.Trim(), out var v) && v > 0) ids.Add(v); + } + return await DeleteByIds(ids); + } + + private async Task DeleteByIds(List ids) + { + ids = ids.Distinct().ToList(); + if (ids.Count == 0) return BadRequest(new { error = "missing_id" }); + var deleted = await db.Messages + .Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me) + .ExecuteDeleteAsync(); + return Ok(new { success = true, error = "", deleted }); + } + + /// POST api/messages/v3/markRead — bulk + /// mark-as-read for every unread message addressed to the caller. + /// Optional ids CSV scopes to specific message ids. + [HttpPost("/api/messages/v3/markRead")] + [HttpPost("/api/messages/v1/markRead")] + public async Task MarkReadBulk([FromForm(Name = "ids")] string? ids) + { + IQueryable q = db.Messages + .Where(m => m.RecipientPlayerId == Me && m.ReadAt == null); + if (!string.IsNullOrWhiteSpace(ids)) + { + var idSet = ids + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => long.TryParse(s, out var v) ? v : 0L) + .Where(v => v > 0) + .ToHashSet(); + if (idSet.Count > 0) q = q.Where(m => idSet.Contains(m.Id)); + } + var now = DateTime.UtcNow; + await q.ExecuteUpdateAsync(s => s.SetProperty(m => m.ReadAt, now)); + return Ok(new { success = true, error = "" }); + } + + public sealed record SendRequest(long RecipientId, string Body, long? RoomId = null); + + /// Send a direct message — JSON wire shape used by some + /// SPA/admin callers. The 2020 watch posts to the same path with + /// form-urlencoded fields (); the + /// [Consumes] attribute disambiguates which action MVC + /// picks. Empty bodies are rejected so the inbox doesn't get + /// padded with whitespace pings; messages over 2000 chars are + /// truncated to fit the column. + [HttpPost("send")] + [HttpPost("/api/messages/v1/send")] + [Consumes("application/json")] + public async Task> SendMessage([FromBody] SendRequest req) + { + if (req.RecipientId <= 0 || req.RecipientId == Me) + return BadRequest(new { error = "invalid_recipient" }); + var trimmed = (req.Body ?? string.Empty).Trim(); + if (trimmed.Length == 0) return BadRequest(new { error = "empty_body" }); + if (trimmed.Length > 2000) trimmed = trimmed[..2000]; + + var entry = new MessageEntity + { + SenderPlayerId = Me, + RecipientPlayerId = req.RecipientId, + Body = trimmed, + Type = 30, // TextMessage + RoomId = req.RoomId, + }; + db.Messages.Add(entry); + await db.SaveChangesAsync(); + + await BroadcastMessageAsync(entry); + return Ok(ToDto(entry)); + } + + /// Form-urlencoded variant — what the 2020 watch actually + /// posts when the user taps "Send Join Request" or any other + /// RecNet.Messages.SendMessage(player, type, data) caller. + /// Wire shape (verified against client log contentType= + /// application/x-www-form-urlencoded, body ToPlayerId=…& + /// Type=10&Data=): the Type integer is a + /// RecNet.Message.MessageType enum value + /// (Cpp2IL_CS/.../RecNet/Message.cs) — 10 = + /// RequestGameInvite, 30 = TextMessage, etc. Empty Data is + /// allowed for control-channel types (join request, declines) + /// where the message itself is the signal. + [HttpPost("send")] + [HttpPost("/api/messages/v1/send")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task> SendFormMessage( + [FromForm(Name = "ToPlayerId")] long toPlayerId, + [FromForm(Name = "Type")] int type, + [FromForm(Name = "Data")] string? data, + [FromForm(Name = "RoomId")] long? roomId) + { + if (toPlayerId <= 0 || toPlayerId == Me) + return BadRequest(new { error = "invalid_recipient" }); + var trimmed = (data ?? string.Empty); + if (trimmed.Length > 2000) trimmed = trimmed[..2000]; + + // For TextMessage (type 30) the body IS the message — empty + // is meaningless and renders as a ghost "Message :" row in + // the watch's inbox (MessagesScreenItem format string at + // Cpp2IL_ISIL/.../MessagesScreenItem.txt:2145 is + // "Message : {0}"). Control-channel types (RequestGameInvite, + // declines, FriendStatusOnline, etc.) intentionally carry + // empty Data — they get rendered by their own label string + // in the same screen item. + if (type == 30 && trimmed.Trim().Length == 0) + return BadRequest(new { error = "empty_text_message" }); + + var entry = new MessageEntity + { + SenderPlayerId = Me, + RecipientPlayerId = toPlayerId, + Body = trimmed, + Type = type, + RoomId = roomId, + }; + db.Messages.Add(entry); + await db.SaveChangesAsync(); + + logger.LogInformation( + "[messages] send from={From} to={To} type={Type} dataLen={Len}", + Me, toPlayerId, type, trimmed.Length); + + await BroadcastMessageAsync(entry); + return Ok(ToDto(entry)); + } + + /// POST invite — handles the watch's + /// RecNet.Messages.SendGameInvite(playerId, roomInstanceId) + /// (verified at Cpp2IL_ISIL/.../Messages.txt:613-773): form + /// fields playerId (recipient) + roomInstanceId + /// (the inviter's private match id). Two side-effects: + /// 1. Add the recipient to the invite list for that instance so + /// POST /goto/instance/{id} accepts them. + /// 2. Drop a Type-6 (GameInviteV2) message in their inbox carrying the + /// RoomInstanceId so the message UI's "Join" button knows + /// where to send them. + /// Registered at multiple paths because the exact URL the watch + /// builds for "invite" depends on the messages-namespace base — + /// rather than guess wrong and silently fall through to the + /// wildcard, we cover all plausible mounts and log the one that + /// actually fires. + [HttpPost("/invite")] + [HttpPost("/api/invite")] + [HttpPost("/api/messages/v1/invite")] + [HttpPost("/api/messages/v2/invite")] + [HttpPost("/api/messages/v3/invite")] + public async Task SendInvite( + [FromForm(Name = "playerId")] long playerId, + [FromForm(Name = "roomInstanceId")] long roomInstanceId) + { + logger.LogInformation( + "[invite] from={From} → to={To} instance={Instance} (path={Path})", + Me, playerId, roomInstanceId, Request.Path); + + if (playerId <= 0 || playerId == Me) + return BadRequest(new { error = "invalid_recipient" }); + + // Only the OWNER of the private instance can invite into it. + // Otherwise an attacker who somehow learned a roomInstanceId + // could invite themselves (or anyone) freely. + var inst = await privateInstances.GetAsync(roomInstanceId); + if (inst is null) + return BadRequest(new { error = "instance_not_found", roomInstanceId }); + if (inst.OwnerPlayerId != Me) + return Forbid(); + + // Persist a real RecNet.Message.MessageType.GameInviteV2 (=6) + // so the watch's GameInviteController calls + // Matchmaking.AcceptRoomInvite(inviteId, roomId), which posts + // goto/invite/{inviteId}. Type 0 follows the sender's current + // presence instead and can land in the wrong instance. + // + // Two saves: the first allocates the message Id (which becomes + // the `inviteId` the watch sends back in /goto/invite/{N}); the + // second backfills the JSON Body once we know it. We can't + // pre-allocate the Id because EF Core's identity columns only + // populate on SaveChanges. + var roomName = await db.Rooms + .Where(r => r.Id == inst.RoomId) + .Select(r => r.Name) + .FirstOrDefaultAsync() ?? "Private match"; + + var entry = new MessageEntity + { + SenderPlayerId = Me, + RecipientPlayerId = playerId, + // Placeholder so the row passes the NOT NULL constraint + // until we overwrite it post-save with the real JSON. + Body = string.Empty, + Type = 6, + RoomId = inst.RoomId, + }; + db.Messages.Add(entry); + await db.SaveChangesAsync(); + + // InviteAsync AFTER the message save so we can pin the freshly- + // allocated Message.Id onto the invitee row's + // LatestInviteMessageId. This is what /goto/invite/{N} falls + // back to when the watch's accept flow has already raced + // through DELETE /api/messages/v3/delete — the message row is + // gone but the (instance, player) → messageId mapping survives. + await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id); + + // Body shape MUST be the JSON the watch's Message.Deserialize + // builds RoomInviteDetails from (verified at + // Cpp2IL_ISIL/.../RecNet/Message.txt:786-805 — for Type 6/7 + // the parser hits `Json.Deserialize` and reads "inviteId" + + // "name"; for other types it `Int32.Parse`s the body). + // + // inviteId → the message Id; watch echoes it back in + // /goto/invite/{inviteId}, server looks the + // row up to resolve roomInstanceId. + // name → display string in the invite toast / row. + // roomInstanceId → extra field the watch ignores; server uses + // it (via TryParseRoomInstanceId's JSON + // fallback) so /goto/invite/{N} doesn't have + // to re-query the private-instance service. + // + // The roomInstanceId MUST be a JSON STRING, not a number. LitJson + // chokes on the dict-cast when a value is a 64-bit integer larger + // than int32 — confirmed via SaveDebugMod trace: the watch logs + // `data={"inviteId":31,"name":"Dorm_1811750","roomInstanceId":6546545006375796736}` + // `RoomInviteDetails=` + // `System.NullReferenceException at RecNet.Util.GetKey from + // RecNet.Message.Deserialize` + // Json.Deserialize returns a null cast-result on the long value, + // GetKey("inviteId") then NREs and the whole parse aborts before + // RoomInviteDetails is built. Stringifying the value sidesteps + // LitJson's long-int handling entirely; the watch only reads + // inviteId + name so the string form is invisible to it. + entry.Body = System.Text.Json.JsonSerializer.Serialize(new + { + inviteId = entry.Id, + name = roomName, + roomInstanceId = roomInstanceId.ToString(System.Globalization.CultureInfo.InvariantCulture), + }); + await db.SaveChangesAsync(); + + await BroadcastMessageAsync(entry); + return Ok(new { success = true, messageId = entry.Id }); + } + + /// Mark a single message as read. No-op if not + /// addressed to the caller — prevents the sender from "reading" + /// the recipient's copy. + [HttpPost("markread/{id:long}")] + public async Task MarkRead(long id) + { + var msg = await db.Messages.FirstOrDefaultAsync(m => + m.Id == id && m.RecipientPlayerId == Me); + if (msg is null) return NotFound(); + if (msg.ReadAt is null) + { + msg.ReadAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } + return Ok(); + } + + public sealed class SendMultipleBody + { + public List? ToPlayerIds { get; set; } + public int Type { get; set; } + public string? Data { get; set; } + public long? RoomId { get; set; } + } + + /// POST api/messages/v1/sendMultiple — fan-out a + /// message to many recipients. Persists one MessageEntity per + /// recipient. Wire shape: {ToPlayerIds (int[]), Type (int), Data (string)}. + [HttpPost("/api/messages/v1/sendMultiple")] + [Consumes("application/json")] + public async Task SendMultiple([FromBody] SendMultipleBody body) + { + if (body.ToPlayerIds is null || body.ToPlayerIds.Count == 0) + return Ok(Array.Empty()); + var entries = new List(); + foreach (var to in body.ToPlayerIds.Distinct().Where(id => id > 0 && id != Me)) + { + var entry = new MessageEntity + { + SenderPlayerId = Me, + RecipientPlayerId = to, + Body = body.Data ?? string.Empty, + Type = body.Type, + RoomId = body.RoomId, + }; + entries.Add(entry); + db.Messages.Add(entry); + } + await db.SaveChangesAsync(); + foreach (var entry in entries) + { + await BroadcastMessageAsync(entry); + } + return Ok(Array.Empty()); + } + + /// POST api/offlineinvite/v1/send — send an invite + /// message to a player who's offline; surfaces in their inbox on + /// next login. Wire shape: form-urlencoded RecipientId + + /// Data. Idempotent per (sender, recipient, body). + [HttpPost("/api/offlineinvite/v1/send")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task OfflineInviteSend( + [FromForm(Name = "RecipientId")] long? recipientId, + [FromForm(Name = "Data")] string? data) + { + if (recipientId is not long rid || rid <= 0 || rid == Me) + return Ok(new { success = false, error = "invalid_recipient" }); + var entry = new MessageEntity + { + SenderPlayerId = Me, + RecipientPlayerId = rid, + Body = data ?? string.Empty, + Type = 0, + }; + db.Messages.Add(entry); + await db.SaveChangesAsync(); + await BroadcastMessageAsync(entry); + return Ok(new { success = true, messageId = entry.Id }); + } + + /// GET api/offlineinvite/v1/all — invites + /// received while the player was offline. Pulls unread + /// MessageEntity rows whose body looks like an invite. + [HttpGet("/api/offlineinvite/v1/all")] + public async Task OfflineInviteAll() + { + var rows = await db.Messages + .Where(m => m.RecipientPlayerId == Me && m.ReadAt == null + && (m.Body.Contains("invite") || m.Body.Contains("event"))) + .OrderByDescending(m => m.SentAt) + .Take(50) + .Select(m => new + { + InviteId = m.Id, + FromPlayerId = (int)m.SenderPlayerId, + m.Body, + SentAt = m.SentAt, + }) + .ToListAsync(); + return Ok(rows); + } + + /// The boot sequence asks for online friends. We + /// derive that from the SignalR hub's connected-player set — + /// the response shape is [ {AccountId, Online}, ... ]. + [HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")] + public async Task FavoriteFriendOnlineStatus() + { + var friendIds = await db.Relationships + .Where(r => r.Status == RelationshipStatus.Friend && + (r.RequesterId == Me || r.TargetId == Me)) + .Select(r => r.RequesterId == Me ? r.TargetId : r.RequesterId) + .ToListAsync(); + var online = onlinePresence.OnlinePlayerIds().ToHashSet(); + return Ok(friendIds.Select(id => new + { + AccountId = id, + Online = online.Contains(id), + })); + } + + /// Wire shape verified against + /// Cpp2IL_ISIL/.../RecNet/Message.txt:591-787. + /// RecNet.Message.Deserialize calls Util.GetKey + /// (throws on miss) for: Id (long), FromPlayerId (int), + /// SentTime (DateTime), Type (int — MessageType enum), + /// and reads Data (string) via Util.GetKeyOrDefault. + /// Optional nullable wrappers: RoomId, PlayerEventId. + /// Previous response used {Id, Sender, Recipient, Body, SentAt, ReadAt} + /// which crashed at Util.GetKey("FromPlayerId"). + public sealed record MessageDto( + long Id, + long FromPlayerId, + DateTime SentTime, + int Type, + string Data, + long? RoomId, + long? PlayerEventId); + + private async Task> ToDtosAsync(List rows) + { + var instanceIds = rows + .Where(m => IsInstanceInviteType(m.Type) + && m.RoomId is null + && long.TryParse(m.Body, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out _)) + .Select(m => long.Parse(m.Body, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture)) + .Distinct() + .ToList(); + + var roomByInstance = instanceIds.Count == 0 + ? new Dictionary() + : await db.PrivateInstances + .Where(i => instanceIds.Contains(i.Id)) + .Select(i => new { i.Id, i.RoomId }) + .ToDictionaryAsync(i => i.Id, i => i.RoomId); + + return rows.Select(m => + { + long? roomId = m.RoomId; + if (roomId is null + && IsInstanceInviteType(m.Type) + && long.TryParse(m.Body, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var instanceId) + && roomByInstance.TryGetValue(instanceId, out var rid)) + { + roomId = rid; + } + return ToDto(m, roomId); + }).ToList(); + } + + private static bool IsInstanceInviteType(int type) => type is 6 or 7; + + /// Push a MessageReceived SignalR notification to BOTH + /// the recipient AND the sender. The recipient half is the obvious one + /// — that's what fires the watch's OnMessageAdded event and + /// gives them the toast / inbox-row update. The sender-half is less + /// obvious but equally needed: the watch's Messages.SendMessage + /// (Messages.txt:1790-1900) just does ExpectHttpStatusSuccess + /// on the response and never touches the local MessageList, + /// so without a self-targeted push the sender's chat thread doesn't + /// know about its own outgoing message until the next + /// RefreshList (which only fires on hub-reconnect or first + /// inbox open). Pushing to both sides keeps both watches in sync + /// in real time. Dedup happens client-side in + /// OnMessageReceived via the "already-in-MessageList by Id" + /// check (Messages.txt:2790-2793), so a player who somehow ends up + /// as both sender and recipient (admin self-messaging) only sees + /// the message once. + private async Task BroadcastMessageAsync(MessageEntity entry) + { + var dto = ToDto(entry); + await notifications.NotifyAsync(entry.RecipientPlayerId, + PushNotificationId.MessageReceived, dto); + if (entry.SenderPlayerId != entry.RecipientPlayerId) + { + await notifications.NotifyAsync(entry.SenderPlayerId, + PushNotificationId.MessageReceived, dto); + } + } + + private static MessageDto ToDto(MessageEntity m, long? roomId = null) => + new( + Id: m.Id, + FromPlayerId: m.SenderPlayerId, + SentTime: m.SentAt, + // Round-trip the stored Type verbatim. Earlier we coerced + // Type==0 → 30 for "pre-Type-column rows", but the + // Messages.Type Postgres patch (Program.cs:612-614) added + // the column with DEFAULT 30 and backfilled every legacy + // row to 30, so any Type==0 row in the DB now is a real + // GameInvite (e.g. SendInvite above). Coercing it to 30 + // routes it through TextMessageController and renders + // "Message : " with no Accept button. + Type: m.Type, + Data: m.Body, + RoomId: roomId ?? m.RoomId, + PlayerEventId: null); +} diff --git a/DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs b/DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs new file mode 100644 index 0000000..92f98bc --- /dev/null +++ b/DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs @@ -0,0 +1,211 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Moderation; + +/// +/// In-game moderation endpoints invoked by the watch's player-action +/// dialogs. URLs / form-fields verified against the Cpp2IL ISIL dump: +/// +/// POST api/PlayerReporting/v3/create +/// PlayerReporting.txt:1696 + 1712-1822 +/// Form: PlayerIdReported (int), ReportCategory (int), Details (string), +/// HeightReporter (float "F2"), HeightReported (float "F2"), +/// RoomId (long). +/// +/// POST api/PlayerReporting/v3/voteToKick +/// PlayerReporting.txt:3175 + 3215-3237 +/// Form: PlayerId (long), Response (bool), GameSessionId (long). +/// +/// POST api/PlayerReporting/v1/instantKick +/// PlayerReporting.txt:3417 (single) + 3590 (bulk) +/// Body: PlayerId(s) — one or many. Admin/dev only. +/// +/// POST api/PlayerReporting/v1/deviceId +/// PlayerReporting.txt:3736 + 3749 +/// Form: oldDeviceId (string). +/// +/// POST api/PlayersBanned/v2/ban +/// Moderation.txt:623-702 +/// Form: PlayerId (long), Reason (int), BanType (int), +/// DisplayReason (string), BannedUntil (string MM/dd/yyyy HH:mm). +/// Admin only. +/// +/// Without these handlers the watch fell through to the JSON catch-all +/// returning {}, which the OkResponse parser tolerates but the +/// admin-action then has no observable effect — i.e. "the admin +/// system doesn't work at all". +/// +[ApiController] +[Authorize] +public class InGameModerationController( + DorkNetDbContext db, + NotificationService notifications, + ILogger logger) : ControllerBase +{ + // ── /v3/create — file a player report ──────────────────────────── + [HttpPost("api/PlayerReporting/v3/create")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task CreateReport( + [FromForm(Name = "PlayerIdReported")] long? playerIdReported, + [FromForm(Name = "ReportCategory")] int reportCategory = 0, + [FromForm(Name = "Details")] string? details = null, + [FromForm(Name = "HeightReporter")] float? heightReporter = null, + [FromForm(Name = "HeightReported")] float? heightReported = null, + [FromForm(Name = "RoomId")] long roomId = 0) + { + if (playerIdReported is not long target || target <= 0) + return Ok(new { success = false, error = "missing_target" }); + + var reporter = this.RequireCurrentPlayerId(); + if (reporter == target) + return Ok(new { success = false, error = "cannot_report_self" }); + + var trimmed = (details ?? string.Empty).Trim(); + if (trimmed.Length > 1000) trimmed = trimmed[..1000]; + + db.Reports.Add(new ReportEntity + { + ReporterPlayerId = reporter, + TargetPlayerId = target, + Category = reportCategory, + Message = trimmed, + RoomId = roomId, + }); + await db.SaveChangesAsync(); + logger.LogInformation( + "[mod] report filed: reporter={Reporter} target={Target} category={Cat} room={Room}", + reporter, target, reportCategory, roomId); + return Ok(new { success = true, error = "" }); + } + + // ── /v3/voteToKick — room-vote kick ────────────────────────────── + [HttpPost("api/PlayerReporting/v3/voteToKick")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public IActionResult VoteToKick( + [FromForm(Name = "PlayerId")] long? playerId, + [FromForm(Name = "Response")] bool? response, + [FromForm(Name = "GameSessionId")] long gameSessionId = 0) + { + var caller = this.RequireCurrentPlayerId(); + logger.LogInformation( + "[mod] vote-to-kick by {Caller}: target={Target} yes={Yes} session={Session}", + caller, playerId, response, gameSessionId); + // Real implementation would tally votes per (session, target) and + // push a kick once the threshold tripped. For now we accept the + // vote without side-effects so the watch UI's "voted" indicator + // shows. + return Ok(new { success = true, error = "" }); + } + + // ── /v1/instantKick — admin one-shot ───────────────────────────── + /// + /// Admin/dev one-shot kick. The watch posts either + /// ?playerId=N for a single target (PlayerReporting.txt:3417) + /// or a list body for bulk kicks (3590). Either form lands here. + /// Gated to / + /// . + /// + [HttpPost("api/PlayerReporting/v1/instantKick")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task InstantKick( + [FromQuery(Name = "playerId")] long? singleId, + [FromForm(Name = "PlayerId")] long? formId, + [FromForm(Name = "PlayerIds")] string? formIds) + { + var caller = this.RequireCurrentPlayerId(); + if (!await IsCallerAdminOrDevAsync(caller)) + return Forbid(); + + var ids = new List(); + if (singleId is long s && s > 0) ids.Add(s); + if (formId is long f && f > 0) ids.Add(f); + if (!string.IsNullOrEmpty(formIds)) + { + foreach (var part in formIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (long.TryParse(part, out var v) && v > 0) ids.Add(v); + } + ids = ids.Distinct().ToList(); + if (ids.Count == 0) return Ok(new { success = false, error = "missing_target" }); + + foreach (var target in ids) + { + logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target); + // Push a SignalR ModerationKick notification — the watch's + // PlayerReporting.OnModerationKick handler boots the player + // back to their dorm. + await notifications.NotifyAsync( + target, + Models.Notification.PushNotificationId.ModerationKick); + } + return Ok(new { success = true, error = "" }); + } + + // ── /v1/deviceId — log mismatched device id ────────────────────── + [HttpPost("api/PlayerReporting/v1/deviceId")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public IActionResult MismatchedDeviceWarning( + [FromForm(Name = "oldDeviceId")] string? oldDeviceId) + { + logger.LogInformation( + "[mod] mismatched device id reported: caller={Caller} old={Old}", + this.CurrentPlayerId(), oldDeviceId); + return Ok(new { success = true, error = "" }); + } + + // ── /api/PlayersBanned/v2/ban — admin ban ──────────────────────── + [HttpPost("api/PlayersBanned/v2/ban")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task Ban( + [FromForm(Name = "PlayerId")] long? playerId, + [FromForm(Name = "Reason")] int reason = 0, + [FromForm(Name = "BanType")] int banType = 0, + [FromForm(Name = "DisplayReason")] string? displayReason = null, + [FromForm(Name = "BannedUntil")] string? bannedUntil = null) + { + var caller = this.RequireCurrentPlayerId(); + if (!await IsCallerAdminOrDevAsync(caller)) + return Forbid(); + + if (playerId is not long target || target <= 0) + return Ok(new { success = false, error = "missing_target" }); + + // BannedUntil arrives as "MM/dd/yyyy HH:mm" per Moderation.txt:696. + DateTime? until = null; + if (!string.IsNullOrEmpty(bannedUntil) && + DateTime.TryParseExact(bannedUntil, "MM/dd/yyyy HH:mm", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, + out var parsed)) + { + until = parsed; + } + + var p = await db.Players.FirstOrDefaultAsync(x => x.Id == target); + if (p is null) return Ok(new { success = false, error = "no_such_player" }); + p.BannedUntil = until ?? DateTime.UtcNow.AddYears(99); + await db.SaveChangesAsync(); + + logger.LogInformation( + "[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}", + caller, target, reason, p.BannedUntil, displayReason); + + // Push a ModerationRoomBan notification so the target watch + // shows the ban dialog and disconnects (PlayerReporting.txt:2253). + await notifications.NotifyAsync( + target, + Models.Notification.PushNotificationId.ModerationRoomBan); + return Ok(new { success = true, error = "" }); + } + + private async Task IsCallerAdminOrDevAsync(long callerId) => + await db.Players + .Where(p => p.Id == callerId) + .Select(p => p.IsAdmin || p.IsDeveloper) + .FirstOrDefaultAsync(); +} diff --git a/DorkNet.Server/Controllers/API/Notification/V2/NotificationController.cs b/DorkNet.Server/Controllers/API/Notification/V2/NotificationController.cs new file mode 100644 index 0000000..c605673 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Notification/V2/NotificationController.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Notification.V2; + +/// +/// Legacy raw-WebSocket notification endpoint. Replaced by the SignalR +/// hub at notify.{apex}/hub/v1. Returns 410 Gone for any client +/// that still tries the old path. +/// +[ApiController] +[Route("api/[controller]/v2")] +public class NotificationController(DomainConfig domain) : ControllerBase +{ + [HttpGet] + public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new + { + error = "use_signalr_hub", + hint = $"connect to {domain.Sub("notify")}/hub/v1 instead", + }); + + /// GET api/notification/hub/v1 — returns the + /// SignalR hub URL the client should open. Without this the + /// watch falls back to its own default and may try the wrong + /// scheme. URL is built off the configured deployment apex + /// (DORKNET_DOMAIN). + [HttpGet("/api/notification/hub/v1")] + public IActionResult HubInfo() => Ok(new + { + Url = $"https://{domain.Sub("notify")}/hub/v1", + Protocol = "signalr", + Version = 1, + }); +} diff --git a/DorkNet.Server/Controllers/API/NotificationPrefs/IOSNotificationPrefsController.cs b/DorkNet.Server/Controllers/API/NotificationPrefs/IOSNotificationPrefsController.cs new file mode 100644 index 0000000..61b6c85 --- /dev/null +++ b/DorkNet.Server/Controllers/API/NotificationPrefs/IOSNotificationPrefsController.cs @@ -0,0 +1,128 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.NotificationPrefs; + +/// +/// api.rec.net/api/messages/v1/IOSGetNotificationPreferences + +/// IOSModifyNotificationPreferences. Wire DTO matches +/// RecNet.IOSNotificationPreferences (decompiled at +/// Cpp2IL_CS/.../RecNet/IOSNotificationPreferences.cs): +/// ChatMessage(bool), FriendInvite(bool), FavoriteFriendOnline(bool). +/// +/// Persists via keyed by +/// (PlayerId, Platform). Defaults to all-on when no row exists yet. +/// +[ApiController] +[Authorize] +public class IOSNotificationPrefsController(DorkNetDbContext db) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + [HttpGet("api/messages/v1/IOSGetNotificationPreferences")] + public async Task Get() + { + var pid = Me; + var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid); + return Ok(ToWire(row)); + } + + public sealed class IOSPrefsRequest + { + public bool ChatMessage { get; set; } = true; + public bool FriendInvite { get; set; } = true; + public bool FavoriteFriendOnline { get; set; } = true; + } + + [HttpPost("api/messages/v1/IOSModifyNotificationPreferences")] + public async Task Modify([FromBody] IOSPrefsRequest body) + { + var pid = Me; + var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid); + if (row is null) + { + row = new NotificationPrefsEntity + { + PlayerId = pid, + Platform = "ios", + }; + db.NotificationPrefs.Add(row); + } + row.AllowMessage = body.ChatMessage; + row.AllowFriendRequest = body.FriendInvite; + row.AllowAnnouncements = body.FavoriteFriendOnline; + row.AllowAll = body.ChatMessage || body.FriendInvite || body.FavoriteFriendOnline; + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWire(row)); + } + + /// POST IOSResetNotificationPreferencesBadgeCount — + /// the watch sends this when the user opens the iOS app to clear + /// the badge counter. No persistent state to mutate; ack. + [HttpPost("api/messages/v1/IOSResetNotificationPreferencesBadgeCount")] + public IActionResult ResetBadge() => Ok(new { Reset = true }); + + public sealed class SaveTokenRequest + { + public string? Token { get; set; } + public string? DeviceToken { get; set; } + public string? Platform { get; set; } + } + + /// POST api/messages/v1/IOSSaveDeviceToken — + /// upserts the caller's APNS token to + /// for a future APNS proxy to fan + /// out push notifications. + [HttpPost("api/messages/v1/IOSSaveDeviceToken")] + public async Task SaveDeviceToken( + [FromBody] SaveTokenRequest? body, + [FromForm(Name = "Token")] string? tokenForm, + [FromForm(Name = "DeviceToken")] string? deviceTokenForm) + { + var pid = Me; + var token = body?.Token ?? body?.DeviceToken ?? tokenForm ?? deviceTokenForm ?? string.Empty; + var platform = body?.Platform ?? "ios"; + if (string.IsNullOrWhiteSpace(token)) + return BadRequest(new { error = "missing token" }); + + var row = await db.PushTokens.FirstOrDefaultAsync(t => + t.PlayerId == pid && t.Platform == platform); + if (row is null) + { + row = new PushTokenEntity { PlayerId = pid, Platform = platform }; + db.PushTokens.Add(row); + } + row.Token = token; + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(new { Saved = true }); + } + + /// POST api/messages/v1/IOSClearDeviceToken — + /// drops every push-token row for the caller (e.g. on iOS + /// logout). + [HttpPost("api/messages/v1/IOSClearDeviceToken")] + public async Task ClearDeviceToken() + { + var pid = Me; + var rows = await db.PushTokens.Where(t => t.PlayerId == pid).ToListAsync(); + if (rows.Count > 0) + { + db.PushTokens.RemoveRange(rows); + await db.SaveChangesAsync(); + } + return Ok(new { Cleared = rows.Count }); + } + + private static object ToWire(NotificationPrefsEntity? r) => new + { + ChatMessage = r?.AllowMessage ?? true, + FriendInvite = r?.AllowFriendRequest ?? true, + FavoriteFriendOnline = r?.AllowAnnouncements ?? true, + }; +} diff --git a/DorkNet.Server/Controllers/API/Photos/PhotosController.cs b/DorkNet.Server/Controllers/API/Photos/PhotosController.cs new file mode 100644 index 0000000..9d9bc23 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Photos/PhotosController.cs @@ -0,0 +1,335 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Photos; + +/// +/// api.rec.net/api/photos/v1/* — social-feed wrapper around the +/// in-game camera. The camera itself uses storage.rec.net/upload with +/// FileType=Image to push the PNG bytes; that returns a Filename. The +/// watch's "Share" panel then POSTs here with that Filename, a caption +/// and any tagged players to create the public-feed photo. +/// +/// Public reads (feed, by-player, by-room) are anonymous so the +/// feed.rec.net frontend can browse without an admin token. +/// Mutations (post, cheer, delete) require auth. +/// +[ApiController] +// Two hosts: api.rec.net is what the in-game watch hits; feed.rec.net +// also serves these endpoints so the public frontend website can do +// same-origin fetches without CORS. +[Route("api/photos/v1")] +public class PhotosController( + DorkNetDbContext db, + PlayerPresenceService presence, + NotificationService notifications, + LevelService level, + DomainConfig domain) : ControllerBase +{ + public sealed record PostPhotoRequest( + string ImageName, + string? Caption, + string? TaggedPlayerIds, + long? RoomId, + bool? IsPublic); + + /// POST api/photos/v1/post — promote an uploaded image + /// blob to the photo feed. Body: ImageName (filename returned by + /// storage.rec.net/upload), Caption, TaggedPlayerIds (CSV), + /// optional RoomId (defaults to caller's current room from + /// PlayerPresenceService), IsPublic (defaults true). Returns the + /// created PhotoEntity. + [HttpPost("post")] + [Authorize] + public async Task PostPhoto([FromBody] PostPhotoRequest body) + { + var me = this.RequireCurrentPlayerId(); + if (string.IsNullOrWhiteSpace(body.ImageName)) + return BadRequest(new { error = "missing_image_name" }); + + // Verify the blob actually exists and was uploaded by us. Without + // this check anyone could cite another player's filename and + // post-attribute the photo to themselves. + var blob = await db.RoomDataBlobs + .Where(b => b.BlobName == body.ImageName) + .Select(b => new { b.UploadedByPlayerId }) + .FirstOrDefaultAsync(); + if (blob is null) + return NotFound(new { error = "image_blob_not_found" }); + if (blob.UploadedByPlayerId != me) + return Forbid(); + + // Auto-resolve current room if the caller didn't supply one. + var roomId = body.RoomId ?? presence.GetRoom(me)?.RoomId ?? 0; + + var photo = new PhotoEntity + { + UploaderPlayerId = me, + BlobName = body.ImageName, + Caption = (body.Caption ?? string.Empty).Trim(), + TaggedPlayerIdsCsv = NormaliseTaggedIds(body.TaggedPlayerIds, exceptId: me), + RoomId = roomId, + IsPublic = body.IsPublic ?? true, + }; + db.Photos.Add(photo); + await db.SaveChangesAsync(); + + // Posting a photo gives a small XP bump — encourages camera use. + await level.AwardXpAsync(me, LevelService.InventionSavedXp, $"photo_posted:{photo.Id}"); + + // Push to tagged players so their "Photos of me" feed refreshes. + foreach (var taggedId in ParseTagged(photo.TaggedPlayerIdsCsv)) + await notifications.NotifyAsync(taggedId, + PushNotificationId.SubscriptionUpdateProfile, + new { Reason = "TaggedInPhoto", PhotoId = photo.Id, From = me }); + + return Ok(ToDto(photo)); + } + + /// GET api/photos/v1/feed — public newest-first feed. + /// Anonymous-safe (so the frontend website can render without a + /// JWT). Soft-deleted and private photos are excluded. + [HttpGet("feed")] + [AllowAnonymous] + public async Task Feed([FromQuery] int take = 20, [FromQuery] int skip = 0) + { + take = Math.Clamp(take, 1, 100); + skip = Math.Max(0, skip); + var rows = await db.Photos + .Where(p => p.IsPublic && p.DeletedAt == null) + .OrderByDescending(p => p.CreatedAt) + .Skip(skip).Take(take) + .ToListAsync(); + return Ok(await EnrichAsync(rows)); + } + + /// GET api/photos/v1/by/{playerId} — photos posted by a + /// specific player. Anonymous-safe. Private photos hidden unless + /// the caller is the uploader (or an admin). + [HttpGet("by/{playerId:long}")] + [AllowAnonymous] + public async Task ByPlayer(long playerId, + [FromQuery] int take = 20, [FromQuery] int skip = 0) + { + take = Math.Clamp(take, 1, 100); + skip = Math.Max(0, skip); + var me = this.CurrentPlayerId(); + var includePrivate = me == playerId || await IsAdminAsync(me); + + var q = db.Photos.Where(p => p.UploaderPlayerId == playerId && p.DeletedAt == null); + if (!includePrivate) q = q.Where(p => p.IsPublic); + var rows = await q + .OrderByDescending(p => p.CreatedAt) + .Skip(skip).Take(take) + .ToListAsync(); + return Ok(await EnrichAsync(rows)); + } + + /// GET api/photos/v1/of/{playerId} — photos the player + /// is tagged in. Anonymous-safe. + [HttpGet("of/{playerId:long}")] + [AllowAnonymous] + public async Task OfPlayer(long playerId, + [FromQuery] int take = 20, [FromQuery] int skip = 0) + { + take = Math.Clamp(take, 1, 100); + skip = Math.Max(0, skip); + var needle = $",{playerId},"; + // SQLite EF translates `string.Contains` to LIKE — wrapping the + // CSV with leading/trailing commas avoids "12" matching "112". + var rows = await db.Photos + .Where(p => p.IsPublic && p.DeletedAt == null && + ("," + p.TaggedPlayerIdsCsv + ",").Contains(needle)) + .OrderByDescending(p => p.CreatedAt) + .Skip(skip).Take(take) + .ToListAsync(); + return Ok(await EnrichAsync(rows)); + } + + /// GET api/photos/v1/in/{roomId} — photos taken in a + /// specific room. Anonymous-safe. + [HttpGet("in/{roomId:long}")] + [AllowAnonymous] + public async Task InRoom(long roomId, + [FromQuery] int take = 20, [FromQuery] int skip = 0) + { + take = Math.Clamp(take, 1, 100); + skip = Math.Max(0, skip); + var rows = await db.Photos + .Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null) + .OrderByDescending(p => p.CreatedAt) + .Skip(skip).Take(take) + .ToListAsync(); + return Ok(await EnrichAsync(rows)); + } + + /// GET api/photos/v1/{id} — single photo detail. + /// Increments ViewCount as a side effect (denormalised for the + /// "trending" sort). + [HttpGet("{id:long}")] + [AllowAnonymous] + public async Task Get(long id) + { + var p = await db.Photos.FirstOrDefaultAsync(x => x.Id == id); + if (p is null || p.DeletedAt is not null) return NotFound(); + if (!p.IsPublic) + { + var me = this.CurrentPlayerId(); + if (me != p.UploaderPlayerId && !await IsAdminAsync(me)) return NotFound(); + } + p.ViewCount += 1; + await db.SaveChangesAsync(); + var enriched = await EnrichAsync(new[] { p }); + return Ok(enriched.First()); + } + + /// POST api/photos/v1/{id}/cheer — like a photo. Idempotent + /// per (caller, photo) pair. Pushes a notification to the + /// uploader so their watch can refresh the count. + [HttpPost("{id:long}/cheer")] + [Authorize] + public async Task Cheer(long id) + { + var me = this.RequireCurrentPlayerId(); + var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id); + if (photo is null || photo.DeletedAt is not null) return NotFound(); + + var existing = await db.Cheers.FirstOrDefaultAsync(c => + c.FromPlayerId == me && c.TargetPhotoId == id && + c.TargetPlayerId == 0 && c.TargetRoomId == 0); + if (existing is not null) return Ok(new { already_cheered = true }); + + db.Cheers.Add(new CheerEntity + { + FromPlayerId = me, + TargetPhotoId = id, + }); + photo.CheerCount += 1; + await db.SaveChangesAsync(); + + if (photo.UploaderPlayerId != me) + { + await level.AwardXpAsync(photo.UploaderPlayerId, + LevelService.CheerReceivedXp, $"photo_cheer:{id}"); + await notifications.NotifyAsync(photo.UploaderPlayerId, + PushNotificationId.SubscriptionUpdateProfile, + new { Reason = "PhotoCheer", PhotoId = id, From = me }); + } + return Ok(new { cheered = true, count = photo.CheerCount }); + } + + /// DELETE api/photos/v1/{id} — soft-delete. Allowed for + /// the uploader or an admin. The blob bytes stay in + /// RoomDataBlobs for audit; the row's DeletedAt timestamp hides + /// it from feeds. + [HttpDelete("{id:long}")] + [Authorize] + public async Task Delete(long id) + { + var me = this.RequireCurrentPlayerId(); + var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id); + if (photo is null) return NotFound(); + if (photo.UploaderPlayerId != me && !await IsAdminAsync(me)) + return Forbid(); + photo.DeletedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(new { deleted = id }); + } + + // ── Helpers ────────────────────────────────────────────────────── + + /// Trim/dedupe tagged ids and drop the uploader's own id. + /// CSV in, CSV out. + private static string NormaliseTaggedIds(string? csv, long exceptId) + { + if (string.IsNullOrWhiteSpace(csv)) return string.Empty; + var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => long.TryParse(s, out var v) ? v : 0L) + .Where(v => v > 0 && v != exceptId) + .Distinct() + .ToList(); + return string.Join(",", ids); + } + + private static IEnumerable ParseTagged(string? csv) => + string.IsNullOrWhiteSpace(csv) + ? Enumerable.Empty() + : csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => long.TryParse(s, out var v) ? v : 0L) + .Where(v => v > 0); + + private async Task IsAdminAsync(long? playerId) + { + if (playerId is not long id) return false; + return await db.Players.Where(p => p.Id == id).Select(p => p.IsAdmin).FirstOrDefaultAsync(); + } + + /// Materialise photo rows into the wire DTO shape. One + /// extra DB hit fetches the uploader display names and room names + /// in batch so the JSON has human-readable labels — saves the + /// frontend from a per-row N+1. + private async Task> EnrichAsync(IEnumerable photos) + { + var photoList = photos.ToList(); + if (photoList.Count == 0) return new(); + + var uploaderIds = photoList.Select(p => p.UploaderPlayerId).Distinct().ToList(); + var roomIds = photoList.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList(); + + var uploaderNames = await db.Players + .Where(p => uploaderIds.Contains(p.Id)) + .Select(p => new { p.Id, p.DisplayName, p.Username }) + .ToListAsync(); + var roomNames = await db.Rooms + .Where(r => roomIds.Contains(r.Id)) + .Select(r => new { r.Id, r.Name }) + .ToListAsync(); + + var uMap = uploaderNames.ToDictionary(u => u.Id); + var rMap = roomNames.ToDictionary(r => r.Id); + + return photoList.Select(p => + { + uMap.TryGetValue(p.UploaderPlayerId, out var u); + rMap.TryGetValue(p.RoomId, out var r); + return (object)new + { + p.Id, + p.UploaderPlayerId, + UploaderDisplayName = u?.DisplayName ?? $"Player_{p.UploaderPlayerId}", + UploaderUsername = u?.Username ?? string.Empty, + p.BlobName, + ImageUrl = $"https://{domain.Sub("cdn")}/{p.BlobName}", + p.Caption, + p.RoomId, + RoomName = r?.Name ?? string.Empty, + TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(), + p.IsPublic, + p.CheerCount, + p.ViewCount, + p.CreatedAt, + }; + }).ToList(); + } + + private object ToDto(PhotoEntity p) => new + { + p.Id, + p.UploaderPlayerId, + p.BlobName, + ImageUrl = $"https://{domain.Sub("cdn")}/{p.BlobName}", + p.Caption, + p.RoomId, + TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(), + p.IsPublic, + p.CheerCount, + p.ViewCount, + p.CreatedAt, + }; +} diff --git a/DorkNet.Server/Controllers/API/PlatformLogin/V2/PlatformLoginController.cs b/DorkNet.Server/Controllers/API/PlatformLogin/V2/PlatformLoginController.cs new file mode 100644 index 0000000..47e3060 --- /dev/null +++ b/DorkNet.Server/Controllers/API/PlatformLogin/V2/PlatformLoginController.cs @@ -0,0 +1,209 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Auth; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.PlatformLogin.V2; + +/// +/// api.rec.net/api/platformlogin/* — OAuth-style password-grant login. +/// Login.LOGIN_API in dump.cs (line 578249) is "api/platformlogin/" — the +/// version suffix (v2 / v5) is appended by callers. The 2020 build uses v5. +/// +/// Request body is application/x-www-form-urlencoded (HTTPUrlEncodedForm in +/// Login.LoginHelper, dump.cs:578319), NOT JSON. Form fields are bound +/// case-insensitively by ASP.NET Core, so the parameter names don't have to +/// match the on-the-wire casing exactly. +/// +/// Response is a Login.LoginResponse: { Error, ErrorDescription, +/// AccessToken, RefreshToken } — JSON keys must be PascalCase (the +/// Util.GetKey calls inside Login.LoginResponse.Deserialize pass PascalCase +/// string literals; verified via global-metadata.dat scan). +/// +[ApiController] +[Route("api/platformlogin")] +public class PlatformLoginController( + PlayerService playerService, + AuthService authService, + ServerSettingsService settings, + DorkNetDbContext db) : ControllerBase +{ + // Login.LoginToAccount → POST api/platformlogin/v2 / v5 + // Form fields the 2020 client sends are not yet fully confirmed; common + // OAuth-2 password-grant + Rec Room platform fields: + // grant_type, username, password, platform, platformId, deviceId, + // buildTimestamp, version. Bind permissively and ignore extras. + [HttpPost("v2")] + [HttpPost("v5")] + public async Task> Login( + [FromForm(Name = "username")] string? Username, + [FromForm(Name = "Name")] string? Name, + [FromForm(Name = "platform")] int Platform = 0, + [FromForm(Name = "platformId")] string? PlatformId = null, + [FromForm(Name = "deviceId")] string? DeviceId = null, + [FromForm(Name = "buildTimestamp")] long BuildTimestamp = 0, + [FromForm(Name = "version")] string? Version = null) + { + var displayName = Username ?? Name ?? PlatformId ?? "Player"; + + // Admin "signups disabled" gate. Mirrors the same guard on + // /connect/token's device-id fallback in AuthController — if + // the device has no existing account, GetOrCreate would mint a + // new one here, bypassing the toggle. Refuse pre-creation. + if (await settings.AreSignupsDisabledAsync() + && await playerService.GetByDeviceAsync(DeviceId) is null) + { + return Unauthorized(new LoginResponse + { + Error = "signups_disabled", + ErrorDescription = "Account creation is currently disabled by the server admin.", + }); + } + + // Account identity is keyed off deviceId. The service handles the + // null/empty case by synthesising a stable id from platformId or a + // GUID — either way, no two unrelated callers ever share an account. + var player = await playerService.GetOrCreateByDeviceAsync( + deviceId: DeviceId, + platform: Platform, + platformId: PlatformId, + displayName: displayName); + var (access, refresh) = authService.GenerateTokenPair(player.Id); + + return Ok(new LoginResponse + { + AccessToken = access, + RefreshToken = refresh, + }); + } + + // Login.DownloadCachedLogins → GET api/platformlogin/cached + // Returns List. Empty array means no remembered accounts. + // + // The 2020 client doesn't include a platformId in the path here, but + // BestHTTP appends current-session credentials as query params on these + // bootstrap calls — so we accept platform/platformId via query or form + // and reuse the same lookup as auth.rec.net/cachedlogin/forplatformid. + [HttpGet("cached")] + [HttpGet("cachedlogins")] + [HttpGet("savedlogins")] + [HttpGet("v1/cached")] + [HttpGet("v2/cached")] + public async Task GetCachedLogins( + [FromQuery] int platform = 0, + [FromQuery] string? platformId = null) + { + var players = await playerService.GetCachedLoginsAsync(platform, platformId); + return Ok(players.Select(p => new CachedLogin + { + Platform = platform, + PlatformId = platformId ?? string.Empty, + AccountId = (int)p.Id, + LastLoginTime = p.LastSeenAt, + RequirePassword = false, + }).ToList()); + } + + // Login.RemoveCachedLogin → DELETE api/platformlogin/cached + [HttpDelete("cached")] + [HttpDelete("cachedlogins")] + public IActionResult RemoveCachedLogin() => Ok(new { }); + + // Login.RefreshLogin → POST api/platformlogin/refresh + // Response is Login.RefreshLoginResponse { Token } — single field, the + // new access token. (Note: dump.cs Login.RefreshLoginResponse has only + // "Token", not "AccessToken".) + [HttpPost("refresh")] + public async Task Refresh() + { + // Refresh requires a valid bearer (access OR refresh JWT); both are + // signed with the same secret so ValidateToken accepts either. + // Refusing to "GetOrCreateAsync(null)" here means a stale install + // can't quietly mint a brand-new account when its refresh token is + // gone — it has to go through the cached-login flow instead. + var auth = Request.Headers.Authorization.ToString(); + if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return Unauthorized(); + var token = auth["Bearer ".Length..].Trim(); + var resolved = authService.ValidateToken(token); + if (resolved is not long playerId) + return Unauthorized(); + var player = await playerService.GetByIdAsync(playerId); + if (player is null) return Unauthorized(); + var (access, _) = authService.GenerateTokenPair(player.Id); + return Ok(new RefreshLoginResponse { Token = access }); + } + + // Login.LoginToCachedAccount(int accountId) → POST api/platformlogin/cached/{accountId} + // Returns the same Login.LoginResponse shape as the regular login. + [HttpPost("cached/{accountId:long}")] + public async Task LoginToCachedAccount( + long accountId, + [FromForm] int platform = 0, + [FromForm(Name = "platformId")] string? platformId = null, + [FromForm(Name = "platform_id")] string? platformIdSnake = null) + { + // No GetOrCreateAsync(null) fallback — if the cached id doesn't + // resolve, the cache is stale (another DB instance, deleted account, + // etc.) and we'd rather force the client back to the create-account + // flow than silently mint a new player. + var player = await playerService.GetByIdAsync(accountId); + if (player is null) return Unauthorized(); + await playerService.TagPlatformAsync(player.Id, platform, platformId ?? platformIdSnake); + var (access, refresh) = authService.GenerateTokenPair(player.Id); + return Ok(new LoginResponse + { + AccessToken = access, + RefreshToken = refresh, + }); + } + + // Login.CheckHasPassword → GET api/platformlogin/haspassword + // Client uses ExpectPrimitiveResponse → Convert.ChangeType + // on the raw response, so we MUST emit a bare boolean literal. + // Real password state lives on PlayerEntity.PasswordHash; + // AuthAccountController on auth.* does the same lookup against the + // authenticated player. We mirror it here so the api.* host also + // returns the truthful value instead of an always-true stub. + [HttpGet("haspassword")] + public async Task HasPassword() + { + var pid = this.CurrentPlayerId() ?? 0; + if (pid == 0) return Content("false", "application/json"); + var player = await playerService.GetByIdAsync(pid); + var has = player?.PasswordHash is { Length: > 0 }; + return Content(has ? "true" : "false", "application/json"); + } + + // Login.CreatePassword / ChangePassword / ResetPassword on auth.* + // already do the real bcrypt write — they're wired in + // AuthAccountController. The api.* path duplicates the route + // without persisting; rather than mirror the full logic here, + // delegate to AuthService.ChangePassword for consistency. + [HttpPost("password")] + [HttpPut("password")] + public async Task SetPassword([FromForm(Name = "newPassword")] string? newPassword, + [FromForm(Name = "password")] string? legacy) + { + var pid = this.CurrentPlayerId() ?? 0; + if (pid == 0) return Unauthorized(); + var pw = newPassword ?? legacy ?? string.Empty; + if (pw.Length < 6) return Ok(new RecNetResult { Success = false, Error = "password_too_short" }); + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid); + if (player is null) return NotFound(); + player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(pw, workFactor: 11); + await db.SaveChangesAsync(); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + [HttpPost("resetpassword")] + public IActionResult ResetPassword() + { + // Real password reset would email a token; on a private + // server with no SMTP this is an explicit no-op (the admin + // SPA "change password" tool is the supported path). + return Ok(new RecNetResult { Success = false, Error = "reset_not_supported" }); + } +} diff --git a/DorkNet.Server/Controllers/API/PlayerCheer/PlayerCheerController.cs b/DorkNet.Server/Controllers/API/PlayerCheer/PlayerCheerController.cs new file mode 100644 index 0000000..4258245 --- /dev/null +++ b/DorkNet.Server/Controllers/API/PlayerCheer/PlayerCheerController.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.PlayerCheer; + +/// +/// api.{rec.net,localhost}/api/PlayerCheer/v1/* — peer cheer flow. +/// Cheers between players are stored as +/// rows keyed by TargetPlayerId. The "selected cheer" badge +/// each player can pin to their profile is stored as a +/// with key SelectedCheer. +/// +[ApiController] +[Authorize] +public class PlayerCheerController( + DorkNetDbContext db, + NotificationService notifications, + LevelService level) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + /// POST api/PlayerCheer/v1/create — caller cheers + /// the target player. Type matches the same enum as + /// CheerEntity.Type. Idempotent on (caller, target, type) + /// to match PlayerStateController.CheerPlayer, so the same + /// category click doesn't inflate the count. + [HttpPost("api/PlayerCheer/v1/create")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task Create( + [FromForm(Name = "TargetAccountId")] long? targetAccountId, + [FromForm(Name = "Type")] int type = 0) + { + if (targetAccountId is not long target || target <= 0) + return Ok(new { success = false, error = "missing_target" }); + if (Me == target) + return Ok(new { success = false, error = "cannot_cheer_self" }); + + var existing = await db.Cheers.FirstOrDefaultAsync(c => + c.FromPlayerId == Me && c.TargetPlayerId == target && + c.TargetRoomId == 0 && c.Type == type); + if (existing is not null) return Ok(new { success = true, already_cheered = true }); + + db.Cheers.Add(new CheerEntity + { + FromPlayerId = Me, + TargetPlayerId = target, + Type = type, + }); + await db.SaveChangesAsync(); + await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}"); + await notifications.NotifyAsync(target, + PushNotificationId.SubscriptionUpdateProfile, + new { Reason = "CheerReceived", From = Me, Type = type }); + return Ok(new { success = true, error = "" }); + } + + /// POST api/PlayerCheer/v1/SetSelectedCheer — pin + /// a cheer category as the caller's selected badge. Stored on the + /// PlayerSetting key SelectedCheer (string-form int). + [HttpPost("api/PlayerCheer/v1/SetSelectedCheer")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task SetSelectedCheer([FromForm(Name = "Cheer")] int cheer = 0) + { + var existing = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer"); + if (existing is null) + db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = cheer.ToString() }); + else + existing.Value = cheer.ToString(); + await db.SaveChangesAsync(); + return Ok(new { success = true, error = "" }); + } + + /// GET api/PlayerCheer/v1/all — every cheer the + /// caller has received. Drives the profile's "X cheers" badge. + [HttpGet("api/PlayerCheer/v1/all")] + public async Task All() + { + var rows = await db.Cheers + .Where(c => c.TargetPlayerId == Me) + .OrderByDescending(c => c.CheeredAt) + .Take(200) + .Select(c => new + { + Id = c.Id, + FromPlayerId = c.FromPlayerId, + Type = c.Type, + CheeredAt = c.CheeredAt, + }) + .ToListAsync(); + return Ok(rows); + } +} diff --git a/DorkNet.Server/Controllers/API/PlayerElo/PlayerEloController.cs b/DorkNet.Server/Controllers/API/PlayerElo/PlayerEloController.cs new file mode 100644 index 0000000..6a89a8f --- /dev/null +++ b/DorkNet.Server/Controllers/API/PlayerElo/PlayerEloController.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.PlayerElo; + +/// +/// api.rec.net/api/PlayerElo/* — match-result Elo deltas. The 2020 +/// client posts after each ranked match (Royale, Paintball, Soccer) +/// with a delta the server applies to the per-player Elo row. +/// +/// Wire request matches RecNet.Elo.ReportPlayerElo: +/// {GameMode(int), EloDelta(int), Won(bool)} +/// (we tolerate a few synonym fields). Response: the new Elo as a +/// JSON primitive int. +/// +[ApiController] +public class PlayerEloController(DorkNetDbContext db) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + public sealed class ReportEloRequest + { + public int GameMode { get; set; } + public int EloDelta { get; set; } + public bool Won { get; set; } + } + + [HttpPost("api/PlayerElo/v1/reportPlayerElo")] + [Authorize] + public async Task ReportPlayerElo([FromBody] ReportEloRequest req) + { + var pid = Me; + var row = await db.PlayerElo.FirstOrDefaultAsync(p => + p.PlayerId == pid && p.GameMode == req.GameMode); + if (row is null) + { + row = new PlayerEloEntity + { + PlayerId = pid, + GameMode = req.GameMode, + }; + db.PlayerElo.Add(row); + } + // Clamp Elo to a reasonable range to avoid overflow exploits. + row.Elo = Math.Clamp(row.Elo + req.EloDelta, 0, 5000); + row.MatchesPlayed += 1; + if (req.Won) row.Wins += 1; else row.Losses += 1; + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(row.Elo); + } + + [HttpGet("api/PlayerElo/v1/get")] + public async Task Get( + [FromQuery] long? playerId, [FromQuery] int gameMode = 0) + { + var pid = playerId ?? this.CurrentPlayerId() ?? 0; + if (pid == 0) return Ok(new { Elo = 1000, Wins = 0, Losses = 0 }); + var row = await db.PlayerElo.FirstOrDefaultAsync(p => + p.PlayerId == pid && p.GameMode == gameMode); + return Ok(row is null + ? new { Elo = 1000, Wins = 0, Losses = 0 } + : new { row.Elo, row.Wins, row.Losses }); + } +} diff --git a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs new file mode 100644 index 0000000..4fe7fd8 --- /dev/null +++ b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs @@ -0,0 +1,319 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.PlayerEvents; + +/// +/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for +/// player-scheduled gatherings. +/// +[ApiController] +[Authorize] +public class PlayerEventsController( + DorkNetDbContext db, + NotificationService notifications) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + // ── Browse (v2) ────────────────────────────────────────────────────── + + /// GET api/playerevents/v2 — upcoming events list. + /// Returns the next 50 events ordered by start time. Wire shape + /// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId, + /// Title, Description, StartsAt, EndsAt, Capacity). + [AllowAnonymous] + [HttpGet("api/playerevents/v2")] + public async Task ListUpcoming( + [FromQuery] int skip = 0, [FromQuery] int take = 50) + { + skip = Math.Max(0, skip); + take = Math.Clamp(take, 1, 100); + var rows = await db.PlayerEvents + .Where(e => e.EndsAt > DateTime.UtcNow) + .OrderBy(e => e.StartsAt) + .Skip(skip) + .Take(take) + .ToListAsync(); + return Ok(rows.Select(ToWire)); + } + + [AllowAnonymous] + [HttpGet("api/playerevents/v2/{eventId:long}")] + public async Task GetOne(long eventId) + { + var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); + if (ev is null) return NotFound(); + return Ok(ToWire(ev)); + } + + /// GET api/playerevents/v1/all — the watch's + /// LocalPlayerEventInfo fetch (caller's created + RSVP'd + /// events). Response keys (both required, via + /// Util.GetObjectListKey): Created, Responses. + [AllowAnonymous] + [HttpGet("api/playerevents/v1/all")] + public async Task AllPlayerEvents() + { + var pid = this.CurrentPlayerId(); + if (pid is not long me) + return Ok(new { Created = Array.Empty(), Responses = Array.Empty() }); + + var created = await db.PlayerEvents + .Where(e => e.CreatorPlayerId == me) + .OrderByDescending(e => e.StartsAt) + .Select(e => new + { + e.Id, + e.Title, + e.Description, + e.RoomId, + e.StartsAt, + e.EndsAt, + e.Capacity, + }) + .ToListAsync(); + + var responses = await db.PlayerEventResponses + .Where(r => r.PlayerId == me) + .Select(r => new + { + r.Id, + r.EventId, + r.Response, + r.CreatedAt, + }) + .ToListAsync(); + + return Ok(new { Created = created, Responses = responses }); + } + + /// POST api/playerevents/v1/{eventId}/responses — + /// alternate RSVP route the watch uses for the response-list + /// flow (older / sub-room view). Body is the same RsvpRequest + /// as v1/{id}/rsvp; behaves identically. + [HttpPost("api/playerevents/v1/{eventId:long}/responses")] + public Task RespondList(long eventId, [FromBody] RsvpRequest body) + => Rsvp(eventId, body); + + /// Wire shape verified against + /// Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815: + /// PascalCase keys PlayerEventId, Name, Description, + /// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId, + /// ImageName, Accessibility. Server-side we store these + /// under different names (Title, StartsAt, EndsAt, Capacity) — + /// remap explicitly here so the watch's + /// PlayerEvent.Deserialize picks them up. + private static object ToWire(PlayerEventEntity ev) => new + { + PlayerEventId = ev.Id, + Name = ev.Title, + ev.Description, + StartTime = ev.StartsAt, + EndTime = ev.EndsAt, + CreatorPlayerId = (int)ev.CreatorPlayerId, + AttendeeCount = 0, // computed at query time when needed + ev.RoomId, + ImageName = string.Empty, + Accessibility = 1, // Public + }; + + public sealed record CreateEventRequest( + string Title, string? Description, long RoomId, + DateTime StartsAt, DateTime EndsAt, int Capacity); + + [HttpPost("api/playerevents/v1/create")] + [HttpPost("api/playerevents/v1")] + public async Task Create([FromBody] CreateEventRequest req) + { + if (string.IsNullOrWhiteSpace(req.Title)) + return BadRequest("missing_title"); + var ev = new PlayerEventEntity + { + CreatorPlayerId = Me, + RoomId = req.RoomId, + Title = req.Title.Trim(), + Description = (req.Description ?? string.Empty).Trim(), + StartsAt = req.StartsAt, + EndsAt = req.EndsAt > req.StartsAt ? req.EndsAt : req.StartsAt.AddHours(1), + Capacity = Math.Max(0, req.Capacity), + }; + db.PlayerEvents.Add(ev); + await db.SaveChangesAsync(); + return Ok(new + { + ev.Id, ev.Title, ev.Description, ev.RoomId, + ev.StartsAt, ev.EndsAt, ev.Capacity, + }); + } + + public sealed record RsvpRequest(int Response); + + [HttpPost("api/playerevents/v1/{eventId:long}/rsvp")] + public async Task Rsvp(long eventId, [FromBody] RsvpRequest body) + { + if (body.Response < 0 || body.Response > 2) + return BadRequest("invalid_response"); + var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); + if (ev is null) return NotFound(); + + var row = await db.PlayerEventResponses + .FirstOrDefaultAsync(r => r.EventId == eventId && r.PlayerId == Me); + if (row is null) + { + row = new PlayerEventResponseEntity { EventId = eventId, PlayerId = Me }; + db.PlayerEventResponses.Add(row); + } + row.Response = body.Response; + await db.SaveChangesAsync(); + + // Tell the creator someone RSVP'd. They can ignore but the + // watch will refresh their event detail card. + if (ev.CreatorPlayerId != Me) + await notifications.NotifyAsync(ev.CreatorPlayerId, + PushNotificationId.PlayerEventResponseChanged, + new { ev.Id, From = Me, body.Response }); + return Ok(new { row.EventId, row.PlayerId, row.Response }); + } + + /// POST api/playerevents/v1/respond — set the + /// caller's RSVP state for an event. Response is the + /// PlayerEventResponseType enum (0=Going, 1=Maybe, 2=Pass). + /// Idempotent — re-responding overwrites. + [HttpPost("api/playerevents/v1/respond")] + public async Task RespondForm( + [FromForm(Name = "EventId")] long? eventId, + [FromForm(Name = "Response")] int response = 0) + { + if (eventId is not long evt || evt <= 0) return Ok(new { success = false }); + var existing = await db.PlayerEventResponses + .FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt); + if (existing is null) + db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response }); + else + existing.Response = response; + await db.SaveChangesAsync(); + return Ok(new { success = true, error = "" }); + } + + /// POST api/playerevents/v1/deleteResponse — drop + /// the caller's RSVP row. + [HttpPost("api/playerevents/v1/deleteResponse")] + public async Task DeleteResponseForm([FromForm(Name = "EventId")] long? eventId) + { + if (eventId is not long evt) return Ok(new { success = true }); + await db.PlayerEventResponses + .Where(r => r.PlayerId == Me && r.EventId == evt) + .ExecuteDeleteAsync(); + return Ok(new { success = true, error = "" }); + } + + /// DELETE api/playerevents/v2/delete/{id} — hard + /// delete an event the caller created. + [HttpDelete("api/playerevents/v2/delete/{id:long}")] + public async Task DeleteByPath(long id) + { + var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me); + if (evt is null) return NotFound(); + db.PlayerEvents.Remove(evt); + await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync(); + await db.SaveChangesAsync(); + return Ok(new { success = true, error = "" }); + } + + [HttpDelete("api/playerevents/v1/{eventId:long}")] + public async Task Delete(long eventId) + { + var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); + if (ev is null) return NotFound(); + if (ev.CreatorPlayerId != Me) return Forbid(); + db.PlayerEvents.Remove(ev); + // Cascade-clean the responses; SQLite has no FK cascade + // configured here so do it manually. + var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId); + db.PlayerEventResponses.RemoveRange(responses); + await db.SaveChangesAsync(); + return Ok(); + } + + // ── Bulk-invite (Phase 8) ──────────────────────────────────────────── + + public sealed class BulkInviteRequest + { + public long PlayerEventId { get; set; } + public List? InvitedPlayerIds { get; set; } + } + + /// POST /api/playerevents/v1/bulkInvite — wire + /// type per agent ISIL extraction: BulkInviteRequest{PlayerEventId, + /// InvitedPlayerIds:List<int>}; response + /// BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}. + /// We insert one MessageEntity per recipient so the watch's inbox + /// shows the invite, and push a per-recipient notification. + [HttpPost("api/playerevents/v1/bulkInvite")] + public async Task BulkInvite([FromBody] BulkInviteRequest req) + { + if (req?.InvitedPlayerIds is null || req.InvitedPlayerIds.Count == 0) + return Ok(new { FailedInvites = Array.Empty(), Result = 0 }); + + var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId); + if (ev is null) return NotFound(); + if (ev.CreatorPlayerId != Me) return Forbid(); + + var failed = new List(); + var sender = Me; + var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}."; + foreach (var rid in req.InvitedPlayerIds.Distinct()) + { + var exists = await db.Players.AnyAsync(p => p.Id == rid); + if (!exists) + { + failed.Add(new { PlayerId = rid, Error = "unknown player" }); + continue; + } + db.Messages.Add(new MessageEntity + { + SenderPlayerId = sender, + RecipientPlayerId = rid, + Body = inviteText, + }); + await notifications.NotifyAsync(rid, + PushNotificationId.PlayerEventCreated, + new { ev.Id, ev.Title, ev.StartsAt, From = sender }); + } + await db.SaveChangesAsync(); + return Ok(new { FailedInvites = failed, Result = 0 }); + } + + // ── Report (Phase 8) ───────────────────────────────────────────────── + + public sealed class PlayerEventReportRequest + { + public int ReportCategory { get; set; } + public long PlayerEventId { get; set; } + public string? Details { get; set; } + } + + [HttpPost("api/playerevents/v1/report")] + public async Task Report([FromBody] PlayerEventReportRequest req) + { + var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId); + if (ev is null) return NotFound(); + var msg = (req.Details ?? string.Empty).Trim(); + db.Reports.Add(new ReportEntity + { + ReporterPlayerId = Me, + TargetPlayerId = ev.CreatorPlayerId, + TargetEventId = req.PlayerEventId, + Category = req.ReportCategory, + Message = msg[..Math.Min(1000, msg.Length)], + }); + await db.SaveChangesAsync(); + return Ok(new { Reported = true }); + } +} diff --git a/DorkNet.Server/Controllers/API/PlayerReporting/ReportsController.cs b/DorkNet.Server/Controllers/API/PlayerReporting/ReportsController.cs new file mode 100644 index 0000000..ebb1745 --- /dev/null +++ b/DorkNet.Server/Controllers/API/PlayerReporting/ReportsController.cs @@ -0,0 +1,210 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.PlayerReporting; + +/// +/// api.rec.net/api/PlayerReporting/v1/* — real player report flow. +/// The 2020 client's "report player" UI in the watch posts here. +/// Form fields the watch sends (per the in-game send_report dialog): +/// TargetPlayerId — int account id of the offender +/// Type — RoomieReportCategory enum (1..5) +/// Message — free-form description (capped at 1000) +/// GameSessionId — optional, current session for context +/// RoomId — optional, room the offence happened in +/// +/// Anyone authenticated can file a report. Admins read the queue +/// from GET api/admin/v1/reports. +/// +[ApiController] +[Authorize] +public class ReportsController(DorkNetDbContext db) : ControllerBase +{ + [HttpPost("api/PlayerReporting/v1/report")] + [HttpPost("api/PlayerReporting/v1/playerreport")] + [HttpPost("api/PlayerReporting/v2/report")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task SubmitReport( + [FromForm(Name = "TargetPlayerId")] long? targetPlayerId, + [FromForm(Name = "Type")] int type = 0, + [FromForm(Name = "Message")] string? message = null, + [FromForm(Name = "GameSessionId")] long gameSessionId = 0, + [FromForm(Name = "RoomId")] long roomId = 0) + { + if (targetPlayerId is not long target || target <= 0) + return BadRequest(new { success = false, error = "missing_target" }); + + var reporter = this.RequireCurrentPlayerId(); + if (reporter == target) + return BadRequest(new { success = false, error = "cannot_report_self" }); + + // Cap message length to match the watch UI's enforcement + + // the column max — anything longer is almost certainly junk. + var trimmed = (message ?? string.Empty).Trim(); + if (trimmed.Length > 1000) trimmed = trimmed[..1000]; + + db.Reports.Add(new ReportEntity + { + ReporterPlayerId = reporter, + TargetPlayerId = target, + Category = type, + Message = trimmed, + GameSessionId = gameSessionId, + RoomId = roomId, + }); + await db.SaveChangesAsync(); + + return Ok(new { success = true, error = "" }); + } + + /// GET api/PlayerReporting/v2/detail?playerId={id} + /// — moderator detail panel. Lists recent reports against the + /// target player. Public — admin-only mutations are elsewhere. + [HttpGet("api/PlayerReporting/v2/detail")] + public async Task ReportDetail( + [FromQuery] long? playerId, [FromQuery] long? accountId) + { + var pid = playerId ?? accountId ?? 0; + if (pid <= 0) return Ok(new { PlayerId = 0L, OpenReports = 0, RecentReports = Array.Empty() }); + var reports = await db.Reports + .Where(r => r.TargetPlayerId == pid) + .OrderByDescending(r => r.CreatedAt) + .Take(20) + .ToListAsync(); + return Ok(new + { + PlayerId = pid, + OpenReports = reports.Count(r => r.ResolvedAt == null), + RecentReports = reports.Select(r => new + { + r.Id, r.Category, r.Message, + r.ReporterPlayerId, r.RoomId, r.CreatedAt, r.ResolvedAt, + }), + }); + } + + public sealed class V2BanRequest + { + public long PlayerId { get; set; } + public int? Category { get; set; } + public string? Reason { get; set; } + public DateTime? Until { get; set; } + } + + /// POST api/PlayerReporting/v2/ban — admin-only. + /// Sets + appends a + /// resolved for the audit trail. + [HttpPost("api/PlayerReporting/v2/ban")] + [Authorize] + public async Task V2Ban([FromBody] V2BanRequest req) + { + var me = this.RequireCurrentPlayerId(); + if (req.PlayerId <= 0) return BadRequest(new { error = "missing playerId" }); + + var isAdmin = await db.Players + .Where(p => p.Id == me) + .Select(p => p.IsAdmin) + .FirstOrDefaultAsync(); + if (!isAdmin) return Forbid(); + + var target = await db.Players.FirstOrDefaultAsync(p => p.Id == req.PlayerId); + if (target is null) return NotFound(); + target.BannedUntil = req.Until ?? DateTime.UtcNow.AddYears(100); + var reason = req.Reason ?? string.Empty; + if (reason.Length > 1000) reason = reason[..1000]; + db.Reports.Add(new ReportEntity + { + ReporterPlayerId = me, + TargetPlayerId = req.PlayerId, + Category = req.Category ?? 5, + Message = reason, + ResolvedAt = DateTime.UtcNow, + ResolverAdminId = me, + ResolutionNote = "v2/ban", + }); + await db.SaveChangesAsync(); + return Ok(new { Banned = true, target.Id, target.BannedUntil }); + } + + public sealed class HileRequest + { + public string? Detail { get; set; } + public string? Body { get; set; } + public string? Message { get; set; } + public int? Type { get; set; } + public long? GameSessionId { get; set; } + } + + /// POST api/PlayerReporting/v1/hile — anti-cheat + /// heartbeat. Stored as a tagged + /// hile so admins can audit the stream. + [HttpPost("api/PlayerReporting/v1/hile")] + public async Task Hile() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + return await SaveHileAsync( + form["Message"].FirstOrDefault() ?? + form["Detail"].FirstOrDefault() ?? + form["Body"].FirstOrDefault(), + int.TryParse(form["Type"].FirstOrDefault(), out var t) ? t : (int?)null, + long.TryParse(form["GameSessionId"].FirstOrDefault(), out var g) ? g : (long?)null); + } + + HileRequest? body = null; + try + { + body = await JsonSerializer.DeserializeAsync( + Request.Body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException) { } + + return await SaveHileAsync( + body?.Message ?? body?.Detail ?? body?.Body, + body?.Type, + body?.GameSessionId); + } + + /// POST api/PlayerReporting/v1/log — bare client + /// telemetry log. Persisted as a low-priority BugReport for admin + /// visibility but never acted on automatically. + [HttpPost("api/PlayerReporting/v1/log")] + public async Task ReportLog() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + await SaveHileAsync( + form["Message"].FirstOrDefault() ?? form["Body"].FirstOrDefault(), + null, null); + } + return Ok(new { success = true, error = "" }); + } + + private async Task SaveHileAsync(string? rawDetail, int? type, long? gameSessionId) + { + var pid = this.CurrentPlayerId(); + if (pid is not long me) return Ok(new { success = true }); + var detail = (rawDetail ?? string.Empty).Trim(); + if (type is int hileType) + detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}"; + if (detail.Length > 4000) detail = detail[..4000]; + db.BugReports.Add(new BugReportEntity + { + ReporterPlayerId = me, + Title = "hile heartbeat", + Body = detail, + GameSessionId = gameSessionId ?? 0, + Category = "hile", + }); + await db.SaveChangesAsync(); + return Ok(new { success = true }); + } +} diff --git a/DorkNet.Server/Controllers/API/PlayerState/PlayerStateController.cs b/DorkNet.Server/Controllers/API/PlayerState/PlayerStateController.cs new file mode 100644 index 0000000..44e7cc3 --- /dev/null +++ b/DorkNet.Server/Controllers/API/PlayerState/PlayerStateController.cs @@ -0,0 +1,227 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Models.Players; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.PlayerState; + +/// +/// api.rec.net endpoints that return player-state objects whose Deserialize +/// methods do strict required-key reads via Util.GetKey<T>. The previous +/// catch-all returned {} or {"Success":true} for these, which crashed the +/// client with KeyNotFoundException at: +/// RecNet.Progression.Deserialize → "PlayerId", "Level", "XP" +/// RecNet.Reputation.Deserialize → "AccountId", "Noteriety" (sic), Cheer*, Sub* +/// RecNet.ModerationBlockDetail.Deserialize → "ReportCategory", "Duration", "GameSessionId", "Message" +/// +/// Each endpoint here returns the minimum-viable shape (zeros / empty +/// strings) that satisfies every required key. Real values can be plugged +/// in later when persistence is added for these. +/// +/// Specific routes here win over the api/PlayerReporting/{*path} and +/// api.rec.net catch-alls. +/// +[ApiController] +public class PlayerStateController( + DorkNetDbContext db, + NotificationService notifications, + LevelService level) : ControllerBase +{ + // RecNet.Progressions.GetProgressionById / GetMyProgression — real + // values from PlayerEntity.Level / .XP. Default 1/0 when the + // account hasn't been seen yet (synthetic ids passed in queries). + [HttpGet("api/players/v1/progression/{accountId:long}")] + public async Task> GetProgression(long accountId) + { + var p = await db.Players + .Where(p => p.Id == accountId) + .Select(p => new { p.Id, p.Level, p.XP }) + .FirstOrDefaultAsync(); + return Ok(new Progression + { + PlayerId = (int)(p?.Id ?? accountId), + Level = p?.Level ?? 1, + XP = p?.XP ?? 0, + }); + } + + [HttpPost("api/players/v1/progression/bulk")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task>> GetProgressionBulk( + [FromForm(Name = "Ids")] string? ids) + { + var idList = ParseIds(ids).Select(i => (long)i).ToList(); + if (idList.Count == 0) return Ok(new List()); + var rows = await db.Players + .Where(p => idList.Contains(p.Id)) + .Select(p => new Progression + { + PlayerId = (int)p.Id, + Level = p.Level, + XP = p.XP, + }) + .ToListAsync(); + // Synthesise zeros for any ids the watch asked about that + // don't exist in our DB — keeps array length stable. + var found = rows.Select(r => r.PlayerId).ToHashSet(); + rows.AddRange(idList + .Where(id => !found.Contains((int)id)) + .Select(id => new Progression { PlayerId = (int)id })); + return Ok(rows); + } + + // RecNet.Reputations.GetReputationById — aggregates CheerEntity + // by category. Each Cheer* count is the sum of distinct cheers + // received from other players in that category. + [HttpGet("api/playerReputation/v1/{accountId:long}")] + public async Task> GetReputation(long accountId) => + Ok(await BuildReputationAsync(accountId)); + + [HttpPost("api/playerReputation/v1/bulk")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task>> GetReputationBulk( + [FromForm(Name = "Ids")] string? ids) + { + var idList = ParseIds(ids).Select(i => (long)i).ToList(); + var result = new List(idList.Count); + foreach (var id in idList) + result.Add(await BuildReputationAsync(id)); + return Ok(result); + } + + /// POST api/playerReputation/v1/cheer/{playerId} — cheer + /// another player. Idempotent on (caller, target, type) so + /// repeat-clicking the same category doesn't inflate the count. + /// Pushes a presence-update notification so the target's watch + /// refreshes their cheer total. + [HttpPost("api/playerReputation/v1/cheer/{playerId:long}")] + [Authorize] + public async Task CheerPlayer(long playerId, [FromQuery] int type = 0) + { + var me = this.RequireCurrentPlayerId(); + if (me == playerId) return BadRequest("cannot_cheer_self"); + + var existing = await db.Cheers.FirstOrDefaultAsync(c => + c.FromPlayerId == me && c.TargetPlayerId == playerId && + c.TargetRoomId == 0 && c.Type == type); + if (existing is not null) return Ok(new { already_cheered = true }); + + db.Cheers.Add(new CheerEntity + { + FromPlayerId = me, + TargetPlayerId = playerId, + Type = type, + }); + await db.SaveChangesAsync(); + + // Reward the cheered player with a small XP bump. + await level.AwardXpAsync(playerId, LevelService.CheerReceivedXp, $"cheer_from:{me}"); + + await notifications.NotifyAsync(playerId, + PushNotificationId.SubscriptionUpdateProfile, + new { Reason = "CheerReceived", From = me, Type = type }); + return Ok(); + } + + /// POST api/rooms/v1/cheer/{roomId} — cheer a room. + /// Same idempotency rules as player cheers; bumps + /// as a denormalised counter + /// so the watch's "Hot" rooms feed doesn't have to JOIN-aggregate + /// on every request. + [HttpPost("api/rooms/v1/cheer/{roomId:long}")] + [Authorize] + public async Task CheerRoom(long roomId, [FromQuery] int type = 0) + { + var me = this.RequireCurrentPlayerId(); + + var existing = await db.Cheers.FirstOrDefaultAsync(c => + c.FromPlayerId == me && c.TargetRoomId == roomId && + c.TargetPlayerId == 0 && c.Type == type); + if (existing is not null) return Ok(new { already_cheered = true }); + + db.Cheers.Add(new CheerEntity + { + FromPlayerId = me, + TargetRoomId = roomId, + Type = type, + }); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + if (room is not null) + { + room.CheerCount += 1; + // Cheers feed the Hot-tab ranking — weight 5x a first + // visit so the "Trending" feed reflects what players + // actually like, not just what they passed through. + room.HotScore += 10.0; + } + await db.SaveChangesAsync(); + return Ok(); + } + + private async Task BuildReputationAsync(long accountId) + { + var rows = await db.Cheers + .Where(c => c.TargetPlayerId == accountId) + .GroupBy(c => c.Type) + .Select(g => new { Type = g.Key, Count = g.Count() }) + .ToListAsync(); + int Count(int type) => rows.FirstOrDefault(r => r.Type == type)?.Count ?? 0; + + var subscriberCount = await db.Subscriptions + .CountAsync(s => s.TargetPlayerId == accountId); + var subscribedCount = await db.Subscriptions + .CountAsync(s => s.SubscriberPlayerId == accountId); + + // SelectedCheer is the pinned badge category the player chose + // via PlayerCheerController.SetSelectedCheer. Stored as a + // PlayerSettingEntity with key "SelectedCheer" and Value = the + // category int as string. Null when unset — UI just shows no + // pinned badge. + var selected = await db.PlayerSettings + .Where(s => s.PlayerId == accountId && s.Key == "SelectedCheer") + .Select(s => s.Value) + .FirstOrDefaultAsync(); + int? selectedCheer = int.TryParse(selected, out var sc) ? sc : null; + + return new Reputation + { + AccountId = (int)accountId, + Notoriety = rows.Sum(r => r.Count), + CheerGeneral = Count(0), + CheerHelpful = Count(1), + CheerGreatHost = Count(2), + CheerSportsman = Count(3), + CheerCreative = Count(4), + CheerCredit = Count(5), + SelectedCheer = selectedCheer, + SubscriberCount = subscriberCount, + SubscribedCount = subscribedCount, + }; + } + + /// + /// "1,2,3" → [1, 2, 3]. The client formats Ids as a comma-separated + /// string when calling AddField on the form. Tolerant of empty input + /// and whitespace. + /// + private static IEnumerable ParseIds(string? ids) => + string.IsNullOrWhiteSpace(ids) + ? Enumerable.Empty() + : ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.TryParse(s, out var v) ? v : 0) + .Where(v => v != 0); + + // RecNet.PlayerReporting.GetModerationBlockDetails + // IMPORTANT: returns a single object, not an array. Defaults indicate + // "user is not currently blocked / kicked". + // ASP.NET Core route matching is case-insensitive, so a single attribute + // covers both "PlayerReporting" and "playerreporting" callers. + [HttpGet("api/PlayerReporting/v1/moderationBlockDetails")] + public ActionResult GetModerationBlockDetails() => + Ok(new ModerationBlockDetail()); +} diff --git a/DorkNet.Server/Controllers/API/PlayerSubscriptions/PlayerSubscriptionsController.cs b/DorkNet.Server/Controllers/API/PlayerSubscriptions/PlayerSubscriptionsController.cs new file mode 100644 index 0000000..3a7a0ad --- /dev/null +++ b/DorkNet.Server/Controllers/API/PlayerSubscriptions/PlayerSubscriptionsController.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.PlayerSubscriptions; + +/// +/// api.rec.net/api/playersubscriptions/v1/* — asymmetric follow +/// graph. The my endpoint returns who the caller follows; +/// subscribe/unsubscribe mutate the row directly. Reputation +/// aggregates pull SubscriberCount / SubscribedCount counts from +/// here. +/// +[ApiController] +[Route("api/[controller]/v1")] +[Authorize] +public class PlayerSubscriptionsController( + DorkNetDbContext db, + NotificationService notifications) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + [HttpGet("my")] + public async Task My() + { + var subs = await db.Subscriptions + .Where(s => s.SubscriberPlayerId == Me) + .Select(s => new { s.TargetPlayerId, s.CreatedAt }) + .ToListAsync(); + return Ok(subs); + } + + [HttpGet("{playerId:long}/subscribers")] + public async Task Subscribers(long playerId) + { + var subs = await db.Subscriptions + .Where(s => s.TargetPlayerId == playerId) + .Select(s => new { s.SubscriberPlayerId, s.CreatedAt }) + .ToListAsync(); + return Ok(subs); + } + + [HttpPost("subscribe/{targetId:long}")] + public async Task Subscribe(long targetId) + { + if (targetId == Me) return BadRequest("cannot_subscribe_to_self"); + + var existing = await db.Subscriptions.FirstOrDefaultAsync(s => + s.SubscriberPlayerId == Me && s.TargetPlayerId == targetId); + if (existing is not null) return Ok(new { already_subscribed = true }); + + db.Subscriptions.Add(new SubscriptionEntity + { + SubscriberPlayerId = Me, + TargetPlayerId = targetId, + }); + await db.SaveChangesAsync(); + + // Tell the target their subscriber count changed so the watch + // refreshes their profile page if open. + await notifications.NotifyAsync(targetId, + PushNotificationId.SubscriptionUpdateProfile, + new { Reason = "NewSubscriber", From = Me }); + return Ok(); + } + + [HttpDelete("subscribe/{targetId:long}")] + [HttpPost("unsubscribe/{targetId:long}")] + public async Task Unsubscribe(long targetId) + { + var row = await db.Subscriptions.FirstOrDefaultAsync(s => + s.SubscriberPlayerId == Me && s.TargetPlayerId == targetId); + if (row is null) return NotFound(); + db.Subscriptions.Remove(row); + await db.SaveChangesAsync(); + return Ok(); + } +} diff --git a/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs b/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs new file mode 100644 index 0000000..38c377d --- /dev/null +++ b/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using DorkNet.Models.Players; +using DorkNet.Server.Auth; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Players.V2; + +[ApiController] +[Route("api/[controller]/v2")] +[Authorize] +public class PlayersController(PlayerService playerService, NotificationService notificationService) : ControllerBase +{ + private long CurrentPlayerId => this.RequireCurrentPlayerId(); + + [HttpGet("me")] + public async Task> GetSelf() + { + var player = await playerService.GetByIdAsync(CurrentPlayerId); + if (player is null) return NotFound(); + return Ok(PlayerService.ToProfile(player)); + } + + [HttpGet("{id:long}")] + public async Task> GetById(long id) + { + var player = await playerService.GetByIdAsync(id); + if (player is null) return NotFound(); + return Ok(PlayerService.ToProfile(player)); + } + + [HttpGet("search")] + public async Task>> Search([FromQuery] string query) + { + if (string.IsNullOrWhiteSpace(query)) return Ok(new List()); + var results = await playerService.SearchAsync(query); + return Ok(results.Select(PlayerService.ToProfile).ToList()); + } + + [HttpPost("displayName")] + public async Task> UpdateDisplayName([FromForm] string? DisplayName) + { + if (string.IsNullOrWhiteSpace(DisplayName)) + return BadRequest("DisplayName is required"); + + var updated = await playerService.UpdateDisplayNameAsync(CurrentPlayerId, DisplayName); + if (!updated) return NotFound(); + + var player = await playerService.GetByIdAsync(CurrentPlayerId); + await notificationService.NotifyAsync(CurrentPlayerId, Models.Notification.PushNotificationId.SubscriptionUpdateProfile, PlayerService.ToProfile(player!)); + return Ok(PlayerService.ToProfile(player!)); + } + + [HttpPut("{id:long}")] + public async Task> UpdateProfile(long id, [FromBody] UpdateProfileRequest req) + { + if (id != CurrentPlayerId) return Forbid(); + + if (req.DisplayName is not null) + await playerService.UpdateDisplayNameAsync(id, req.DisplayName); + + if (req.Bio is not null) + await playerService.UpdateBioAsync(id, req.Bio); + + var player = await playerService.GetByIdAsync(id); + return Ok(PlayerService.ToProfile(player!)); + } +} diff --git a/DorkNet.Server/Controllers/API/PlayersBanned/PlayersBannedController.cs b/DorkNet.Server/Controllers/API/PlayersBanned/PlayersBannedController.cs new file mode 100644 index 0000000..ac34632 --- /dev/null +++ b/DorkNet.Server/Controllers/API/PlayersBanned/PlayersBannedController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Controllers.API.PlayersBanned; + +/// +/// api.{rec.net,localhost}/api/PlayersBanned/v1/all — current global +/// ban list. The watch's mod panel queries this on open. +/// +[ApiController] +public class PlayersBannedController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/PlayersBanned/v1/all")] + public async Task All() + { + var now = DateTime.UtcNow; + var rows = await db.Players + .Where(p => p.BannedUntil != null && p.BannedUntil > now) + .Select(p => new + { + PlayerId = (int)p.Id, + p.Username, + BannedUntil = p.BannedUntil, + }) + .ToListAsync(); + return Ok(rows); + } +} diff --git a/DorkNet.Server/Controllers/API/Progression/ProgressionController.cs b/DorkNet.Server/Controllers/API/Progression/ProgressionController.cs new file mode 100644 index 0000000..192f8a8 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Progression/ProgressionController.cs @@ -0,0 +1,554 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.ProgressionApi; + +/// +/// api.rec.net/api/{challenge,checklist,objectives}/* — daily +/// challenges, onboarding checklist, and the full objectives surface +/// (myprogress, cleargroup, updateobjective, completegroup). All +/// concepts share the same persistence model: +/// rows keyed by a prefix: +/// challenge:{id}, checklist:{id}, group:{id}, +/// obj:{group}:{index}. +/// +[ApiController] +public class ProgressionController(DorkNetDbContext db, LevelService level) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + private long? MeOrNull => this.CurrentPlayerId(); + private const int RecCenterTokens = 2; + + // ── Challenges ─────────────────────────────────────────────────────── + + /// GET api/challenge/v{1,2}/getCurrent — returns + /// the active daily-challenge map (NOT a flat challenge object). + /// Verified against + /// Cpp2IL_ISIL/.../RecNet/Challenges.txt:105-184 + /// (GetCurrentChallenges hits this URL and parses as + /// ChallengeMap). Wire shape per + /// RecNet/ChallengeMap.txt:540-616: + /// + /// ChallengeMapId (int, required), CompletedRequired + /// (bool, required), StartAt/EndAt/ServerTime + /// (DateTime, required), Challenges (List<RecNetChallenge>, + /// required), Gift (ChallengeGift, required), + /// ChallengeThemeString (string, optional). + /// + /// Each RecNetChallenge requires only ChallengeId; + /// Name/Config/Description/Tooltip/Complete + /// are optional. Each ChallengeGift requires GiftDropId, + /// Xp, Level, GiftContext, GiftRarity. + /// + /// Previous flat-challenge response crashed + /// ChallengeMap.Deserialize at Util.GetKey("ChallengeMapId"). + [HttpGet("api/challenge/v1/getCurrent")] + [HttpGet("api/challenge/v2/getCurrent")] + public async Task CurrentChallenge() + { + var pid = MeOrNull; + var now = DateTime.UtcNow; + var start = now.Date.AddDays(-(int)now.DayOfWeek); + var mapId = CurrentChallengeMapId(); + + var keys = new[] { $"challenge:{mapId}:0", $"challenge:{mapId}:1", $"challenge:{mapId}:2" }; + var rows = pid is long me + ? await db.ObjectiveProgress + .Where(o => o.PlayerId == me && keys.Contains(o.Key)) + .ToDictionaryAsync(o => o.Key, o => o.IsCompleted) + : new Dictionary(); + + bool Done(int idx) => rows.TryGetValue(keys[idx], out var v) && v; + + return Ok(new + { + ChallengeMapId = mapId, + CompletedRequired = false, + StartAt = start, + EndAt = start.AddDays(7), + ServerTime = now, + ChallengeThemeString = "Weekly", + Challenges = new[] + { + new { ChallengeId = mapId * 10 + 0, Name = "Visit any room", + Config = "{\"Goal\":1}", Description = "Step into any room this week.", + Tooltip = "", Complete = Done(0) }, + new { ChallengeId = mapId * 10 + 1, Name = "Cheer a player", + Config = "{\"Goal\":1}", Description = "Send a cheer to anyone you meet this week.", + Tooltip = "", Complete = Done(1) }, + new { ChallengeId = mapId * 10 + 2, Name = "Finish an activity", + Config = "{\"Goal\":1}", Description = "Complete any activity this week.", + Tooltip = "", Complete = Done(2) }, + }, + Gift = new + { + GiftDropId = mapId, + AvatarItemDesc = "", + ConsumableItemDesc = "", + EquipmentPrefabName = "", + EquipmentModificationGuid = "", + Xp = 100, + Level = 1, + GiftContext = 0, + GiftRarity = 0, + }, + }); + } + + public sealed class UpdateChallengeRequest + { + public int Id { get; set; } + public int ChallengeMapId { get; set; } + public int ChallengeId { get; set; } + public int Progress { get; set; } + } + + [HttpPost("api/challenge/v2/updateProgress")] + [Authorize] + public async Task UpdateChallengeProgress([FromForm] UpdateChallengeRequest formReq) + { + var pid = Me; + var req = await ReadChallengeRequestAsync(formReq); + var mapId = req.ChallengeMapId != 0 ? req.ChallengeMapId : CurrentChallengeMapId(); + var challengeId = req.ChallengeId != 0 ? req.ChallengeId : req.Id; + var index = challengeId >= mapId * 10 && challengeId < mapId * 10 + 10 + ? challengeId - mapId * 10 + : Math.Abs(challengeId % 10); + index = Math.Clamp(index, 0, 2); + + var key = $"challenge:{mapId}:{index}"; + var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key); + if (row is null) + { + row = new ObjectiveProgressEntity { PlayerId = pid, Key = key }; + db.ObjectiveProgress.Add(row); + } + if (!row.IsCompleted) + { + row.IsCompleted = true; + row.ClearedAt = DateTime.UtcNow; + } + await db.SaveChangesAsync(); + + var weeklyRewarded = false; + var allKeys = new[] { $"challenge:{mapId}:0", $"challenge:{mapId}:1", $"challenge:{mapId}:2" }; + var completeCount = await db.ObjectiveProgress + .CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted); + if (completeCount >= allKeys.Length) + { + weeklyRewarded = await GrantRewardOnceAsync( + pid, + $"weekly-challenge:{mapId}", + xp: 250, + tokens: 250, + reason: $"weekly_challenge:{mapId}"); + } + + return Ok(new + { + ChallengeMapId = mapId, + ChallengeId = challengeId, + Progress = Math.Max(req.Progress, 10), + IsCompleted = true, + ClearedAt = row.ClearedAt, + Rewarded = weeklyRewarded, + }); + } + + // ── Checklist ──────────────────────────────────────────────────────── + + /// GET api/checklist/v{1,2}/current — returns + /// a flat List<ChecklistObjective>, NOT a wrapper + /// object. Wire shape per + /// Cpp2IL_CS/.../RecNet/Checklist.cs: + /// Order(int), Objective(ObjectiveType int), Count(int), + /// CreditAmount(int). All four keys are required + /// (Util.GetKey, not OrDefault). + /// + /// Completion state is tracked separately by the client via + /// rows it cross-references — we + /// don't put a per-item "done" flag on the wire; the client + /// matches by Order. Returning an empty list ([]) is also valid + /// per the deserialiser (List wrapper accepts empty). + [HttpGet("api/checklist/v1/current")] + [HttpGet("api/checklist/v2/current")] + public IActionResult CurrentChecklist() + { + // Default NUX checklist for a brand-new account. Each row + // maps an in-game step to an ObjectiveType enum value + // (decompiled from ProgressionManager.cs). + return Ok(new[] + { + new { Order = 0, Objective = 38 /* SaveOutfitSlot */, Count = 1, CreditAmount = 25 }, + new { Order = 1, Objective = 32 /* VisitACustomRoom */, Count = 1, CreditAmount = 25 }, + new { Order = 2, Objective = 2 /* AddAFriend */, Count = 1, CreditAmount = 25 }, + new { Order = 3, Objective = 30 /* GoToRecCenter */, Count = 1, CreditAmount = 25 }, + new { Order = 4, Objective = 6 /* CheerAPlayer */, Count = 1, CreditAmount = 25 }, + }); + } + + public sealed class CompleteChecklistRequest + { + public int Id { get; set; } + public int ItemIndex { get; set; } + } + + [HttpPost("api/checklist/v1/complete")] + [HttpPost("api/checklist/v2/complete")] + [Authorize] + public async Task CompleteChecklist([FromBody] CompleteChecklistRequest req) + { + var pid = Me; + var id = req.ItemIndex != 0 ? req.ItemIndex : req.Id; + var key = $"checklist:{id}"; + var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key); + if (row is null) + { + row = new ObjectiveProgressEntity { PlayerId = pid, Key = key }; + db.ObjectiveProgress.Add(row); + } + row.IsCompleted = true; + row.ClearedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + var rewarded = await GrantRewardOnceAsync( + pid, + $"checklist:{id}", + xp: 25, + tokens: 25, + reason: $"checklist:{id}"); + var balance = await level.GetBalanceAsync(pid, RecCenterTokens); + return Ok(BalanceUpdateResponse(balance, RecCenterTokens, rewarded ? 25 : 0, 303)); + } + + // ── Objectives bulk-complete (real persistence) ────────────────────── + + /// POST api/objectives/v1/completegroup — replaces + /// the previous shape-stub in MissingEndpointsController. Now + /// upserts a real row keyed + /// by group:{id}. + public sealed class UpdateObjectiveRequest + { + public int Group { get; set; } + public int Index { get; set; } + public float Progress { get; set; } + public bool IsCompleted { get; set; } + } + + /// POST api/objectives/v1/updateobjective — per- + /// objective progress update. Was previously caught by the + /// namespace wildcard returning []; now persists to + /// keyed + /// obj:{group}:{index} so progress survives sessions. + /// Wire shape from RecNet.Objectives.UpdateObjectiveProgress. + [HttpPost("api/objectives/v1/updateobjective")] + [Authorize] + public async Task UpdateObjective([FromBody] UpdateObjectiveRequest req) + { + var pid = Me; + var key = $"obj:{req.Group}:{req.Index}"; + var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key); + if (row is null) + { + row = new ObjectiveProgressEntity { PlayerId = pid, Key = key }; + db.ObjectiveProgress.Add(row); + } + if (req.IsCompleted || req.Progress >= 1f) + { + row.IsCompleted = true; + row.ClearedAt ??= DateTime.UtcNow; + } + await db.SaveChangesAsync(); + var rewardKey = $"obj:{req.Group}:{req.Index}"; + var isRewarded = await IsRewardedAsync(pid, rewardKey); + return Ok(new + { + req.Group, + req.Index, + Progress = row.IsCompleted ? 1f : req.Progress, + VisualProgress = row.IsCompleted ? 1f : req.Progress, + IsCompleted = row.IsCompleted, + IsRewarded = isRewarded, + IsDirty = false, + }); + } + + [HttpPost("api/objectives/v1/completegroup")] + [Authorize] + public async Task CompleteGroup([FromBody] CompleteGroupBody body) + { + var pid = Me; + var key = $"group:{body.Group}"; + var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key); + if (row is null) + { + row = new ObjectiveProgressEntity { PlayerId = pid, Key = key }; + db.ObjectiveProgress.Add(row); + } + row.IsCompleted = true; + row.ClearedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + var rewarded = await GrantObjectiveGroupRewardAsync(pid, body.Group); + return Ok(new + { + Group = body.Group, + IsCompleted = true, + ClearedAt = row.ClearedAt!.Value.ToString("o"), + RequiresCompleteOnServer = false, + IsRewarded = true, + Rewarded = rewarded, + }); + } + + public sealed class CompleteGroupBody { public int Group { get; set; } } + + /// GET api/objectives/v1/myprogress + + /// api/players/v2/objectives — the watch's daily/weekly + /// objectives checklist. Returns + /// {Objectives: [...], ObjectiveGroups: [...]} per + /// Objectives.MyProgress.Deserialize (RVA 0x14510A0). + /// Empty lists are acceptable; both keys must be present. + [HttpGet("api/objectives/v1/myprogress")] + [HttpGet("api/players/v2/objectives")] + public async Task MyObjectiveProgress() + { + var pid = this.CurrentPlayerId(); + if (pid is not long me) + return Ok(new { Objectives = Array.Empty(), ObjectiveGroups = Array.Empty() }); + + var rows = await db.ObjectiveProgress + .Where(o => o.PlayerId == me) + .ToListAsync(); + + var rewardKeys = rows + .Where(o => o.Key.StartsWith("reward:")) + .Select(o => o.Key["reward:".Length..]) + .ToHashSet(StringComparer.Ordinal); + + var objectives = rows + .Where(o => o.Key.StartsWith("obj:")) + .Select(o => + { + var (grp, idx) = ParseObjectiveKey(o.Key); + return new + { + Index = idx, + Group = grp, + Progress = o.IsCompleted ? 1f : 0f, + VisualProgress = o.IsCompleted ? 1f : 0f, + IsCompleted = o.IsCompleted, + IsRewarded = rewardKeys.Contains(o.Key), + IsDirty = false, + }; + }); + + var groups = rows + .Where(o => o.Key.StartsWith("group:")) + .Select(o => new + { + Group = int.TryParse(o.Key["group:".Length..], out var g) ? g : 0, + IsCompleted = o.IsCompleted, + ClearedAt = o.ClearedAt ?? new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), + RequiresCompleteOnServer = false, + IsRewarded = rewardKeys.Contains(o.Key), + }); + + return Ok(new { Objectives = objectives, ObjectiveGroups = groups }); + } + + /// POST api/objectives/v1/cleargroup — flips a + /// group's IsCompleted flag, used by the checklist's + /// "claim group reward" button. Response is a single + /// ObjectiveGroupProgress with Group/IsCompleted/ClearedAt + /// required keys; RequiresCompleteOnServer is optional. + [HttpPost("api/objectives/v1/cleargroup")] + public async Task ClearGroup([FromForm(Name = "group")] int? group) + { + var pid = this.CurrentPlayerId(); + var key = $"group:{group ?? 0}"; + var now = DateTime.UtcNow; + + if (pid is long me) + { + var row = await db.ObjectiveProgress + .FirstOrDefaultAsync(o => o.PlayerId == me && o.Key == key); + if (row is null) + { + row = new ObjectiveProgressEntity { PlayerId = me, Key = key }; + db.ObjectiveProgress.Add(row); + } + row.IsCompleted = true; + row.ClearedAt = now; + await db.SaveChangesAsync(); + await GrantObjectiveGroupRewardAsync(me, group ?? 0); + } + + return Ok(new + { + Group = group ?? 0, + IsCompleted = true, + ClearedAt = now.ToString("o"), + RequiresCompleteOnServer = false, + IsRewarded = true, + }); + } + + /// POST api/players/v2/objectives — batched + /// ProgressionManager completion records. These are separate from + /// ObjectiveProgress rows: they grant XP/currency for daily + /// objective events and must be idempotent per UTC day. + [HttpPost("api/players/v2/objectives")] + [Authorize] + public async Task CompletePlayerObjectives([FromBody] List? records) + { + var pid = Me; + var grantedXp = 0; + var grantedTokens = 0; + foreach (var record in records ?? new()) + { + var (xp, tokens, reason) = RewardForObjectiveType(record.ObjectiveType, record.AdditionalXp); + if (xp <= 0 && tokens <= 0) continue; + if (await GrantRewardOnceAsync(pid, $"{reason}:{DateTime.UtcNow:yyyyMMdd}", xp, tokens, reason)) + { + grantedXp += xp; + grantedTokens += tokens; + } + } + + var balance = await level.GetBalanceAsync(pid, RecCenterTokens); + return Ok(new + { + GrantedXp = grantedXp, + GrantedCurrency = grantedTokens, + CurrencyType = RecCenterTokens, + Balance = balance, + BalanceType = 0, + }); + } + + public sealed class PlayerObjectiveCompletionRecord + { + public int ObjectiveType { get; set; } + public bool InParty { get; set; } + public int AdditionalXp { get; set; } + } + + private static (int Group, int Index) ParseObjectiveKey(string key) + { + const string prefix = "obj:"; + if (!key.StartsWith(prefix)) return (-1, -1); + var rest = key[prefix.Length..]; + var colon = rest.IndexOf(':'); + if (colon <= 0) return (-1, -1); + var grp = int.TryParse(rest[..colon], out var g) ? g : -1; + var idx = int.TryParse(rest[(colon + 1)..], out var i) ? i : -1; + return (grp, idx); + } + + private static int CurrentChallengeMapId() => + (int)((DateTime.UtcNow - new DateTime(2020, 1, 1)).Days / 7 % 52 + 1); + + private async Task ReadChallengeRequestAsync(UpdateChallengeRequest formReq) + { + if (Request.HasFormContentType || formReq.Id != 0 || formReq.ChallengeId != 0 || formReq.ChallengeMapId != 0) + return formReq; + + try + { + var body = await System.Text.Json.JsonSerializer.DeserializeAsync( + Request.Body, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return body ?? formReq; + } + catch + { + return formReq; + } + } + + private async Task GrantObjectiveGroupRewardAsync(long playerId, int group) + { + var (xp, tokens, name) = group switch + { + 0 => (150, 150, "daily-objectives"), + 1 => (100, 100, "new-player-punch-card"), + 2 => (75, 75, "orientation-checklist"), + _ => (50, 50, $"objective-group-{group}"), + }; + return await GrantRewardOnceAsync(playerId, $"group:{group}", xp, tokens, name); + } + + private static (int Xp, int Tokens, string Reason) RewardForObjectiveType(int objectiveType, int additionalXp) + { + var xp = Math.Max(additionalXp, 0); + return objectiveType switch + { + 10 or 11 or 12 => (Math.Max(xp, 25), 25, $"daily-objective:{objectiveType}"), + 13 => (Math.Max(xp, 150), 150, "all-daily-objectives"), + 14 => (Math.Max(xp, 50), 50, "complete-any-daily"), + 15 => (Math.Max(xp, 75), 75, "complete-any-weekly"), + 25 => (Math.Max(xp, 25), 25, "nux-punchcard-objective"), + 26 => (Math.Max(xp, 100), 100, "nux-all-punchcard-objectives"), + _ => (xp, 0, $"objective:{objectiveType}"), + }; + } + + private async Task IsRewardedAsync(long playerId, string rewardKey) => + await db.ObjectiveProgress.AnyAsync(o => + o.PlayerId == playerId && o.Key == $"reward:{rewardKey}" && o.IsCompleted); + + private async Task GrantRewardOnceAsync(long playerId, string rewardKey, int xp, int tokens, string reason) + { + var sentinelKey = $"reward:{rewardKey}"; + var exists = await db.ObjectiveProgress.AnyAsync(o => + o.PlayerId == playerId && o.Key == sentinelKey && o.IsCompleted); + if (exists) return false; + + db.ObjectiveProgress.Add(new ObjectiveProgressEntity + { + PlayerId = playerId, + Key = sentinelKey, + IsCompleted = true, + ClearedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + if (xp > 0) await level.AwardXpAsync(playerId, xp, reason); + if (tokens > 0) await level.GrantCurrencyAsync(playerId, RecCenterTokens, tokens, reason); + return true; + } + + private static object BalanceUpdateResponse(long balance, int currencyType, int delta, int balanceAddType) => new + { + Balance = balance, + CurrencyType = currencyType, + BalanceType = 0, + BalanceUpdates = new[] + { + new + { + UpdateResponse = 0, + Data = delta <= 0 + ? Array.Empty() + : new object[] + { + new + { + BalanceAddType = balanceAddType, + BaseAward = delta, + BonusAward = 0, + RateLimit = 0, + CurrentCount = 1, + Total = delta, + BalanceType = 0, + BalanceInGiftBox = false, + }, + }, + }, + }, + }; +} diff --git a/DorkNet.Server/Controllers/API/QuickPlay/QuickPlayController.cs b/DorkNet.Server/Controllers/API/QuickPlay/QuickPlayController.cs new file mode 100644 index 0000000..c8fff6d --- /dev/null +++ b/DorkNet.Server/Controllers/API/QuickPlay/QuickPlayController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Controllers.API.QuickPlay; + +/// +/// api.rec.net/api/quickPlay/* — the watch's "Quick Play" tile picks +/// a popular open AG room when the player wants to jump in +/// somewhere social without browsing. +/// +/// • GET v{1,2}/getandclear — returns the next match the +/// user was queued for, then clears it. We just synthesise a +/// "join hot AG room" target on each call (no real queue). +/// • POST v1/set — stores a desired-match tag. We log + ack; +/// no persistence needed for a single-region private server. +/// +[ApiController] +public class QuickPlayController(DorkNetDbContext db) : ControllerBase +{ + /// GET v{1,2}/getandclear — the watch polls this + /// on every connect / navigation to check whether the user has a + /// pending quickplay queue. Returning a real {RoomId, RoomName} + /// makes the client treat it as "you queued, go there now" and + /// immediately teleports — that's WHY users were randomly + /// spawning in Paddleball etc. + /// + /// Correct behaviour: return empty {} unless we actually have a + /// queued match for the caller. The /set endpoint is what + /// queues; getandclear is the consume. + /// + /// We don't persist queue state on a single-region private + /// server (no real matchmaking pool), so always return empty. + [HttpGet("api/quickPlay/v1/getandclear")] + [HttpGet("api/quickPlay/v2/getandclear")] + public IActionResult GetAndClear() => Ok(new { }); + + public sealed class SetQuickPlayRequest + { + public string? Tag { get; set; } + } + + [HttpPost("api/quickPlay/v1/set")] + [Authorize] + public IActionResult Set([FromBody] SetQuickPlayRequest? req) + { + // Preference is per-session; we don't persist (no benefit in + // a small private deployment). Just ack. + return Ok(new { Tag = req?.Tag ?? string.Empty, Set = true }); + } +} diff --git a/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs new file mode 100644 index 0000000..a424e3c --- /dev/null +++ b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs @@ -0,0 +1,367 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +// Storage-side enum aliased so the wire mapping stays explicit at +// every callsite. Wire enum values (RelationshipType / +// ReciprocalStatus) live in this file as constants — they're tied +// to the disassembly and not used elsewhere. +using EntityStatus = DorkNet.Server.Data.Entities.RelationshipStatus; + +namespace DorkNet.Server.Controllers.API.Relationships.V2; + +/// +/// api/relationships/v2 — friendship + reciprocal-state endpoints. +/// +/// All routes / wire shapes verified against the patched 2020 client: +/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationships.txt +/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationship.txt +/// +/// Wire response = a Relationship object (or List of them for /get). +/// Relationship.Deserialize strict-reads via Util.GetKey<T>: +/// "PlayerID" (int) — REQUIRED — the OTHER account's id +/// "RelationshipType" (int) — REQUIRED — 0..3 +/// "Muted" (int) — REQUIRED — ReciprocalStatus 0..3 +/// "Ignored" (int) — REQUIRED — ReciprocalStatus 0..3 +/// "Favorited" (int) — optional (Util.GetKeyOrDefault, defaults 0) +/// +/// RelationshipType (from caller's perspective): +/// 0 None, 1 FriendRequestSent, 2 FriendRequestReceived, 3 Friend +/// ReciprocalStatus (symmetric flag — who set it): +/// 0 None, 1 Local (just me), 2 Remote (just them), 3 Mutual +/// +/// URLs called from the watch (Relationships.txt, all under +/// "api/relationships/"): +/// POST v2/addfriend?id={N} AddFriend +/// POST v2/removefriend?id={N} RemoveFriend / cancel-pending +/// POST v2/sendfriendrequest?id={N} SendFriendRequest +/// POST v2/acceptfriendrequest?id={N} AcceptFriendRequest +/// POST v1/favorite?id={N} FavoritePlayer +/// POST v1/unfavorite?id={N} UnfavoritePlayer +/// POST v1/mute / unmute / ignore / unignore (PostPreferenceChange) +/// GET v2/get GetMyRelationships +/// All POST URLs return one Relationship; GET returns List<Relationship>. +/// +[ApiController] +[Authorize] +public class RelationshipsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase +{ + // RelationshipType — int values match Relationship.RelationshipType + // in the patched dump.cs. + private const int RT_None = 0; + private const int RT_FriendRequestSent = 1; + private const int RT_FriendRequestReceived = 2; + private const int RT_Friend = 3; + + // ReciprocalStatus values — also match the dump.cs enum. + private const int RS_None = 0; + private const int RS_Local = 1; + private const int RS_Remote = 2; + private const int RS_Mutual = 3; + + private long CurrentPlayerId => this.RequireCurrentPlayerId(); + + // ── GET /api/relationships/v2/get ──────────────────────────────── + // Watch URL: "api/relationships/v2/get". Returns the caller's full + // relationship list as List. + [HttpGet("api/relationships/v2/get")] + public async Task GetMyRelationships() + { + var me = CurrentPlayerId; + var rels = await db.Relationships + .Where(r => r.RequesterId == me || r.TargetId == me) + .ToListAsync(); + var wire = rels.Select(r => BuildRelationship(r, me)).ToList(); + return Ok(wire); + } + + /// GET api/relationships/v2/personaldetails/{playerId} + /// — single relationship row from the caller's perspective. Used + /// by the watch's profile-card view to determine whether to show + /// the "Add Friend" or "Remove Friend" affordance. Returns the + /// same Relationship wire shape as v2/get's list elements. + [HttpGet("api/relationships/v2/personaldetails/{playerId:long}")] + public async Task PersonalDetails(long playerId) + { + var me = CurrentPlayerId; + if (playerId <= 0 || playerId == me) return Ok(EmptyRelationship(playerId)); + var row = await FindAsync(me, playerId); + return Ok(row is null ? EmptyRelationship(playerId) : BuildRelationship(row, me)); + } + + // ── /api/relationships/v2/sendfriendrequest?id={N} ─────────────── + // Watch URL from Relationships.txt: "{0}v2/sendfriendrequest?id={1}". + // The ISIL labels the call site Core.Post but the live trace shows + // the watch sending GET — accepting both is harmless and the + // safest cross-build choice. + [HttpGet("api/relationships/v2/sendfriendrequest")] + [HttpPost("api/relationships/v2/sendfriendrequest")] + public async Task SendFriendRequest([FromQuery] long id) + { + var me = CurrentPlayerId; + if (id <= 0 || id == me) + return Ok(EmptyRelationship(id)); + + var existing = await FindAsync(me, id); + if (existing is not null) + return Ok(BuildRelationship(existing, me)); + + var row = new RelationshipEntity + { + RequesterId = me, + TargetId = id, + Status = EntityStatus.PendingSent, + }; + db.Relationships.Add(row); + await db.SaveChangesAsync(); + + await notifications.FriendRequestReceived(id, me); + return Ok(BuildRelationship(row, me)); + } + + // ── /api/relationships/v2/acceptfriendrequest?id={N} ──────────── + [HttpGet("api/relationships/v2/acceptfriendrequest")] + [HttpPost("api/relationships/v2/acceptfriendrequest")] + public async Task AcceptFriendRequest([FromQuery] long id) + { + var me = CurrentPlayerId; + if (id <= 0) + return Ok(EmptyRelationship(id)); + + // Only an incoming pending row can be accepted. The other side + // must have requested us — RequesterId=them, TargetId=me. + var rel = await db.Relationships.FirstOrDefaultAsync(r => + r.RequesterId == id && r.TargetId == me && + r.Status == EntityStatus.PendingSent); + if (rel is null) + return Ok(EmptyRelationship(id)); + + rel.Status = EntityStatus.Friend; + rel.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + await notifications.FriendRequestAccepted(id, me); + return Ok(BuildRelationship(rel, me)); + } + + // ── /api/relationships/v2/removefriend?id={N} ──────────────────── + // Symmetrical: undoes a friendship OR cancels a pending request OR + // declines an incoming one. Block rows are left alone — the watch + // has a separate explicit unblock path. Live trace shows the watch + // sending GET (BestHTTP queueing oddity vs the ISIL Core.Post call); + // accept both verbs. + [HttpGet("api/relationships/v2/removefriend")] + [HttpPost("api/relationships/v2/removefriend")] + public async Task RemoveFriend([FromQuery] long id) + { + var me = CurrentPlayerId; + if (id <= 0) + return Ok(EmptyRelationship(id)); + + var rel = await FindAsync(me, id); + if (rel is null) + return Ok(EmptyRelationship(id)); + if (rel.Status == EntityStatus.Blocked) + return Ok(BuildRelationship(rel, me)); + + db.Relationships.Remove(rel); + await db.SaveChangesAsync(); + await notifications.FriendRemoved(id, me); + return Ok(EmptyRelationship(id)); + } + + // ── /api/relationships/v2/addfriend?id={N} ─────────────────────── + // Per the disassembly, AddFriend is its own endpoint distinct from + // SendFriendRequest. Live trace shows the watch sending GET; ISIL + // says Core.Post. Accept both verbs. + [HttpGet("api/relationships/v2/addfriend")] + [HttpPost("api/relationships/v2/addfriend")] + public async Task AddFriend([FromQuery] long id) + { + var me = CurrentPlayerId; + if (id <= 0 || id == me) + return Ok(EmptyRelationship(id)); + + var existing = await FindAsync(me, id); + if (existing is null) + { + existing = new RelationshipEntity + { + RequesterId = me, + TargetId = id, + Status = EntityStatus.Friend, + }; + db.Relationships.Add(existing); + } + else if (existing.Status != EntityStatus.Blocked) + { + existing.Status = EntityStatus.Friend; + existing.UpdatedAt = DateTime.UtcNow; + } + await db.SaveChangesAsync(); + return Ok(BuildRelationship(existing, me)); + } + + // ── v1 favorite / unfavorite ───────────────────────────────────── + // Watch URLs: "api/relationships/v1/favorite?id={N}" and + // "api/relationships/v1/unfavorite?id={N}". These return a + // boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer + // disassembly (Core.Post … ExpectHttpStatusSuccess), not a + // Relationship — they don't surface any state change in the + // 2020 storage model so we just acknowledge. + [HttpPost("api/relationships/v1/favorite")] + public Task Favorite([FromQuery] long id) => SetPreference(id, favorited: true); + + [HttpPost("api/relationships/v1/unfavorite")] + public Task Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false); + + [HttpPost("api/relationships/v1/mute")] + public Task Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) + => SetPreference(id != 0 ? id : formId ?? 0, muted: true); + + [HttpPost("api/relationships/v1/unmute")] + public Task Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) + => SetPreference(id != 0 ? id : formId ?? 0, muted: false); + + [HttpPost("api/relationships/v1/ignore")] + public Task Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) + => SetPreference(id != 0 ? id : formId ?? 0, ignored: true); + + [HttpPost("api/relationships/v1/unignore")] + public Task Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) + => SetPreference(id != 0 ? id : formId ?? 0, ignored: false); + + /// Update favorited/muted/ignored on the caller's row. + /// Creates a placeholder row when none exists (so an "ignore" + /// before any friend request is still recorded). + private async Task SetPreference(long otherId, + bool? favorited = null, bool? muted = null, bool? ignored = null) + { + var me = this.RequireCurrentPlayerId(); + if (otherId <= 0 || otherId == me) + return Ok(new Models.Auth.RecNetResult { Success = false, Error = "invalid_id" }); + + var row = await db.Relationships.FirstOrDefaultAsync(r => + r.RequesterId == me && r.TargetId == otherId); + if (row is null) + { + row = new RelationshipEntity + { + RequesterId = me, + TargetId = otherId, + Status = EntityStatus.Friend, // placeholder; favorite/mute/ignore don't require friend status + }; + db.Relationships.Add(row); + } + if (favorited is bool f) row.Favorited = f; + if (muted is bool m) row.Muted = m; + if (ignored is bool i) row.Ignored = i; + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty }); + } + + public sealed class BulkIgnoreRequest + { + public int Platform { get; set; } + public List? PlatformUserIds { get; set; } + } + + /// POST api/relationships/v1/bulkignoreplatformusers + /// — bulk-import Steam/Oculus blocks. Persists to + /// so when those users sign + /// up later we can auto-set their relationship to Ignored. + [HttpPost("api/relationships/v1/bulkignoreplatformusers")] + [Authorize] + public async Task BulkIgnorePlatformUsers([FromBody] BulkIgnoreRequest req) + { + var me = this.RequireCurrentPlayerId(); + if (req?.PlatformUserIds is null || req.PlatformUserIds.Count == 0) + return Ok(new { Imported = 0 }); + + var added = 0; + foreach (var puid in req.PlatformUserIds.Distinct()) + { + if (string.IsNullOrWhiteSpace(puid)) continue; + var exists = await db.PlatformIgnores.AnyAsync(i => + i.PlayerId == me && i.Platform == req.Platform && i.PlatformUserId == puid); + if (exists) continue; + db.PlatformIgnores.Add(new PlatformIgnoreEntity + { + PlayerId = me, + Platform = req.Platform, + PlatformUserId = puid, + }); + added++; + } + await db.SaveChangesAsync(); + return Ok(new { Imported = added }); + } + + // ── helpers ────────────────────────────────────────────────────── + + private Task FindAsync(long me, long other) => + db.Relationships.FirstOrDefaultAsync(r => + (r.RequesterId == me && r.TargetId == other) || + (r.RequesterId == other && r.TargetId == me)); + + /// Map a storage row to the watch's wire RelationshipType + /// from 's perspective. PendingSent flips + /// direction depending on which side originated. + private static int MapType(RelationshipEntity r, long me) => r.Status switch + { + EntityStatus.Friend => RT_Friend, + EntityStatus.Blocked => RT_None, // Blocked is not a friendship; the watch reads block state via a separate flag we don't track yet. + EntityStatus.PendingSent => r.RequesterId == me ? RT_FriendRequestSent : RT_FriendRequestReceived, + EntityStatus.PendingReceived => r.RequesterId == me ? RT_FriendRequestReceived : RT_FriendRequestSent, + _ => RT_None, + }; + + /// + /// Build the wire object the patched Relationship.Deserialize + /// reads. Keys are case-sensitive (Util.GetKey is a Dictionary + /// TryGetValue) — we ship exactly what's in the disassembly. + /// Implemented as a Dictionary because System.Text.Json's + /// case-insensitive collision check rejects anonymous types with + /// PascalCase property names like "PlayerID" + "Muted" if any + /// other property could collide on lowercase — using a Dictionary + /// avoids that whole class of trouble. + /// + private static Dictionary BuildRelationship(RelationshipEntity r, long me) + { + var other = r.RequesterId == me ? r.TargetId : r.RequesterId; + // ReciprocalStatus enum: 0=None, 1=ByMe, 2=ByThem, 3=Both. + // Our schema only tracks "by me" (the requester's perspective) + // so we can answer ByMe / None; ByThem / Both would require + // looking up the OTHER direction's row too. Cheap extra query + // omitted — the watch's UI only consumes the ByMe bit. + var mineDir = r.RequesterId == me; + var muted = mineDir && r.Muted ? 1 : 0; + var ignored = mineDir && r.Ignored ? 1 : 0; + var favored = mineDir && r.Favorited ? 1 : 0; + return new() + { + ["PlayerID"] = (int)other, + ["RelationshipType"] = MapType(r, me), + ["Muted"] = muted, + ["Ignored"] = ignored, + ["Favorited"] = favored, + }; + } + + /// "None" relationship for a target the caller has nothing + /// on file with — used as a no-op success response so the watch's + /// Relationship.Deserialize stays satisfied. + private static Dictionary EmptyRelationship(long other) => new() + { + ["PlayerID"] = (int)other, + ["RelationshipType"] = RT_None, + ["Muted"] = RS_None, + ["Ignored"] = RS_None, + ["Favorited"] = RS_None, + }; +} diff --git a/DorkNet.Server/Controllers/API/Role/RoleController.cs b/DorkNet.Server/Controllers/API/Role/RoleController.cs new file mode 100644 index 0000000..836651d --- /dev/null +++ b/DorkNet.Server/Controllers/API/Role/RoleController.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Controllers.API.Role; + +/// +/// api.rec.net/api/role/* — role / privilege probes the watch consults +/// when deciding whether to surface dev-only UI (the in-watch debug +/// console button, hidden admin-tool tab, etc). +/// +/// URL verified against +/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Accounts.txt:5168 +/// ("role/developer/{0}") called from +/// RecNet.Accounts.GetAccountIsDeveloper. Response shape is a +/// bare JSON boolean — the watch wraps the call with +/// PromiseExtensions.ExpectPrimitiveResponse<bool>, so an +/// array or object response throws InvalidCastException and silently +/// disables the dev UI. +/// +/// To make a player a developer (and unlock the watch's +/// debug-console button), set Players.IsDeveloper = 1 in the +/// SQLite DB or via the admin UI's player editor. +/// +[ApiController] +public class RoleController(DorkNetDbContext db) : ControllerBase +{ + // Service-routed call from Accounts.GetAccountIsDeveloper. The + // 2020 dump's SendRequest carries `Service = 0` (Auth) — verified + // at Cpp2IL_ISIL/.../RecNet/Accounts.txt:5183-5186 against the + // RecNet.Service enum in dump.cs:586523-586538 (Auth=0). Service.Auth + // maps to auth.{apex} in ConfigService's ServiceUrls, and + // the path the watch appends has NO `/api/` prefix — it's just + // role/developer/{id}. The previous /api/role/...-only + // route never matched, so every nameplate probe came back 404 and + // the watch logged "Failed to GetIsDeveloper for accountId X: HTTP + // Error 404" once per remote player per scene load. + [HttpGet("/role/developer/{accountId:long}")] + [HttpGet("/api/role/developer/{accountId:long}")] + [AllowAnonymous] + public async Task> IsDeveloper(long accountId) + { + // OR IsCommunityTeam into the answer so the watch unlocks the + // in-settings "Developer Display Mode" slider for community-team + // members too — the slider's positions render the overhead + // badge as "Community Team" / "Developer" (see + // Cpp2IL_ISIL/.../PlayerUI.txt:9085-9099). The 2020 watch never + // probes a separate community-team role, so collapsing both + // flags into this one endpoint is the only way to reach the + // badge UI without patching the binary. + var isDev = await db.Players + .Where(p => p.Id == accountId) + .Select(p => (bool?)(p.IsDeveloper || p.IsCommunityTeam || p.IsAdmin)) + .FirstOrDefaultAsync(); + return Ok(isDev ?? false); + } + + /// Mirror endpoint for the admin role — same shape, in case + /// later builds added an /api/role/admin/{id} probe. The 2020 + /// dump only references the developer one but admins also get all + /// dev privileges, so the result is unioned. + [HttpGet("/role/admin/{accountId:long}")] + [HttpGet("/api/role/admin/{accountId:long}")] + [AllowAnonymous] + public async Task> IsAdmin(long accountId) + { + var isAdmin = await db.Players + .Where(p => p.Id == accountId) + .Select(p => (bool?)p.IsAdmin) + .FirstOrDefaultAsync(); + return Ok(isAdmin ?? false); + } + + /// Future-proofing for a hypothetical + /// role/communityteam/{id} probe — not referenced by the 2020 + /// build, but mirrors the developer shape so a patched watch can + /// distinguish the two roles without a server change. + [HttpGet("/role/communityteam/{accountId:long}")] + [HttpGet("/api/role/communityteam/{accountId:long}")] + [AllowAnonymous] + public async Task> IsCommunityTeam(long accountId) + { + var isCt = await db.Players + .Where(p => p.Id == accountId) + .Select(p => (bool?)p.IsCommunityTeam) + .FirstOrDefaultAsync(); + return Ok(isCt ?? false); + } +} diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs new file mode 100644 index 0000000..cf50c3a --- /dev/null +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -0,0 +1,1062 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Controllers.Match; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Rooms.V2; + +/// +/// api.rec.net/api/rooms/* — DB-backed implementation of the watch's +/// Rooms tab. +/// +/// Replaces the previous stubs in MiscStubsController for: +/// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms, +/// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent, +/// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id}, +/// v1/personaldetails/{id}, v2/personaldetails/{id}. +/// +/// All endpoints serialize Rooms via RoomService.ToWireRoom which matches +/// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required +/// fields plus optional VR-low / mobile / mic-mute flags). +/// +[ApiController] +public class RoomsController( + RoomService rooms, + DorkNetDbContext db, + PlayerPresenceService presence, + OnlinePresenceService onlinePresence, + DomainConfig domain, + NotificationService notifications, + ILogger logger) : ControllerBase +{ + private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this); + + // ── Browse / Hot / Search ──────────────────────────────────────────── + + /// + /// `Rooms.GetHotRooms(roomScoreType, tags)` — the watch's "Trending" tab. + /// `roomScoreType` is ignored (we always sort by HotScore). `tags` is + /// passed through as a substring filter against the comma-separated + /// TagsCsv column (e.g. `#community`, `#recroomoriginal`). + /// + [HttpGet("api/rooms/v1/hot")] + public async Task Hot( + [FromQuery] string? roomScoreType, + [FromQuery] string? tags) + => Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList()); + + /// + /// `Rooms.SearchForRooms(query)` — free-text room search. + /// + [HttpGet("api/rooms/v1/search")] + [HttpGet("api/rooms/v2/search")] + public async Task Search( + [FromQuery(Name = "query")] string? query, + [FromQuery(Name = "value")] string? value) + => Ok((await rooms.SearchAsync(query ?? value ?? string.Empty)) + .Select(RoomService.ToWireRoom).ToList()); + + /// GET api/rooms/v2/live — currently-active rooms + /// (rooms with at least one player presence). Same shape as Hot + /// — order by HotScore as a proxy for "live activity" since we + /// don't track instance population separately yet. + [HttpGet("api/rooms/v2/live")] + public async Task Live([FromQuery] string? roomScoreType) + => Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList()); + + /// + /// `Rooms.GetBaseRooms` — list of "base" rooms used by Rec Room Originals + /// to seed the room creation UI. Returns the same set as #recroomoriginal. + /// + [HttpGet("api/rooms/v2/baserooms")] + [HttpGet("api/rooms/v3/baserooms")] + public async Task BaseRooms() + => Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList()); + + /// + /// `Rooms.GetFilters` — pinned/popular tag chips shown above the room + /// list in the watch. + /// + // Tag wire format: NO leading '#'. The watch's RoomFilterChip prepends + // '#' at render time, so sending "#community" produces "##community" in + // the UI. Names stored in the DB also drop the prefix. + [HttpGet("api/rooms/v1/filters")] + public IActionResult Filters() => Ok(new + { + PinnedFilters = new[] { "community", "recroomoriginal", "featured", "quest" }, + PopularFilters = new[] { "paintball", "dodgeball", "soccer", "lasertag", "discgolf" }, + }); + + [HttpGet("api/rooms/v1/tags")] + public IActionResult Tags() => Ok(new[] { + "community", "recroomoriginal", "featured", "quest", + "paintball", "dodgeball", "soccer", "lasertag", "discgolf", + }); + + [HttpGet("api/rooms/v1/pinnedtags")] + public IActionResult PinnedTags() => Ok(new[] { "community", "recroomoriginal", "featured" }); + + [HttpGet("api/rooms/v1/populartags")] + public IActionResult PopularTags() => Ok(new[] { "paintball", "dodgeball", "soccer" }); + + // ── Single room lookups ────────────────────────────────────────────── + + /// + /// `Rooms.GetByName(roomName)` — used when the client constructs the + /// share-link target or when /goto/room/{name} runs. Falls back to a + /// synthesised "Room_" for unknown names so the deserializer + /// doesn't crash on 404. + /// + [HttpGet("api/rooms/v2/name/{roomName}")] + [HttpGet("api/rooms/v3/name/{roomName}")] + public async Task ByName(string roomName) + { + var r = await rooms.GetByNameAsync(roomName); + return Ok(RoomService.ToWireRoom(r ?? Synthetic(roomName))); + } + + /// + /// `Rooms.GetById(roomId)` — same shape as ByName. + /// + [HttpGet("api/rooms/v2/{roomId:long}")] + [HttpGet("api/rooms/v3/{roomId:long}")] + public async Task ById(long roomId) + { + var r = await rooms.GetByIdAsync(roomId); + return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId))); + } + + // ── RoomDetails (the boot-critical one) ───────────────────────────── + // + // /v4/details/{roomId} is hit *during* the matchmaking goto flow. + // RoomDetails.Deserialize at RVA 0x114C580 expects PascalCase keys: + // Room (object), Scenes (list), CoOwners/InvitedCoOwners, + // Moderators/InvitedModerators, Hosts/InvitedHosts (all int lists), + // CheerCount, FavoriteCount, VisitCount, Tags. + // + // Scenes is populated for every room. The persistence coroutine + // (`RoomPersistenceManager+`) runs + // unconditionally on scene load and dereferences + // `Rooms.LocalRoomScene.DataBlobName` on its first MoveNext — so + // returning Scenes=[] makes LocalRoomScene null and NPEs the boot. + // + // The historical "Scenes=[] short-circuits the persistence manager" + // comment that used to live here was the reverse of what actually + // happens in this build. Confirmed empirically with the [req] log: + // /v4/details/1 fires before scene load for the dorm too, so + // Rooms.LocalRoom is populated and the coroutine just needs a + // populated Scenes[0] to get a non-null LocalRoomScene. + + /// GET api/rooms/v1/datahistory/{roomId} — returns the + /// version history for a room as a list of saves. Drives the + /// watch's "Restore to old version" UI in the room-settings tab. + /// + /// The watch sometimes calls this with roomId=0, which we + /// interpret as "the caller's currently-presence-tracked room" + /// (typically their dorm). Anything else is a literal room id. + /// + /// Wire shape: List<RoomDataHistoryDTO>. Deserializer + /// reads EXACTLY three PascalCase keys (verified at + /// Cpp2IL_ISIL/.../RecNet/Rooms_NestedType_RoomDataHistoryDTO.txt): + /// RoomDataHistoryId (long), DataBlobName (string), + /// CreatedAt (DateTime). Extra/wrong keys aren't ignored — + /// missing required keys throw KeyNotFoundException in + /// LitJson and the watch shows an empty history list. Don't add + /// helper fields here; pipe them through a separate admin endpoint + /// if needed. + [HttpGet("api/rooms/v1/datahistory/{roomId:long}")] + [HttpGet("api/rooms/v2/datahistory/{roomId:long}")] + public async Task DataHistory(long roomId) + { + // 0 is a sentinel — fall back to the caller's current room + // from PlayerPresenceService. + if (roomId == 0 && CurrentPlayerId is long pid && + presence.GetRoom(pid)?.RoomId is long currentId) + { + roomId = currentId; + } + if (roomId == 0) return Ok(Array.Empty()); + + var blobs = await db.RoomDataBlobs + .Where(b => b.RoomId == roomId) + .OrderByDescending(b => b.UploadedAt) + .Take(50) + .Select(b => new + { + RoomDataHistoryId = b.Id, + DataBlobName = b.BlobName, + CreatedAt = b.UploadedAt, + }) + .ToListAsync(); + return Ok(blobs); + } + + /// POST api/rooms/v4/saveData — the watch's + /// RecNet.Rooms.UploadLocalRoomSceneData save flow. + /// Body shape mirrors RecNet.Rooms+SaveRoomSceneRequest + /// (Cpp2IL_CS RecNet/Rooms.cs:858): RoomSceneId, RoomDataFilename, + /// InventionUsages[], CreatorActionContext, RequestPlayerId. + /// + /// Persists the new CurrentDataBlobName on the room + /// (and, for dorms, the per-player DormStateEntity) so the next + /// /v4/details + cdn fetch loads what just got uploaded. Returns + /// a populated RoomScene JSON — that's what the watch's + /// continuation callback (b__1 in DisplayClass124_0) expects; + /// returning an empty body trips a "HTTP Error 404" path in + /// BestHTTP's Future framework even though the HTTP status was + /// fine. + [HttpPost("api/rooms/v4/saveData")] + [Authorize] + public async Task SaveData([FromBody] SaveRoomSceneRequest body) + { + var pid = this.RequireCurrentPlayerId(); + + // Resolve the room. The watch sends RoomSceneId (the scene's + // OrderIndex per BuildRoomDetails above — 0 for single-scene + // rooms). To find the parent room we cross-reference the + // player's current presence (which RecordResponseAsync stamps + // on every /goto). This is the only signal we have without a + // RoomId on the request body. + var current = presence.GetRoom(pid); + if (current is null) + { + // No active room — without it we can't pick a target row. + // Return a degenerate RoomScene so the deserializer doesn't + // throw, but flag CanMatchmakeInto=false so the watch + // doesn't think the save succeeded. + return Ok(new + { + RoomSceneId = body.RoomSceneId, + RoomId = 0L, + RoomSceneLocationId = "76d98498-60a1-430c-ab76-b54a29b7a163", + Name = "Home", + IsSandbox = false, + DataBlobName = body.RoomDataFilename ?? string.Empty, + MaxPlayers = 8, + CanMatchmakeInto = false, + DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), + }); + } + + var roomId = current.RoomId; + var room = await rooms.GetByIdAsync(roomId); + if (room is null) return NotFound(new { error = "room_not_found" }); + + var newBlob = body.RoomDataFilename?.Trim() ?? string.Empty; + + // Multi-scene rooms have a RoomScenes row per scene. The 2020 + // client usually sends the scene's OrderIndex, but some flows + // send a stale/non-index id while the local room is still a + // single-scene dorm/custom room. In that common single-scene + // case, stamp the only row anyway; otherwise the pushed + // RoomDetails can keep Scenes[0].DataBlobName at v(N) while + // Rooms.CurrentDataBlobName is v(N+1), and the post-save reload + // reads stale bytes. + var sceneRowsForRoom = await db.RoomScenes + .Where(s => s.RoomId == roomId) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + var sceneRow = sceneRowsForRoom.FirstOrDefault(s => s.OrderIndex == (int)body.RoomSceneId) + ?? (sceneRowsForRoom.Count == 1 ? sceneRowsForRoom[0] : null); + + // Dorms: per-player state, keyed by the calling player so two + // people in the same dorm room ID save into separate slots. + if (room.IsDormRoom) + { + if (room.CreatorPlayerId != pid) return Forbid(); + + var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid); + if (dormState is null) + { + dormState = new DormStateEntity { PlayerId = pid }; + db.DormStates.Add(dormState); + } + dormState.CurrentDataBlobName = newBlob; + dormState.UpdatedAt = DateTime.UtcNow; + } + else + { + // Owned rooms: only the creator can save. Admin override + // intentionally not included — admins should use /restore + // when they need to roll a room back. + if (room.CreatorPlayerId != pid) return Forbid(); + } + + if (sceneRow is not null) + { + sceneRow.DataBlobName = newBlob; + sceneRow.DataModifiedAt = DateTime.UtcNow; + } + await db.Rooms + .Where(r => r.Id == roomId) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.CurrentDataBlobName, newBlob) + .SetProperty(r => r.UpdatedAt, DateTime.UtcNow)); + await db.SaveChangesAsync(); + + // The watch's MasterUploadRoomDataBlobCoroutine waits for BOTH + // the /upload + /saveData responses AND a SubscriptionUpdateRoom + // SignalR push before flipping the save state machine out of + // SAVING. Without this push the spinner stays "Saving..." forever + // even though both HTTP calls returned 200 — verified via the + // RoomDetailsUpdatedCallback closure in + // RoomPersistenceManager+<>c__DisplayClass174_0. + // + // For dorms we re-shape with the caller as creator + their own + // DormStateEntity blob name so the watch's set_LocalRoomDetails + // matches LocalRoomDetails.RoomId and triggers + // SafeRaiseRoomDetailsUpdated(roomId). + var pushRoom = room; + if (room.IsDormRoom) + pushRoom = CloneWithCreator(room, pid, newBlob); + else + pushRoom = CloneWithCreator(room, room.CreatorPlayerId, newBlob); + foreach (var scene in sceneRowsForRoom) + { + if (sceneRow is not null && scene.Id == sceneRow.Id) + { + scene.DataBlobName = newBlob; + scene.DataModifiedAt = DateTime.UtcNow; + } + } + var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId; + var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRowsForRoom, savedSceneId, newBlob); + logger.LogInformation( + "[rooms-save] saveData player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} savedScene={SavedSceneId} blob={BlobName} sceneRow={SceneRowFound} scenes={SceneCount}", + pid, roomId, body.RoomSceneId, savedSceneId, newBlob, sceneRow is not null, sceneRowsForRoom.Count); + var playersInInstance = onlinePresence.OnlinePlayerIds() + .Where(playerId => + { + var playerRoom = presence.GetRoom(playerId); + return playerRoom is not null + && playerRoom.RoomId == roomId + && playerRoom.RoomInstanceId == current.RoomInstanceId; + }) + .Append(pid) + .Distinct() + .ToArray(); + logger.LogInformation( + "[rooms-save] fanout room update player={PlayerId} room={RoomId} instance={InstanceId} blob={BlobName} recipients={RecipientCount}", + pid, roomId, current.RoomInstanceId, newBlob, playersInInstance.Length); + + var updatedRoomInstances = new Dictionary(); + foreach (var playerId in playersInInstance) + { + var currentPresence = presence.GetRoom(playerId); + if (currentPresence is null && playerId == pid) + { + currentPresence = current; + } + if (currentPresence is null) continue; + + currentPresence.DataBlob = newBlob; + presence.SetRoom(playerId, currentPresence); + updatedRoomInstances[playerId] = currentPresence; + } + + // SubscriptionUpdateRoom delivers the new RoomDetails (with + // Scenes[0].DataBlobName = newBlob). Rooms.OnSubscriptionUpdateRoom + // → StoreRoomDetailsInCache → set_LocalRoomDetails iterates + // Scenes for one matching RoomSceneId == LocalSubRoomId and writes + // LocalRoomScene to that scene, which is what DownloadRoomData + // BlobCoroutine reads when PostSaveReloading runs. + foreach (var playerId in playersInInstance) + { + await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload); + } + + // RoomInstanceUpdate keeps the matchmaking-side dataBlob and any + // friends-list "in room X" indicators in sync. No nudge — live + // doesn't send one, and the fake sub-room id confuses the watch's + // OnLocalGameSessionUpdated scene re-resolve. + foreach (var (playerId, currentPresence) in updatedRoomInstances) + { + await notifications.NotifyAsync( + playerId, + PushNotificationId.SubscriptionUpdateGameSession, + currentPresence); + } + + // PresenceUpdate keeps the next heartbeat aligned with the blob + // applied through the room-details push above. + foreach (var (playerId, currentPresence) in updatedRoomInstances) + { + var presencePayload = new + { + playerId = (int)playerId, + statusVisibility = 1, + deviceClass = 0, + vrMovementMode = 0, + roomInstance = new + { + roomInstanceId = currentPresence.RoomInstanceId, + roomId = currentPresence.RoomId, + subRoomId = currentPresence.SubRoomId, + location = currentPresence.Location, + photonRegionId = currentPresence.PhotonRegionId, + photonRoomId = currentPresence.PhotonRoomId, + maxCapacity = currentPresence.MaxCapacity, + isFull = currentPresence.IsFull, + isPrivate = currentPresence.IsPrivate, + isInProgress = currentPresence.IsInProgress, + dataBlob = currentPresence.DataBlob, + eventId = currentPresence.EventId, + name = currentPresence.Name, + }, + }; + await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload); + } + + return Ok(new + { + RoomSceneId = savedSceneId, + RoomId = roomId, + RoomSceneLocationId = sceneRow?.RoomSceneLocationId ?? room.LocationReplicationId, + Name = sceneRow?.Name ?? "Home", + IsSandbox = sceneRow?.IsSandbox ?? false, + DataBlobName = newBlob, + MaxPlayers = sceneRow?.MaxPlayers ?? 8, + CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true, + DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), + }); + } + + public class SaveRoomSceneRequest + { + public long RoomSceneId { get; set; } + public string? RoomDataFilename { get; set; } + public List? InventionUsages { get; set; } + public CreatorActionContextDto? CreatorActionContext { get; set; } + public long RequestPlayerId { get; set; } + } + + public class CreatorActionContextDto + { + public bool IsTeachableMomentRunning { get; set; } + } + + /// POST api/rooms/v1/restore — switch a room back to a + /// previous save. Body: roomId, blobName (or version id). Updates + /// the player's DormStateEntity (for dorms) or RoomEntity + /// CurrentDataBlobName (for owned rooms) so the next visit loads + /// the historical bytes. + /// POST api/rooms/v1/datahistory/restore — restore a + /// room to a previous save. + /// + /// Request body: RestoreDataHistoryRequest serialized by + /// Unity's JsonUtility.ToJson, which uses C# field names. + /// Verified at Cpp2IL_ISIL/.../Rooms.txt:13513-14 — the only + /// field on the request type is the long history id at offset +16, + /// and Unity's serializer names it roomDataHistoryId. We + /// look up by that id, find its + /// parent room, and rewrite CurrentDataBlobName (and the + /// dorm-state row for personal dorms) so the next room reload + /// downloads the historical bytes. + /// + /// Response: CreateModifyRoomSceneResponse — keys are + /// PascalCase per Cpp2IL_ISIL/.../Rooms_NestedType_CreateModifyRoomSceneResponse.txt: + /// Result (int, CreateModifyRoomStatus: 0=Success), and + /// RoomScene (the restored scene). The watch's + /// ProcessCreateModifyRoomSceneResponse chain expects both; + /// returning a bare {restored: "..."} like the old impl makes + /// the watch's deserializer throw and the restore silently fails. + /// The legacy form-urlencoded path stays under + /// /api/rooms/v1/restore for the admin SPA's restore UI. + /// + public sealed class RestoreDataHistoryRequest + { + // Unity JsonUtility serializes C# private backing fields by + // their declared name. The watch's request type has a single + // field which Unity emits as "roomDataHistoryId" (camelCase) — + // [JsonPropertyName] here matches that wire key explicitly so + // System.Text.Json picks it up regardless of any future global + // naming policy. + [System.Text.Json.Serialization.JsonPropertyName("roomDataHistoryId")] + public long RoomDataHistoryId { get; set; } + } + + [HttpPost("api/rooms/v1/datahistory/restore")] + [Authorize] + public async Task RestoreDataHistory([FromBody] RestoreDataHistoryRequest body) + { + var pid = this.RequireCurrentPlayerId(); + var blob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == body.RoomDataHistoryId); + if (blob is null) return NotFound(new { error = "version_not_found" }); + + var room = await rooms.GetByIdAsync(blob.RoomId); + if (room is null) return NotFound(new { error = "room_not_found" }); + + // Ownership: only the dorm owner / room creator can restore. + // Admin override is intentionally NOT included here — restore + // is destructive (overrides the player's chosen state) so we + // require the actual creator. + if (room.CreatorPlayerId != pid) + return Forbid(); + + if (room.IsDormRoom) + { + var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid); + if (dormState is null) + { + dormState = new DormStateEntity { PlayerId = pid }; + db.DormStates.Add(dormState); + } + dormState.CurrentDataBlobName = blob.BlobName; + dormState.UpdatedAt = DateTime.UtcNow; + } + await db.Rooms + .Where(r => r.Id == room.Id) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.CurrentDataBlobName, blob.BlobName) + .SetProperty(r => r.UpdatedAt, DateTime.UtcNow)); + await db.SaveChangesAsync(); + + // Push the same SubscriptionUpdateRoom the save flow sends so + // the watch's StoreRoomDetailsInCache → SafeRaiseRoomDetailsUpdated + // chain fires and any open room-detail panels refresh. + var pushRoom = room.IsDormRoom ? CloneWithCreator(room, pid, blob.BlobName) : CloneWithCreator(room, room.CreatorPlayerId, blob.BlobName); + var allScenes = await db.RoomScenes + .Where(s => s.RoomId == room.Id) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + var roomDetailsPayload = BuildRoomDetails(pushRoom, allScenes, 0L, blob.BlobName); + await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload); + + // CreateModifyRoomSceneResponse shape. RoomScene fields match + // RecNet.RoomScene.Deserialize (PascalCase, RoomSceneId, + // RoomId, RoomSceneLocationId, Name, IsSandbox, DataBlobName, + // MaxPlayers, CanMatchmakeInto, DataModifiedAt). + return Ok(new + { + Result = 0, // CreateModifyRoomStatus.Success + RoomScene = new + { + RoomSceneId = 0L, + RoomId = room.Id, + RoomSceneLocationId = room.LocationReplicationId, + Name = room.Name, + IsSandbox = false, + DataBlobName = blob.BlobName, + MaxPlayers = 8, + CanMatchmakeInto = true, + DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), + }, + }); + } + + /// Legacy form-urlencoded restore — kept for the admin SPA's + /// "Restore version" picker which posts {roomId, blobName} fields. + /// New callers (the watch) use the JSON variant above. + [HttpPost("api/rooms/v1/restore")] + [Authorize] + public async Task RestoreVersionLegacy( + [FromForm] long roomId, + [FromForm] string? blobName, + [FromForm] long version = 0) + { + var pid = this.RequireCurrentPlayerId(); + var room = await rooms.GetByIdAsync(roomId); + if (room is null) return NotFound(new { error = "room_not_found" }); + + var blob = !string.IsNullOrEmpty(blobName) + ? await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName && b.RoomId == roomId) + : await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == version && b.RoomId == roomId); + if (blob is null) return NotFound(new { error = "version_not_found" }); + + if (room.CreatorPlayerId != pid) + return Forbid(); + + if (room.IsDormRoom) + { + var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid); + if (dormState is null) + { + dormState = new DormStateEntity { PlayerId = pid }; + db.DormStates.Add(dormState); + } + dormState.CurrentDataBlobName = blob.BlobName; + dormState.UpdatedAt = DateTime.UtcNow; + } + await db.Rooms + .Where(r => r.Id == roomId) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.CurrentDataBlobName, blob.BlobName) + .SetProperty(r => r.UpdatedAt, DateTime.UtcNow)); + await db.SaveChangesAsync(); + return Ok(new { restored = blob.BlobName }); + } + + /// GET api/rooms/v{1,2}/personaldetails/{roomId} — + /// per-caller bookmark/cheer flags for one room. Wire shape verified + /// against RecNet.PersonalRoomDetails.Deserialize + /// (Cpp2IL_ISIL/.../PersonalRoomDetails.txt:85-91): + /// IsCheering - bool, REQUIRED (Util.GetKey, throws if missing) + /// IsBookmarked - bool, REQUIRED + /// Both keys are PascalCase. Returning the full room-details payload + /// here (which is what we used to do) makes the deserialiser throw + /// KeyNotFoundException for IsCheering, and that blocks the entire + /// /goto/* flow because RecNet.Rooms.GetPersonalRoomDetails is + /// awaited before SessionManager.JoinRoom can run. + [HttpGet("api/rooms/v2/personaldetails/{roomId:long}")] + [HttpGet("api/rooms/v1/personaldetails/{roomId:long}")] + public async Task PersonalDetails(long roomId) + { + var pid = CurrentPlayerId ?? 0; + bool cheering = false, bookmarked = false; + if (pid > 0) + { + cheering = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid); + bookmarked = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid); + } + return Ok(new { IsCheering = cheering, IsBookmarked = bookmarked }); + } + + [HttpGet("api/rooms/v4/details/{roomId:long}")] + [HttpGet("api/rooms/v3/details/{roomId:long}")] + public async Task Details(long roomId) + { + var r = await rooms.GetByIdAsync(roomId); + var room = r ?? Synthetic($"Room_{roomId}", roomId); + + // Dorm-specific shaping: each player has their own first-class + // dorm row. Only the dorm's actual creator should see Creator + // role / Maker Pen. Guests loading someone else's dorm must see + // the owner as CreatorPlayerId, while still receiving the + // owner's DormStateEntity blob so they load the same room state. + if (room.IsDormRoom) + { + var dormBlobName = await db.DormStates + .Where(d => d.PlayerId == room.CreatorPlayerId) + .Select(d => d.CurrentDataBlobName) + .FirstOrDefaultAsync() + ?? string.Empty; + room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName); + } + + var scenes = await db.RoomScenes + .Where(s => s.RoomId == room.Id) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + var roomRoles = await db.RoomRoles + .Where(r => r.RoomId == room.Id) + .ToListAsync(); + return Ok(BuildRoomDetails(room, scenes, roles: roomRoles)); + } + + /// + /// Shallow clone of a RoomEntity with a different CreatorPlayerId + /// and (optionally) a different CurrentDataBlobName. Used to give + /// the per-caller dorm the appearance of being authored by the + /// caller and pointing at the caller's own most recent save. + /// Doesn't touch the DB — purely a response-shaping utility. + /// + public static RoomEntity CloneWithCreator( + RoomEntity src, long creatorId, string? overrideBlobName = null) => new() + { + Id = src.Id, + Name = src.Name, + Description = src.Description, + CreatorPlayerId = creatorId, + ImageName = src.ImageName, + State = src.State, + Accessibility = src.Accessibility, + SupportsLevelVoting = src.SupportsLevelVoting, + IsAGRoom = src.IsAGRoom, + IsDormRoom = src.IsDormRoom, + CloningAllowed = src.CloningAllowed, + SupportsVRLow = src.SupportsVRLow, + SupportsMobile = src.SupportsMobile, + SupportsScreens = src.SupportsScreens, + SupportsWalkVR = src.SupportsWalkVR, + SupportsTeleportVR = src.SupportsTeleportVR, + AllowsJuniors = src.AllowsJuniors, + RoomWarningMask = src.RoomWarningMask, + CustomRoomWarning = src.CustomRoomWarning, + DisableMicAutoMute = src.DisableMicAutoMute, + LocationReplicationId = src.LocationReplicationId, + TagsCsv = src.TagsCsv, + CheerCount = src.CheerCount, + FavoriteCount = src.FavoriteCount, + VisitCount = src.VisitCount, + HotScore = src.HotScore, + CreatedAt = src.CreatedAt, + UpdatedAt = src.UpdatedAt, + CurrentDataBlobName = overrideBlobName ?? src.CurrentDataBlobName, + }; + + /// + /// Wire shape for the RoomDetails object — matches RoomDetails.Deserialize + /// at RVA 0x114C580. JSON key names DO NOT necessarily match the C# + /// property/field names from dump.cs — the deserializer is hand-written + /// and uses different (mostly PascalCase) literal strings. Verified + /// empirically: this exact shape gets past Deserialize successfully. + /// + public static object BuildRoomDetails( + RoomEntity room, + IReadOnlyList? sceneRows = null, + long? overrideSceneId = null, + string? overrideDataBlobName = null, + IReadOnlyList? roles = null) + { + // Tags is a list of OBJECTS, not strings. RoomDetails.Deserialize + // does Util.DeserializeList(tags) — each entry is parsed as + // a Dictionary with `Type` (int RoomTagType: 0=Auto, 1=PlayerAdded, + // 2=AG) and `Tag` (string). Sending bare strings crashes: + // InvalidCastException: Unable to cast 'String' to 'Dictionary`2' + var tags = string.IsNullOrEmpty(room.TagsCsv) + ? Array.Empty() + : room.TagsCsv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(t => (object)new { Type = 0, Tag = t }) + .ToArray(); + + // Scenes population: every room must produce a Scenes[0] entry + // with RoomSceneId=0 (matching the matchmaking SubRoomId=0) so + // that Rooms.set_LocalRoomDetails actually transitions + // Rooms.LocalRoomScene to the new room's scene reference. + // + // Why every room: the setter (verified in Cpp2IL ISIL of + // RecNet.Rooms.set_LocalRoomDetails) iterates Scenes looking + // for a RoomSceneId match; if none matches (Scenes=[] or + // mismatched), it falls through WITHOUT writing LocalRoomScene + // — so the field stays stale at whatever the previous room set. + // After the dorm fix landed, that stale value started leaking + // into the rec center: the dorm sets LocalRoomScene → user + // goes to rec center → if we send Scenes=[], LocalRoomScene + // STAYS pointing at the dorm scene → DownloadRoomDataBlob + // Coroutine reads scene.DataBlobName from the dorm and + // downloads room_1_v1.dat (smoking gun in the server log) + // during the rec center load. The rec center master init + // blocks → FROSTBITE. + // + // The DataBlobName field is what determines whether the + // persistence coroutine actually downloads anything. + // OnRoomDataBlobNameChanged (RoomPersistenceManager.txt ISIL + // {027-040}) short-circuits when DataBlobName is empty: it + // sets GetRoomDataBlobPromise to a CompletedPromise + // with empty bytes and returns immediately — no HTTP fetch, + // no DeserializeRoomDataBlobCoroutine, the master init + // proceeds with default-zero permissions. + // + // • Dorm / customisable rooms: DataBlobName is either the + // latest uploaded blob name (RoomEntity.CurrentDataBlobName, + // written by StorageController on /upload) or, before any + // save, "room__v1.dat" — which the catch-all serves + // from RoomDataBlobService (the all-perms default blob). + // • AG-Original rooms: DataBlobName="" → completed-promise + // short-circuit → no download → no leaked stale-blob + // behaviour → master flow proceeds → spawn fires. Maker + // Pen stays locked, which matches public-server semantics + // (you can't edit the rec center). + // + // Heuristic: "customisable" iff it's the dorm OR a user-created + // room (CreatorPlayerId != 1, the seeded system account that + // owns AG-Originals like the rec center). User clones inherit + // the all-perms default blob until their first save replaces + // it via StorageController; the rec center / paintball / etc. + // serve "" so the persistence flow short-circuits. + var customisable = room.IsDormRoom || room.CreatorPlayerId != 1; + var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName) + ? room.CurrentDataBlobName + : customisable ? $"room_{room.Id}_v1.dat" : ""; + + // Multi-scene rooms (imported via tools/import-room.ps1) carry one + // RoomSceneEntity row per sub-room. Emit the full Scenes[] array + // when present so cross-scene portals (Lobby→Ch1, etc.) can + // resolve. Single-scene rooms (every legacy room, every dorm, + // every AG-Original) fall back to the synthesised Scenes[0] from + // RoomEntity fields. + object[] scenes; + if (sceneRows is { Count: > 0 }) + { + scenes = sceneRows + .Select(s => (object)new + { + RoomSceneId = (long)s.OrderIndex, + RoomId = room.Id, + RoomSceneLocationId = s.RoomSceneLocationId, + Name = s.Name, + IsSandbox = s.IsSandbox, + DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) && + (overrideSceneId == s.OrderIndex || sceneRows.Count == 1) + ? overrideDataBlobName + : s.DataBlobName, + MaxPlayers = s.MaxPlayers, + CanMatchmakeInto = s.CanMatchmakeInto, + DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) + .ToString("yyyy-MM-ddTHH:mm:ssZ"), + }) + .ToArray(); + } + else + { + scenes = new object[] + { + new + { + RoomSceneId = 0L, // MUST match RoomInstance.SubRoomId + RoomId = room.Id, + RoomSceneLocationId = room.LocationReplicationId, + Name = "Home", + IsSandbox = false, + DataBlobName = dataBlobName, + MaxPlayers = 8, + CanMatchmakeInto = true, + // Use the room's actual UpdatedAt so the watch's + // "is my local cache stale?" check sees an accurate + // freshness timestamp. Hardcoding the build date + // (2020-03-06) made the watch flag every dorm visit + // as "room is not up to date" because the cached + // local copy was always newer than the server's + // declared timestamp. + DataModifiedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt) + .ToString("yyyy-MM-ddTHH:mm:ssZ"), + }, + }; + } + + // Owners tile (UIRoomDetails bottom-left) reads CoOwners — the + // 2020 RoomDetails.Deserialize reads keys WITHOUT the "Ids" + // suffix (verified at RoomDetails.txt:1096/1141/1146/1191/1196: + // "CoOwners" / "InvitedCoOwners" / "Moderators" / + // "InvitedModerators" / "Hosts" / "InvitedHosts"). The + // GetListKey calls are strict — wrong names throw + // KeyNotFoundException → "Failed to get room details: + // Malformed Response" → dorm load stalls in a redirect loop. + // + // CreatorPlayerId is always implicitly a CoOwner so RR Originals + // surface Coach (id=1) as the owner instead of rendering blank. + // Additional grants come from RoomRoleEntity rows when supplied; + // callers without role data (clone/myrooms/etc.) ship just the + // implicit creator. + var roleList = roles ?? Array.Empty(); + int[] PlayersIn(int role, bool accepted) => roleList + .Where(r => r.Role == role && r.Accepted == accepted) + .Select(r => (int)r.PlayerId) + .ToArray(); + var coOwnerSet = new HashSet { (int)room.CreatorPlayerId }; + foreach (var pid in PlayersIn(0, true)) coOwnerSet.Add(pid); + return new + { + Room = RoomService.ToWireRoom(room), + Scenes = scenes, + CoOwners = coOwnerSet.ToArray(), + InvitedCoOwners = PlayersIn(0, false), + Moderators = PlayersIn(1, true), + InvitedModerators = PlayersIn(1, false), + Hosts = PlayersIn(2, true), + InvitedHosts = PlayersIn(2, false), + CheerCount = room.CheerCount, + FavoriteCount = room.FavoriteCount, + VisitCount = room.VisitCount, + // VisitorCount is the official Rec.Net "unique visitors" + // companion field (see Stats payload at apim.rec.net). + // 2020 RoomDetails.Deserialize doesn't read it but leaving + // it on the wire is harmless and lets the admin UI / any + // future client surface the number. + VisitorCount = room.VisitorCount, + Tags = tags, + }; + } + + + // ── Clone / create ─────────────────────────────────────────────────── + + /// + /// `Rooms.CloneRoom(roomIdToClone, name)` — POST api/rooms/v1/clone + /// with body `{"Name":"","RoomId":}`. + /// + /// Response shape (CreateModifyRoomResponse.Deserialize, RVA 0xAF1950): + /// Result (int CreateModifyRoomStatus: 0=Success, 1=Unknown, + /// 2=PermissionDenied, 3=RoomNotActive, + /// 4=RoomDoesNotExist, 5=RoomHasNoDataBlob, …) + /// RoomDetails (RoomDetails object, only populated on Result=0) + /// + [HttpPost("api/rooms/v1/clone")] + [Authorize] + public async Task Clone([FromBody] CloneRoomRequest req) + { + var pid = CurrentPlayerId; + if (pid is null) return Unauthorized(); + + // CloneAsync sets the CreateModifyRoomStatus code itself + // (Success/RoomDoesNotExist/DuplicateName/InappropriateName) so the + // watch can show the right toast instead of a generic "Unknown". + var result = await rooms.CloneAsync(req.RoomId, req.Name ?? string.Empty, pid.Value); + return Ok(new + { + Result = result.Status, + RoomDetails = result.Room is null ? null : BuildRoomDetails(result.Room), + }); + } + + public class CloneRoomRequest + { + public string? Name { get; set; } + public long RoomId { get; set; } + } + + // ── My-Rooms tabs ──────────────────────────────────────────────────── + + [HttpGet("api/rooms/v2/myrooms")] + [Authorize] + public async Task MyRooms() + { + var pid = CurrentPlayerId; + if (pid is null) return Ok(Array.Empty()); + return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList()); + } + + [HttpGet("api/rooms/v2/mybookmarks")] + [HttpGet("api/rooms/v2/mybookmarkedrooms")] + [Authorize] + public async Task MyBookmarks() + { + var pid = CurrentPlayerId; + if (pid is null) return Ok(Array.Empty()); + return Ok((await rooms.BookmarkedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList()); + } + + [HttpGet("api/rooms/v2/mymoderated")] + [HttpGet("api/rooms/v1/modrooms")] + [HttpGet("api/rooms/v2/mysubscribed")] + [HttpGet("api/rooms/v2/myrecent")] + public IActionResult MyOtherTabs() => Ok(Array.Empty()); + + // ── Bookmark mutations ─────────────────────────────────────────────── + + [HttpPost("api/rooms/v1/bookmark/{roomId:long}")] + [HttpPost("api/rooms/v2/bookmark/{roomId:long}")] + [Authorize] + public async Task Bookmark(long roomId) + { + var pid = CurrentPlayerId; + if (pid is null) return Unauthorized(); + await rooms.BookmarkAsync(pid.Value, roomId); + return Ok(new { success = true, error = "" }); + } + + [HttpDelete("api/rooms/v1/bookmark/{roomId:long}")] + [HttpDelete("api/rooms/v2/bookmark/{roomId:long}")] + [Authorize] + public async Task Unbookmark(long roomId) + { + var pid = CurrentPlayerId; + if (pid is null) return Unauthorized(); + await rooms.UnbookmarkAsync(pid.Value, roomId); + return Ok(new { success = true, error = "" }); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + /// GET api/rooms/v1/agRoomIds — list of every + /// seeded AG / RR-Original room id. Used by the watch to bias the + /// room browser toward official rooms first. + [HttpGet("api/rooms/v1/agRoomIds")] + public async Task AgRoomIds() => + Ok(await rooms.AgRoomIdsAsync()); + + /// GET api/rooms/v1/featuredRoomGroup + + /// v3/featured — the "Featured" carousel on the watch's + /// rooms tab. Returns the top-12 AG rooms by HotScore. + [HttpGet("api/rooms/v1/featuredRoomGroup")] + [HttpGet("api/rooms/v3/featured")] + public async Task Featured() => Ok(new + { + Name = "Featured", + FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12), + }); + + /// POST api/rooms/v1/bookmark — toggle bookmark + /// state for a room. Body: RoomId + optional Bookmark + /// flag (default = add). + [HttpPost("api/rooms/v1/bookmark")] + [Authorize] + public async Task BookmarkV1( + [FromForm(Name = "RoomId")] long? roomId, + [FromForm(Name = "Bookmark")] bool? bookmark) + { + if (roomId is not long rid) return Ok(new { success = true }); + var pid = this.RequireCurrentPlayerId(); + if (bookmark == false) await rooms.UnbookmarkAsync(pid, rid); + else await rooms.BookmarkAsync(pid, rid); + return Ok(new { success = true, error = "" }); + } + + /// POST api/rooms/v1/cheer — cheer a room (body + /// form). Idempotent per (caller, room) — duplicate taps don't + /// inflate CheerCount. + [HttpPost("api/rooms/v1/cheer")] + [Authorize] + public async Task CheerRoom( + [FromForm(Name = "RoomId")] long? roomId, + [FromForm(Name = "Type")] int? type) + { + if (roomId is not long rid) return Ok(new { success = true }); + var me = this.RequireCurrentPlayerId(); + var (newCount, already) = await rooms.CheerRoomAsync(me, rid, type ?? 0); + return Ok(new { RoomId = rid, CheerCount = newCount, AlreadyCheered = already }); + } + + /// POST api/rooms/v1/uncheer — remove a previously + /// applied room cheer. + [HttpPost("api/rooms/v1/uncheer")] + [HttpPost("api/rooms/v1/uncheer/{roomId:long}")] + [Authorize] + public async Task UncheerRoom( + long? roomId, + [FromForm(Name = "RoomId")] long? roomIdForm, + [FromForm(Name = "Type")] int? type) + { + var rid = roomId ?? roomIdForm ?? 0; + if (rid == 0) return Ok(new { success = true }); + var me = this.RequireCurrentPlayerId(); + var already = await rooms.UncheerRoomAsync(me, rid, type ?? 0); + return Ok(new { RoomId = rid, AlreadyUncheered = already }); + } + + /// GET api/rooms/v1/cheers/{roomId} — did the + /// caller cheer this room, and what's the current count? + [HttpGet("api/rooms/v1/cheers/{roomId:long}")] + [Authorize] + public async Task CheckCheered(long roomId) + { + var me = this.CurrentPlayerId(); + var (count, iCheered) = await rooms.GetCheerStateAsync(me ?? 0, roomId); + return Ok(new { RoomId = roomId, CheerCount = count, ICheered = iCheered }); + } + + /// + /// Synthesise a placeholder Room when the client asks for one we don't + /// have in the DB. Keeps the deserializer happy — every required key + /// is populated. Safe defaults: public, AG, default DormRoom location. + /// + /// Special case: roomId=1 is the canonical DormRoom id used by + /// GoToController when the client visits its own dorm. Make sure the + /// /v4/details/1 follow-up shows "DormRoom" rather than "Room_1". + /// + private static RoomEntity Synthetic(string name, long? id = null) + { + var isDorm = (id == 1) || name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase); + var displayName = isDorm ? "DormRoom" : name; + return new RoomEntity + { + Id = id ?? Math.Abs((long)name.GetHashCode()), + Name = displayName, + Description = isDorm + ? "Your private dorm — yours alone, decorated however you like." + : $"{displayName} room", + CreatorPlayerId = 1, + State = 0, + Accessibility = isDorm ? 0 /* Private */ : 1 /* Public */, + IsAGRoom = !isDorm, + IsDormRoom = isDorm, + LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163", + }; + } +} diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs new file mode 100644 index 0000000..37f7daa --- /dev/null +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -0,0 +1,319 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.Rooms.V2; + +/// +/// api.rec.net/api/rooms/v{1,2}/{modify,modifyPermissions,banfromroom, +/// importroombans,roombans,report,modify/scene,modify/sceneParent, +/// modify/tags,modifyscene} — owner-side mutation surface for rooms. +/// +/// Split from to keep that file +/// (browse / details / clone) at a manageable size. All endpoints +/// are owner-gated (the caller must equal +/// ) or admin-bypassed via +/// on the admin-only sub-routes. +/// +[ApiController] +[Authorize] +public class RoomsModerationController(DorkNetDbContext db) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + private async Task RequireOwnedRoomAsync(long roomId) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) return null; + return room.CreatorPlayerId == Me ? room : null; + } + + // ── /modify (general fields) ───────────────────────────────────────── + + public sealed class ModifyRoomRequest + { + public long RoomId { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? ImageName { get; set; } + public string? Tags { get; set; } + public int? Accessibility { get; set; } + public bool? SupportsScreens { get; set; } + public bool? SupportsMobile { get; set; } + public bool? SupportsVRLow { get; set; } + public bool? SupportsWalkVR { get; set; } + public bool? SupportsTeleportVR { get; set; } + public bool? DisableMicAutoMute { get; set; } + } + + [HttpPost("api/rooms/v2/modify")] + public async Task Modify([FromBody] ModifyRoomRequest req) + { + var room = await RequireOwnedRoomAsync(req.RoomId); + if (room is null) return NotFound(); + + if (!string.IsNullOrWhiteSpace(req.Name)) room.Name = req.Name.Trim(); + if (req.Description is not null) room.Description = req.Description; + if (req.ImageName is not null) room.ImageName = req.ImageName; + if (req.Tags is not null) room.TagsCsv = req.Tags; + if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2); + if (req.SupportsScreens.HasValue) room.SupportsScreens = req.SupportsScreens.Value; + if (req.SupportsMobile.HasValue) room.SupportsMobile = req.SupportsMobile.Value; + if (req.SupportsVRLow.HasValue) room.SupportsVRLow = req.SupportsVRLow.Value; + if (req.SupportsWalkVR.HasValue) room.SupportsWalkVR = req.SupportsWalkVR.Value; + if (req.SupportsTeleportVR.HasValue) room.SupportsTeleportVR = req.SupportsTeleportVR.Value; + if (req.DisableMicAutoMute.HasValue) room.DisableMicAutoMute = req.DisableMicAutoMute.Value; + room.UpdatedAt = DateTime.UtcNow; + + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + // Likely a unique-name collision. + return Conflict(new { Result = 2, Message = "name taken" }); + } + return Ok(new { Result = 0, Room = Services.RoomService.ToWireRoom(room) }); + } + + public sealed class ModifyPermissionsRequest + { + public long RoomId { get; set; } + public int? Accessibility { get; set; } + public bool? SupportsLevelVoting { get; set; } + public bool? AllowsJuniors { get; set; } + public bool? CloningAllowed { get; set; } + public int? RoomWarningMask { get; set; } + public string? CustomRoomWarning { get; set; } + } + + [HttpPost("api/rooms/v2/modifyPermissions")] + public async Task ModifyPermissions([FromBody] ModifyPermissionsRequest req) + { + var room = await RequireOwnedRoomAsync(req.RoomId); + if (room is null) return NotFound(); + + if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2); + if (req.SupportsLevelVoting.HasValue) room.SupportsLevelVoting = req.SupportsLevelVoting.Value; + if (req.AllowsJuniors.HasValue) room.AllowsJuniors = req.AllowsJuniors.Value; + if (req.CloningAllowed.HasValue) room.CloningAllowed = req.CloningAllowed.Value; + if (req.RoomWarningMask.HasValue) room.RoomWarningMask = req.RoomWarningMask.Value; + if (req.CustomRoomWarning is not null) room.CustomRoomWarning = req.CustomRoomWarning; + room.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(Services.RoomService.ToWireRoom(room)); + } + + // ── Scene mutations (LocationReplicationId) ────────────────────────── + + public sealed class ModifySceneRequest + { + public long RoomId { get; set; } + public string? LocationReplicationId { get; set; } + } + + [HttpPost("api/rooms/v1/modify/scene")] + [HttpPost("api/rooms/v1/modifyscene")] + public async Task ModifyScene([FromBody] ModifySceneRequest req) + { + var room = await RequireOwnedRoomAsync(req.RoomId); + if (room is null) return NotFound(); + if (!string.IsNullOrWhiteSpace(req.LocationReplicationId)) + room.LocationReplicationId = req.LocationReplicationId; + room.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(Services.RoomService.ToWireRoom(room)); + } + + public sealed class ModifySceneParentRequest + { + public long RoomId { get; set; } + public long? ParentRoomId { get; set; } + } + + /// Sub-room / nested-scene parent override. We don't + /// model sub-rooms as a real graph, so we accept-and-ignore but + /// still owner-gate to prevent grief. + [HttpPost("api/rooms/v1/modify/sceneParent")] + public async Task ModifySceneParent([FromBody] ModifySceneParentRequest req) + { + var room = await RequireOwnedRoomAsync(req.RoomId); + if (room is null) return NotFound(); + return Ok(new { Result = 0 }); + } + + public sealed class ModifyTagsRequest + { + public long RoomId { get; set; } + public string? Tags { get; set; } + } + + [HttpPost("api/rooms/v1/modify/tags")] + public async Task ModifyTags([FromBody] ModifyTagsRequest req) + { + var room = await RequireOwnedRoomAsync(req.RoomId); + if (room is null) return NotFound(); + room.TagsCsv = req.Tags ?? string.Empty; + room.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(Services.RoomService.ToWireRoom(room)); + } + + // ── Bans ───────────────────────────────────────────────────────────── + + public sealed class BanFromRoomRequest + { + public long RoomId { get; set; } + public long PlayerId { get; set; } + public int BanType { get; set; } // 0=Soft (kick), 1=Permanent, 2=Temp + public DateTime? Until { get; set; } + public string? Reason { get; set; } + } + + [HttpPost("api/rooms/v2/banfromroom")] + public async Task BanFromRoom([FromBody] BanFromRoomRequest req) + { + var room = await RequireOwnedRoomAsync(req.RoomId); + if (room is null) return NotFound(); + db.RoomBans.Add(new RoomBanEntity + { + RoomId = req.RoomId, + BannedPlayerId = req.PlayerId, + BannedByPlayerId = Me, + BanType = req.BanType, + Until = req.Until, + Reason = req.Reason ?? string.Empty, + }); + await db.SaveChangesAsync(); + return Ok(new { Result = 0 }); + } + + public sealed class ImportRoomBansRequest + { + public long RoomId { get; set; } + public List? Bans { get; set; } + } + + [HttpPost("api/rooms/v1/importroombans")] + public async Task ImportRoomBans([FromBody] ImportRoomBansRequest req) + { + var room = await RequireOwnedRoomAsync(req.RoomId); + if (room is null) return NotFound(); + if (req.Bans is null || req.Bans.Count == 0) return Ok(new { Imported = 0 }); + + foreach (var b in req.Bans) + { + db.RoomBans.Add(new RoomBanEntity + { + RoomId = req.RoomId, + BannedPlayerId = b.PlayerId, + BannedByPlayerId = Me, + BanType = b.BanType, + Until = b.Until, + Reason = b.Reason ?? string.Empty, + }); + } + await db.SaveChangesAsync(); + return Ok(new { Imported = req.Bans.Count }); + } + + [HttpGet("api/rooms/v1/roombans/{roomId:long}")] + public async Task RoomBansList(long roomId) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) return NotFound(); + // Only owner sees the ban list (admins also via existing + // admin endpoints). + if (room.CreatorPlayerId != Me) return Forbid(); + var rows = await db.RoomBans + .Where(b => b.RoomId == roomId && (b.Until == null || b.Until > DateTime.UtcNow)) + .OrderByDescending(b => b.CreatedAt) + .ToListAsync(); + return Ok(rows.Select(b => new + { + b.Id, + b.RoomId, + b.BannedPlayerId, + b.BannedByPlayerId, + b.BanType, + b.Until, + b.Reason, + b.CreatedAt, + })); + } + + // ── Report ─────────────────────────────────────────────────────────── + + public sealed class RoomReportRequest + { + public long RoomId { get; set; } + public int Category { get; set; } + public string? Message { get; set; } + } + + [HttpPost("api/rooms/v2/report")] + public async Task Report([FromBody] RoomReportRequest req) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId); + if (room is null) return NotFound(); + var msg = req.Message ?? string.Empty; + db.Reports.Add(new ReportEntity + { + ReporterPlayerId = Me, + TargetPlayerId = room.CreatorPlayerId, + TargetRoomId = req.RoomId, + RoomId = req.RoomId, + Category = req.Category, + Message = msg[..Math.Min(1000, msg.Length)], + }); + await db.SaveChangesAsync(); + return Ok(new { Reported = true }); + } + + public sealed class RoomRolePermissionRequest + { + public long RoomId { get; set; } + public long PlayerId { get; set; } + } + + /// POST api/rooms/v1/roomRolePermissions — watch + /// posts its self-computed role + permission grants for + /// server-side validation. Role enum: + /// 0=Visitor, 5=Member, 10=Mod, 20=Host, 30=Owner. Room creator + /// is always Owner; everyone else defaults to Visitor. + [HttpPost("api/rooms/v1/roomRolePermissions")] + [AllowAnonymous] + public async Task RoomRolePermissions( + [FromBody] RoomRolePermissionRequest? body, + [FromForm(Name = "RoomId")] long? roomIdForm, + [FromForm(Name = "PlayerId")] long? playerIdForm) + { + var roomId = body?.RoomId ?? roomIdForm ?? 0; + var playerId = body?.PlayerId ?? playerIdForm ?? (this.CurrentPlayerId() ?? 0); + if (roomId <= 0 || playerId <= 0) + return Ok(new { success = true, error = "" }); + + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + int role = (room?.CreatorPlayerId == playerId) ? 30 : 0; + long permissions = role switch + { + 30 => -1L, + 10 => 0x0FFFFFFFL, + 5 => 0x000000FFL, + _ => 0x0000000FL, + }; + return Ok(new + { + success = true, + error = "", + RoomId = roomId, + PlayerId = (int)playerId, + Role = role, + Permissions = permissions, + }); + } +} diff --git a/DorkNet.Server/Controllers/API/Royale/RoyaleController.cs b/DorkNet.Server/Controllers/API/Royale/RoyaleController.cs new file mode 100644 index 0000000..fea72e1 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Royale/RoyaleController.cs @@ -0,0 +1,176 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.Royale; + +/// +/// api.rec.net/api/royale/* — Rec Royale battle-royale stats. +/// +/// Per RecNet.RecNetRecRoyaleStats (decompiled at +/// Cpp2IL_CS/.../RecNet/RecNetRecRoyaleStats.cs) the client +/// only calls TWO endpoints: +/// +/// • GET api/royale/v1/current → RecRoyalePlayerProgress: +/// TotalXP, Level, RankIdx, RankName, CurrentLevelXPThreshold, +/// NextLevelXPThreshold, NextLevelAcornReward. +/// • POST api/royale/v2/matchcomplete with body +/// MatchCompleteStats: Rank, NumEliminations, SecondsAlive, +/// WalkGame, CustomGame, ChestsOpened, ShieldPotionsConsumed, +/// HealthPotionsConsumed, SecondsInAir; returns StatUpdates: +/// XPAwardStrings, TotalXPAwarded, NewProgress. +/// +/// We persist match rows to + +/// for server-side audit, and +/// roll the per-player . +/// +[ApiController] +public class RoyaleController(DorkNetDbContext db) : ControllerBase +{ + // Rank ladder names (from the watch's RankIdx → RankName mapping). + // Approximated; the client's ladder isn't exposed in the dump but + // these are the standard 2020-era Royale tiers. + private static readonly string[] RankNames = + { + "Recruit", "Cadet", "Sergeant", "Lieutenant", "Captain", + "Major", "Colonel", "Commander", "Champion", "Legend", + }; + + private static long XpForLevel(int level) => 100L * level * level; + + private static (int level, int rankIdx, string rankName, long currentThreshold, long nextThreshold, int reward) + Compute(long totalXp) + { + int lvl = 1; + while (XpForLevel(lvl + 1) <= totalXp) lvl++; + var rankIdx = Math.Min(RankNames.Length - 1, lvl / 5); + var current = XpForLevel(lvl); + var next = XpForLevel(lvl + 1); + var reward = 50 + 10 * lvl; + return (lvl, rankIdx, RankNames[rankIdx], current, next, reward); + } + + [HttpGet("api/royale/v1/current")] + public async Task Current() + { + var pid = this.CurrentPlayerId(); + if (pid is not long me) return Ok(BlankProgress()); + var row = await db.RoyalePlayerProgress.FirstOrDefaultAsync(p => p.PlayerId == me); + if (row is null) return Ok(BlankProgress()); + return Ok(ToWireProgress(row)); + } + + public sealed class MatchCompleteStats + { + public int Rank { get; set; } + public int NumEliminations { get; set; } + public int SecondsAlive { get; set; } + public bool WalkGame { get; set; } + public bool CustomGame { get; set; } + public int ChestsOpened { get; set; } + public int ShieldPotionsConsumed { get; set; } + public int HealthPotionsConsumed { get; set; } + public int SecondsInAir { get; set; } + } + + [HttpPost("api/royale/v2/matchcomplete")] + [Authorize] + public async Task MatchComplete([FromBody] MatchCompleteStats body) + { + var pid = this.RequireCurrentPlayerId(); + if (body is null) return BadRequest("missing body"); + + var match = new RoyaleMatchEntity + { + CompletedAt = DateTime.UtcNow, + Rank = body.Rank, + NumEliminations = body.NumEliminations, + SecondsAlive = body.SecondsAlive, + WalkGame = body.WalkGame, + CustomGame = body.CustomGame, + ChestsOpened = body.ChestsOpened, + ShieldPotionsConsumed = body.ShieldPotionsConsumed, + HealthPotionsConsumed = body.HealthPotionsConsumed, + SecondsInAir = body.SecondsInAir, + }; + db.RoyaleMatches.Add(match); + await db.SaveChangesAsync(); + + db.RoyaleMatchPlayers.Add(new RoyaleMatchPlayerEntity + { + MatchId = match.Id, + PlayerId = pid, + Rank = body.Rank, + NumEliminations = body.NumEliminations, + SecondsAlive = body.SecondsAlive, + }); + + // Compute XP awards. Match the watch's pattern: + // • Survival: 1 XP per 10 seconds alive + // • Eliminations: 25 XP each + // • Placement: bonus inversely proportional to rank + // • Custom games don't award XP + var awards = new List<(string label, int xp)>(); + if (!body.CustomGame) + { + var survivalXp = body.SecondsAlive / 10; + var killXp = body.NumEliminations * 25; + var rankXp = Math.Max(0, 200 - body.Rank * 5); + if (survivalXp > 0) awards.Add(($"Time Alive: {body.SecondsAlive}s", survivalXp)); + if (killXp > 0) awards.Add(($"Eliminations: {body.NumEliminations}", killXp)); + if (rankXp > 0) awards.Add(($"Rank #{body.Rank}", rankXp)); + if (body.Rank == 1) awards.Add(("Victory Royale!", 500)); + } + var totalAward = awards.Sum(a => a.xp); + + // Update progress. + var row = await db.RoyalePlayerProgress.FirstOrDefaultAsync(p => p.PlayerId == pid); + if (row is null) + { + row = new RoyalePlayerProgressEntity { PlayerId = pid }; + db.RoyalePlayerProgress.Add(row); + } + row.TotalXP += totalAward; + var (lvl, rankIdx, rankName, current, next, reward) = Compute(row.TotalXP); + row.Level = lvl; + row.RankIdx = rankIdx; + row.RankName = rankName; + row.CurrentLevelXPThreshold = current; + row.NextLevelXPThreshold = next; + row.NextLevelAcornReward = reward; + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + return Ok(new + { + XPAwardStrings = awards.Select(a => $"+{a.xp} XP — {a.label}").ToList(), + TotalXPAwarded = (long)totalAward, + NewProgress = new[] { ToWireProgress(row) }, + }); + } + + private static object ToWireProgress(RoyalePlayerProgressEntity p) => new + { + p.TotalXP, + p.Level, + p.RankIdx, + p.RankName, + p.CurrentLevelXPThreshold, + p.NextLevelXPThreshold, + p.NextLevelAcornReward, + }; + + private static object BlankProgress() => new + { + TotalXP = 0L, + Level = 1, + RankIdx = 0, + RankName = "Recruit", + CurrentLevelXPThreshold = 0L, + NextLevelXPThreshold = 100L, + NextLevelAcornReward = 50, + }; +} diff --git a/DorkNet.Server/Controllers/API/Sanitize/SanitizeController.cs b/DorkNet.Server/Controllers/API/Sanitize/SanitizeController.cs new file mode 100644 index 0000000..928eaf4 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Sanitize/SanitizeController.cs @@ -0,0 +1,118 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Sanitize; + +/// +/// api.{rec.net,localhost}/api/sanitize/* — server-side profanity filter +/// the watch's text-input UI calls before allowing a room name / +/// invention name / chat message to be submitted. Delegates to +/// for the actual scrubbing. Response +/// shape: {Text (purified), IsClean (bool)}. +/// +[ApiController] +public class SanitizeController(ILogger logger) : ControllerBase +{ + [HttpPost("api/sanitize/v1")] + [HttpPost("api/sanitize/v1/text")] + public async Task Sanitize() + { + // RecRoom.StringSanitization.PurifyString sends JSON body + // {"Value":"","ReplacementChar":42} — verified from a + // live chat-send error trace ("Received malformed RecNet + // response: ...Text:{\"Value\":...}..."). Earlier we used to + // pass the whole body through as "text", which made Text in + // the response contain the literal JSON string, and the watch's + // LitJson then choked on the malformed wrap. + string text = string.Empty; + try + { + if (Request.HasFormContentType) + text = Request.Form["Text"].ToString(); + } + catch { } + if (string.IsNullOrEmpty(text)) + { + using var reader = new StreamReader(Request.Body); + var raw = await reader.ReadToEndAsync(); + if (!string.IsNullOrEmpty(raw)) + { + var first = raw.AsSpan().TrimStart(); + if (first.Length > 0 && first[0] == '{') + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(raw); + var root = doc.RootElement; + if (root.ValueKind == System.Text.Json.JsonValueKind.Object) + { + if (root.TryGetProperty("Value", out var vEl) && vEl.ValueKind == System.Text.Json.JsonValueKind.String) + text = vEl.GetString() ?? string.Empty; + else if (root.TryGetProperty("Text", out var tEl) && tEl.ValueKind == System.Text.Json.JsonValueKind.String) + text = tEl.GetString() ?? string.Empty; + else if (root.TryGetProperty("Input", out var iEl) && iEl.ValueKind == System.Text.Json.JsonValueKind.String) + text = iEl.GetString() ?? string.Empty; + } + } + catch { text = raw; } + } + else + { + text = raw; + } + } + } + // StringSanitization.PurifyString_d__9 chains Core.Post to + // ApiCallback via ExpectPrimitiveResponse, so the watch's + // LitJson importer wants a BARE JSON string (e.g. "dwadwa"), not + // an object body. Returning {Text, IsClean} as we did before + // makes LitJson hand the Dictionary to Convert.ChangeType(_, typeof(string)) + // which throws "Object must implement IConvertible". Strip the + // envelope — but use Content() with explicit application/json so + // ASP.NET Core's StringOutputFormatter doesn't serialize as + // text/plain (raw string sans quotes), which the watch then sees + // as an empty cleanVersion after LitJson decode. + var purified = ProfanityFilter.Purify(text); + var json = System.Text.Json.JsonSerializer.Serialize(purified); + logger.LogInformation("[sanitize] input=\"{Input}\" purified=\"{Purified}\" json={Json}", text, purified, json); + return Content(json, "application/json"); + } + + public sealed class SanitizeBody + { + public string? Text { get; set; } + public string? Input { get; set; } + public string? Value { get; set; } + } + + /// POST api/sanitize/v1/purifyString — returns + /// {Text} with profanity replaced. Wire shape matches + /// StringSanitization.PurifyString. + [HttpPost("api/sanitize/v1/purifyString")] + public IActionResult PurifyString( + [FromBody] SanitizeBody? body, + [FromForm(Name = "Text")] string? textForm, + [FromForm(Name = "Input")] string? inputForm, + [FromForm(Name = "Value")] string? valueForm, + [FromQuery(Name = "text")] string? textQuery) + { + var input = body?.Text ?? body?.Input ?? body?.Value + ?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty; + return Ok(new { Text = ProfanityFilter.Purify(input) }); + } + + /// POST api/sanitize/v1/requestIsStringPure — + /// returns a bare JSON bool. Watch's ExpectPrimitiveResponse + /// rejects object bodies here. + [HttpPost("api/sanitize/v1/requestIsStringPure")] + public IActionResult IsStringPure( + [FromBody] SanitizeBody? body, + [FromForm(Name = "Text")] string? textForm, + [FromQuery(Name = "text")] string? textQuery) + { + var input = body?.Text ?? body?.Input ?? body?.Value + ?? textForm ?? textQuery ?? string.Empty; + return Ok(ProfanityFilter.IsClean(input)); + } +} diff --git a/DorkNet.Server/Controllers/API/Settings/V2/SettingsController.cs b/DorkNet.Server/Controllers/API/Settings/V2/SettingsController.cs new file mode 100644 index 0000000..69e1255 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Settings/V2/SettingsController.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using System.Text.Json.Serialization; + +namespace DorkNet.Server.Controllers.API.Settings.V2; + +/// +/// api.rec.net/api/settings/v2 — player settings download / set / remove. +/// URL patterns (verified by disassembling RecNet.Settings$$DowloadLocalPlayerSettings +/// at RVA 0xAF55B0 and RecNet.Settings$$StoreLocalPlayerSetting at RVA 0xAF5650): +/// +/// GET api/settings/v2/ — returns List<{Key, Value}> +/// POST api/settings/v2/set — body { Key, Value } (set or update one) +/// POST api/settings/v2/remove — body { Key } (delete one) +/// +/// The CLIENT side parses the GET response with Util.DeserializeList<Setting> +/// and reads each entry's "Key" and "Value" fields (verified by string literal +/// references in the deserializer body — both are PascalCase). +/// +/// Returning a Dictionary<string,string> here serializes as "{}" +/// when empty, which the client casts to a List and dies with +/// InvalidCastException: Unable to cast object of type 'Dictionary`2' to +/// type 'List`1'. Always return a real List. +/// +[ApiController] +[Route("api/[controller]/v2")] +[Authorize] +public class SettingsController(DorkNetDbContext db) : ControllerBase +{ + private long CurrentPlayerId => this.RequireCurrentPlayerId(); + + [HttpGet] + [HttpGet("get")] + public async Task GetSettings() + { + var settings = await db.PlayerSettings + .Where(s => s.PlayerId == CurrentPlayerId) + .Select(s => new SettingDto { Key = s.Key, Value = s.Value }) + .ToListAsync(); + return Ok(settings); + } + + [HttpPost("set")] + public async Task SetSetting([FromBody] SetSettingRequest req) + { + var existing = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == req.Key); + + if (existing is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = CurrentPlayerId, + Key = req.Key, + Value = req.Value, + }); + } + else + { + existing.Value = req.Value; + } + + await db.SaveChangesAsync(); + return Ok(new SettingDto { Key = req.Key, Value = req.Value }); + } + + [HttpPost("remove")] + public async Task RemoveSetting([FromBody] SetSettingRequest req) + { + var existing = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == req.Key); + if (existing is not null) + { + db.PlayerSettings.Remove(existing); + await db.SaveChangesAsync(); + } + return Ok(); + } +} + +public class SettingDto +{ + [JsonPropertyName("Key")] + public string Key { get; set; } = string.Empty; + + [JsonPropertyName("Value")] + public string Value { get; set; } = string.Empty; +} + +public class SetSettingRequest +{ + [JsonPropertyName("Key")] + public string Key { get; set; } = string.Empty; + + [JsonPropertyName("Value")] + public string Value { get; set; } = string.Empty; +} diff --git a/DorkNet.Server/Controllers/API/Store/PurchaseController.cs b/DorkNet.Server/Controllers/API/Store/PurchaseController.cs new file mode 100644 index 0000000..ba7356d --- /dev/null +++ b/DorkNet.Server/Controllers/API/Store/PurchaseController.cs @@ -0,0 +1,143 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Auth; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Store; + +/// +/// api.rec.net/api/purchase/v1/* — real purchase flow that verifies +/// the player has enough currency, deducts it, and grants the item +/// to their inventory. Replaces the old "ack-everything" stubs in +/// AllEndpointsController that let the watch think it bought +/// something but never actually persisted the transaction. +/// +/// The 2020 client follows a three-step flow when the player taps +/// "buy" on a Shop item: +/// 1. POST initiatepurchase — server validates the item exists + +/// player has funds, returns a transaction id (we just echo +/// back the item id; no real reservation needed for a private +/// server's threat model). +/// 2. POST processpurchase — server actually deducts currency, +/// grants the item to the avatar's InventoryJson list, returns +/// the new balance. +/// 3. POST completepurchase — final ack; client uses this to +/// flip its local UI state from "purchasing…" to "owned". +/// We keep all three so the watch's flow doesn't deadlock waiting +/// for one or another, but the actual mutation happens in +/// processpurchase. initiate and complete just validate + ack. +/// +[ApiController] +[Authorize] +public class PurchaseController(StoreService store, ILogger logger) : ControllerBase +{ + public sealed record PurchaseRequest(long? ItemId, string? Slug, int? Quantity); + + /// POST initiatepurchase — validate the item exists and + /// the caller has enough currency, but don't mutate yet. Returns + /// the resolved item id so the watch can echo it back on + /// processpurchase. + [HttpPost("api/purchase/v1/initiatepurchase")] + [HttpPost("api/purchase/v2/initiatepurchase")] + [HttpPost("api/purchase/v3/initiatepurchase")] + public async Task Initiate([FromForm] PurchaseRequest body) + { + var item = await ResolveItemAsync(body); + if (item is null) return Ok(new { success = false, error = "item_not_found" }); + return Ok(new + { + success = true, + error = "", + ItemId = item.Id, + Slug = item.Slug, + // Hand back a token the watch may include in + // processpurchase. We accept anything on the way in, so + // this is informational. + TransactionId = $"txn-{item.Id}-{DateTime.UtcNow.Ticks}", + }); + } + + /// POST processpurchase — atomic charge + grant. The + /// real work. Returns success+balance on completion or the + /// specific error code on failure (insufficient_funds, + /// item_not_available, etc.) so the watch can show the right UI + /// message. + [HttpPost("api/purchase/v1/processpurchase")] + [HttpPost("api/purchase/v2/processpurchase")] + [HttpPost("api/purchase/v3/processpurchase")] + public async Task Process([FromForm] PurchaseRequest body) + { + var pid = this.RequireCurrentPlayerId(); + var item = await ResolveItemAsync(body); + if (item is null) return Ok(new { success = false, error = "item_not_found" }); + + var result = await store.PurchaseAsync(pid, item.Id); + logger.LogInformation( + "[purchase] player={Pid} item={Item} success={Success} error={Error} balance={Balance}", + pid, item.Slug, result.Success, result.Error ?? "", result.Balance ?? 0); + return Ok(new + { + success = result.Success, + error = result.Error ?? "", + Balance = result.Balance ?? 0, + CurrencyType = item.CurrencyType, + ItemId = item.Id, + Slug = result.Slug ?? item.Slug, + }); + } + + /// POST completepurchase — final ack. Client flips its + /// UI to "owned" state and refreshes the avatar selector. + [HttpPost("api/purchase/v1/completepurchase")] + [HttpPost("api/purchase/v2/completepurchase")] + [HttpPost("api/purchase/v3/completepurchase")] + public IActionResult Complete() => + Ok(new { success = true, error = "" }); + + /// POST cancelpurchase — client retreated mid-flow + /// (closed the panel, network blip, etc.). Nothing to roll back + /// since we don't reserve in initiate. + [HttpPost("api/purchase/v1/cancelpurchase")] + [HttpPost("api/purchase/v2/cancelpurchase")] + [HttpPost("api/purchase/v3/cancelpurchase")] + public IActionResult Cancel() => + Ok(new { success = true, error = "" }); + + /// POST cleanuppending — client startup probe to release + /// any stale "in-flight" purchases from a crashed previous + /// session. We don't track in-flight state, so always-ack is + /// correct. + [HttpPost("api/purchase/v1/cleanuppending")] + [HttpPost("api/purchase/v2/cleanuppending")] + [HttpPost("api/purchase/v3/cleanuppending")] + public IActionResult CleanupPending() => + Ok(new { success = true, error = "" }); + + /// POST subscription/v1/cancel — Rec Room+ + /// subscription cancel button. Verified URL at + /// Commerce.txt:2188. The private server has no real billing + /// integration (no Stripe, no platform IAP receipts) so the + /// subscription is purely cosmetic — we ack the cancel so the + /// watch flips its UI to "canceled" and stops the recurring + /// renewal nag. If a real subscription entity ever lands, this + /// is where to expire its CurrentPeriodEnd. + [HttpPost("/subscription/v1")] + [HttpPost("/subscription/v1/cancel")] + [HttpPost("/api/subscription/v1")] + [HttpPost("/api/subscription/v1/cancel")] + public IActionResult CancelSubscription() + { + logger.LogInformation("[subscription] cancel requested by player {Pid}", + this.CurrentPlayerId() ?? 0); + return Ok(new { success = true, error = "" }); + } + + private async Task ResolveItemAsync(PurchaseRequest body) + { + if (body.ItemId is long id && id > 0) + return await store.GetByIdAsync(id); + if (!string.IsNullOrWhiteSpace(body.Slug)) + return await store.GetBySlugAsync(body.Slug); + return null; + } +} diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs new file mode 100644 index 0000000..454d8f1 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs @@ -0,0 +1,358 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Store; + +/// +/// api.rec.net/api/storefronts/v2/{buyItem,buyTier,buyElite} — the +/// secondary purchase URL family used by the watch's gift-drop +/// tiles and season-pass tier buttons. Wire request matches +/// RecNet.RequestPurchaseItemDTO: +/// StorefrontType, PurchasableItemId, CurrencyType, +/// CouponConsumablePlayerMappingId, Gift. +/// +/// Response is PurchaseBalanceUpdateResponseDTO: +/// BalanceUpdates: List<BalanceUpdateDTO>. The client +/// applies these balance deltas optimistically + reads the optional +/// gift package from inventory after. +/// +/// The primary purchase URL (api/purchase/v1/*) is handled +/// elsewhere by PurchaseController; this just covers the v2 surface. +/// +[ApiController] +[Authorize] +public class StorefrontsBuyController( + DorkNetDbContext db, + LevelService level, + NotificationService notifications) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + public sealed class RequestPurchaseItemDto + { + public int StorefrontType { get; set; } + public int PurchasableItemId { get; set; } + public int CurrencyType { get; set; } = 2; + public long? CouponConsumablePlayerMappingId { get; set; } + public GiftDto? Gift { get; set; } + } + + public sealed class GiftDto + { + public long ToPlayerId { get; set; } + public string? Message { get; set; } + public bool Anonymous { get; set; } + public int GiftContext { get; set; } + } + + [HttpPost("api/storefronts/v2/buyItem")] + public async Task BuyItem([FromBody] RequestPurchaseItemDto req) + { + if (req is null) return BadRequest("missing body"); + var pid = Me; + var item = await db.StoreItems.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive); + if (item is null) return NotFound(BalanceUpdateResponse(0, 2 /* CurrencyType */, null, null)); + + var balance = await level.GetBalanceAsync(pid, req.CurrencyType); + if (balance < item.Price) + { + // Insufficient funds — return current balance, no item granted. + return Ok(BalanceUpdateResponse(balance, req.CurrencyType, null, null)); + } + + // Gift block (when present) means the BUYER spends the tokens but + // the ITEM goes to Gift.ToPlayerId. Self-gifting is disallowed — + // the watch's gift UI prevents it, but guard server-side too. + var giftTarget = req.Gift is { ToPlayerId: > 0 } g && g.ToPlayerId != pid + ? g + : null; + var recipient = giftTarget?.ToPlayerId ?? pid; + var isGift = giftTarget is not null; + + var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -item.Price, + isGift ? $"giftItem:{item.Slug}->{recipient}" : $"buyItem:{item.Slug}"); + + // Grant the item to the RECIPIENT (= buyer for self-purchase, gift + // target for gift purchase). Avatar payloads append to + // AvatarEntity.InventoryJson; consumables and everything else + // insert a PlayerInventory row. + if (StoreService.TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc)) + { + await GrantWardrobeAsync(recipient, StoreService.InventoryAvatarItemDesc(avatarItemDesc)); + } + else + { + await GrantInventoryAsync(recipient, item.Slug); + } + + // Insert a real GiftPackageEntity for the gift-box flow. The watch + // shows a gift-box animation after a purchase, then POSTs the + // package Id to /api/avatar/v2/gifts/consume/ to claim it. If the + // Id isn't a real row, that consume returns 404 and the watch + // logs 'Server failed to consume gift … HTTP Error 404' — the + // user's symptom on every successful buy. By writing the row + // synchronously and returning its DB-assigned Id in the + // BalanceUpdate response, the subsequent consume succeeds. + StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out avatarItemDesc); + var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc); + StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc); + if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid)) + { + avatarItemType = 1; + inventoryAvatarDesc = hairDyeColorGuid; + consumableItemDesc = hairDyeConsumableDesc; + } + var gift = new GiftPackageEntity + { + RecipientPlayerId = recipient, + FromPlayerId = isGift && !giftTarget!.Anonymous ? (int?)pid : null, + AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType, + AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc, + ConsumableItemDesc = consumableItemDesc, + CurrencyType = req.CurrencyType, + Currency = 0, + Xp = 0, + Level = 1, + GiftContext = isGift ? giftTarget!.GiftContext : 0, + GiftRarity = 0, + Message = isGift ? (giftTarget!.Message ?? string.Empty) : string.Empty, + Platform = -1, + PackageVariant = "Standard", + PackageMaterial = string.Empty, + Consumed = false, + IsValid = true, + SupportsCurrentPlatform = true, + }; + db.GiftPackages.Add(gift); + await db.SaveChangesAsync(); + + // Push the buyer's balance update so their wallet UI refreshes. + await notifications.NotifyAsync(pid, + PushNotificationId.StorefrontBalancePurchase, + new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug }); + + if (isGift) + { + // Tell the recipient a gift is waiting. The watch dispatches + // GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts + // and pop the "you got a gift" toast; the gift box itself + // appears next time the recipient opens their inventory. + await notifications.NotifyAsync(recipient, + PushNotificationId.GiftPackageReceived, + new + { + Id = gift.Id, + Platform = -1, + Xp = 0, + Level = 1, + GiftRarity = 0, + GiftContext = gift.GiftContext, + FromPlayerId = gift.FromPlayerId, + AvatarItemType = gift.AvatarItemType, + AvatarItemDesc = avatarItemDesc, + ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty, + Message = gift.Message ?? string.Empty, + }); + } + + // For gift purchases the buyer doesn't get a box — their flow + // ends with the balance update. Returning an empty Data array + // (giftPackageId = null) makes the watch's BuyItem callback + // skip the gift-receive animation on the sender's screen. + return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType, + isGift ? null : item.Slug, + isGift ? null : gift.Id)); + } + + [HttpPost("api/storefronts/v2/buyTier")] + public async Task BuyTier([FromBody] RequestPurchaseItemDto req) + { + // Season-pass tier: no per-item table, so we just deduct a + // small fixed cost (1 token) per tier and ack. A full + // season-pass system would have a SeasonProgressEntity. + var pid = Me; + const int TierCost = (int)StoreService.StoreItemPrice; + var balance = await level.GetBalanceAsync(pid, req.CurrencyType); + if (balance < TierCost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType)); + var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -TierCost, + $"buyTier:{req.PurchasableItemId}"); + return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType)); + } + + [HttpPost("api/storefronts/v2/buyElite")] + public async Task BuyElite([FromBody] RequestPurchaseItemDto req) + { + // Elite-tier purchase (paid premium track). Without real + // money flow on a private server we just ack. + var pid = Me; + var balance = await level.GetBalanceAsync(pid, req.CurrencyType); + return Ok(BalanceUpdateResponse(balance, req.CurrencyType)); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private async Task GrantWardrobeAsync(long pid, string guid) + { + var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid); + if (avatar is null) + { + avatar = new AvatarEntity { PlayerId = pid }; + db.Avatars.Add(avatar); + } + try + { + var list = System.Text.Json.JsonSerializer + .Deserialize>(avatar.InventoryJson) ?? new(); + if (!list.Contains(guid)) + { + list.Add(guid); + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list); + avatar.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } + } + catch + { + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { guid }); + avatar.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } + } + + private async Task GrantInventoryAsync(long pid, string slug) + { + var row = await db.PlayerInventory + .FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug); + if (row is null) + { + db.PlayerInventory.Add(new PlayerInventoryEntity + { + PlayerId = pid, + ItemSlug = slug, + Quantity = 1, + }); + } + else + { + row.Quantity += 1; + } + await db.SaveChangesAsync(); + } + + private static object BalanceUpdateResponse(long balance, int currencyType, + string? grantedSlug = null, long? giftPackageId = null) + { + // PurchaseBalanceUpdateResponseDTO wire shape, verified + // against Cpp2IL_ISIL/.../RecNet/Avatars_NestedType_GiftPackage.txt: + // + // PurchaseBalanceUpdateResponseDTO EXTENDS BalanceResponseDTO, + // so the watch first runs BalanceResponseDTO.Deserialize which + // does Util.GetKey("Balance") / GetKey("CurrencyType") + // / GetKey("BalanceType") — REQUIRED, KeyNotFoundException + // on miss. Mirror the trio on the OUTER object. + // + // Then it reads BalanceUpdates: List>. + // Each inner BalanceUpdateDTO has UpdateResponse + Data, where + // Data is List. GiftPackage.Deserialize REQUIRES: + // "Id" (long) | Util.GetKey + // "Platform" (int) | Util.GetKey + // "Xp" (int) | Util.GetKey + // "Level" (int) | Util.GetKey + // "GiftRarity" (int) | Util.GetKey + // "GiftContext" (int) | Util.GetKey + // Plus optional FromPlayerId / AvatarItemType / AvatarItemDesc / + // ConsumableItemDesc / EquipmentPrefabName / EquipmentModificationGuid / + // CurrencyType / Currency / Message. + // + // The previous shape shipped {CurrencyType,Balance,BalanceType,Platform} + // in the Data array — those don't match GiftPackage's required keys, + // so Util.GetKey("Id") threw KeyNotFoundException, the + // ApiCallback received a non-null error string, and StorePurchaseItem + // Screen logged the error as "Null". The user's + // output_log.txt:920 shows that callback path firing on every + // purchase attempt. New shape ships a real (synthetic) GiftPackage + // with all required keys + the granted item's GUID under + // AvatarItemDesc so the watch's gift-receive flow can preview the + // unlocked item. + // + // CRITICAL: the watch follows up every purchase with a POST to + // /api/avatar/v2/gifts/consume/ containing this Id. If the Id + // isn't a real GiftPackageEntity row in the DB, consume returns + // 404 and the watch's logs 'Server failed to + // consume gift … HTTP Error 404', which the player-destroy + // cascade picks up on (the user's symptom on every buy: + // game crashes after opening the gift box). Caller MUST pass + // = the saved + // GiftPackageEntity.Id. The previous code used + // DateTime.UtcNow.Ticks as a fake id. + var giftId = giftPackageId ?? DateTime.UtcNow.Ticks; + // Pull the GUID off the slug for wardrobe-* purchases so the + // watch's GiftPackage receives the AvatarItem GUID and can + // render the unlock animation. dorknet-* slugs leave the desc + // empty (those are non-wardrobe SKUs). + var avatarItemType = 0; + var avatarItemDesc = string.Empty; + var consumableItemDesc = string.Empty; + if (grantedSlug is not null) + { + StoreService.TryGetAvatarItemPayload(grantedSlug, out avatarItemType, out avatarItemDesc); + StoreService.TryGetConsumableItemDesc(grantedSlug, out consumableItemDesc); + if (StoreService.TryGetHairDyePayload(grantedSlug, out var hairDyeConsumableDesc, out var hairDyeColorGuid)) + { + avatarItemType = 1; + avatarItemDesc = hairDyeColorGuid; + consumableItemDesc = hairDyeConsumableDesc; + } + } + + var giftPackage = new + { + // REQUIRED via Util.GetKey — KeyNotFoundException on miss. + Id = giftId, + Platform = 0, + Xp = 0, + Level = 1, + GiftRarity = 0, + GiftContext = 0, + // Optional — surfaced to the watch's gift-preview UI. + FromPlayerId = (int?)null, + AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType, + AvatarItemDesc = avatarItemDesc, + AvatarItemDescOrHairDyeDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc), + ConsumableItemDesc = consumableItemDesc, + EquipmentPrefabName = string.Empty, + EquipmentModificationGuid = string.Empty, + CurrencyType = currencyType, + Currency = 0, + Message = string.Empty, + Consumed = false, + ErrorMessage = string.Empty, + }; + + return new + { + // BalanceResponseDTO required trio. + Balance = balance, + CurrencyType = currencyType, + BalanceType = 0, + // PurchaseBalanceUpdateResponseDTO.BalanceUpdates = + // List>. + BalanceUpdates = new[] + { + new + { + UpdateResponse = 0, // UpdateResponseTypes.Success + Data = grantedSlug is null + ? Array.Empty() + : new object[] { giftPackage }, + }, + }, + }; + } +} diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs new file mode 100644 index 0000000..8ed2d4d --- /dev/null +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs @@ -0,0 +1,498 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using System.Text.Json; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Store; + +/// +/// api.rec.net/api/storefronts/v* — real implementation backed by the +/// catalog (StoreItems table). Replaces the +/// previous AllEndpointsController stubs that returned empty lists, +/// which made the watch's Shop tab look broken. +/// +/// Wire shape: every endpoint returns either a list of items (for +/// catalog endpoints) or a StorefrontDTO with a StoreItems array. +/// +[ApiController] +public class StorefrontsController( + StoreService store, + DorkNetDbContext db, + IConfiguration config, + LevelService level, + DomainConfig domain) : ControllerBase +{ + /// GET api/storefronts/v3/all — full active catalog. The + /// watch's Shop tab uses this to populate every category. + [HttpGet("api/storefronts/v1/all")] + [HttpGet("api/storefronts/v2/all")] + [HttpGet("api/storefronts/v3/all")] + [AllowAnonymous] + public async Task All() + { + var items = await store.GetAllActiveAsync(); + return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray()); + } + + /// GET api/storefronts/v3/skus — alias for the full + /// catalog. Some 2020 client paths call this instead of /all + /// during the watch's "browse all SKUs" flow. + [HttpGet("api/storefronts/v3/skus")] + [AllowAnonymous] + public async Task Skus() + { + var items = await store.GetAllActiveAsync(); + return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray()); + } + + /// GET api/storefronts/v3/season — items currently in the + /// season-pass storefront. Empty for now since we don't run + /// seasons; returning an empty list rather than the catch-all so + /// the deserialiser sees the right shape. + [HttpGet("api/storefronts/v3/season")] + [AllowAnonymous] + public async Task Season() + { + var items = await store.GetActiveByStorefrontAsync("season:1"); + return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray()); + } + + /// GET api/storefronts/v3/giftdrop — list of available + /// gift-drop storefront ids. The watch then queries each one via + /// /giftdropstore/{id} below to render the rotating shelf. + [HttpGet("api/storefronts/v3/giftdrop")] + [AllowAnonymous] + public IActionResult GiftDropList() => + Ok(StoreService.GetStorefrontDefinitions() + .Where(s => s.StorefrontType is > 0) + .Select(s => s.StorefrontType!.Value) + .Distinct() + .OrderBy(i => i) + .ToArray()); + + /// GET api/storefronts/v3/giftdropstore/{id} — one + /// storefront shelf. The {id} is a value from the + /// RecNet.StorefrontTypes enum (verified against + /// Cpp2IL_CS/.../RecNet/StorefrontTypes.cs): + /// None=0, LaserTag=1, RecCenter=2, Watch=3, + /// Quest_*=100..103, RecRoyale=200, Cafe=300, Paintball=400..406, + /// Bowling=500, StuntRunner=600, DormMirror=700. + /// The Watch storefront (id=3) is the in-game Shop's Featured / + /// Clothing / Other grid. That grid assumes every gift drop has a + /// renderable AvatarItemDesc, so only wardrobe-backed rows are + /// surfaced there. Other storefronts use the canonical + /// giftdrop:{id} key plus the shared rro and + /// all admin keys. + /// + [HttpGet("api/storefronts/v3/giftdropstore/{id:int}")] + [AllowAnonymous] + public async Task GiftDropStore(int id) + { + var (key, items) = id switch + { + 3 => ($"watch", await store.GetRenderableGiftDropItemsAsync()), + _ => (StoreService.StorefrontKeyForType(id), await store.GetRoomStorefrontItemsAsync(id)), + }; + return Ok(StoreService.ToStorefrontDto(key, items, domain.Apex, id)); + } + + /// GET api/storefronts/v1/balance/{type} — currency + /// balance for the caller. Wire shape is a list of + /// {CurrencyType, Balance, BalanceType, Platform} entries. The + /// watch displays the Balance value next to item prices. + [HttpGet("api/storefronts/v1/balance/{currencyType:int}")] + [HttpGet("api/storefronts/v2/balance/{currencyType:int}")] + [HttpGet("api/storefronts/v3/balance/{currencyType:int}")] + [HttpGet("api/storefronts/v4/balance/{currencyType:int}")] + [HttpGet("api/storefronts/v4/balance")] + [Authorize] + public async Task Balance(int currencyType = 2, + [FromServices] LevelService level = null!) + { + var pid = this.RequireCurrentPlayerId(); + var balance = await level.GetBalanceAsync(pid, currencyType); + return Ok(new[] + { + new + { + CurrencyType = currencyType, + BalanceType = 0, + Balance = balance, + Platform = 0, + }, + }); + } + + /// GET api/storefronts/v{1-4}/balanceAddType/{type}/{tier} + /// — purchase-tier metadata for a currency. The watch uses this + /// to populate the "add tokens" tier list. Empty for a private + /// server with no IAP. + [HttpGet("api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int}")] + [HttpGet("api/storefronts/v2/balanceAddType/{currencyType:int}/{tierId:int}")] + [HttpGet("api/storefronts/v3/balanceAddType/{currencyType:int}/{tierId:int}")] + [HttpGet("api/storefronts/v4/balanceAddType/{currencyType:int}/{tierId:int}")] + public IActionResult BalanceAddType(int currencyType, int tierId) + => Ok(BalanceAddConfig(currencyType, tierId)); + + /// GET api/storefronts/v2/balance — every currency + /// balance for the caller in a single response. The watch's + /// wallet UI uses this to render all four currency chips at once. + /// CurrencyType per CurrencyType.cs: 0=Token, 1=Cheer, + /// 2=Coin, 3=Premium. + [HttpGet("api/storefronts/v2/balance")] + [Authorize] + public async Task AllBalances() + { + var pid = this.RequireCurrentPlayerId(); + var coin = await level.GetBalanceAsync(pid, 2); + var token = await level.GetBalanceAsync(pid, 0); + return Ok(new[] + { + new { CurrencyType = 2, BalanceType = 0, Balance = coin, Platform = 0 }, + new { CurrencyType = 0, BalanceType = 0, Balance = token, Platform = 0 }, + new { CurrencyType = 1, BalanceType = 0, Balance = 0L, Platform = 0 }, + new { CurrencyType = 3, BalanceType = 0, Balance = 0L, Platform = 0 }, + }); + } + + /// GET api/storefronts/v1/current — single active + /// storefront the watch's "today's deals" panel renders. Returns + /// the main storefront's items as a single PurchasableItem + /// list (bounded to keep older watches snappy). + [HttpGet("api/storefronts/v1/current")] + public async Task Current() + { + if (!config.GetValue("Store:EnableWatchGiftDrops", true)) + return Ok(StoreService.ToStorefrontDto("Main", Array.Empty(), domain.Apex)); + + IQueryable query = db.StoreItems + .Where(s => s.IsActive && s.Storefront == "main") + .OrderByDescending(s => s.UpdatedAt); + var max = config.GetValue("Store:MaxCurrentStorefrontItems", 96); + if (max > 0) query = query.Take(max); + var rows = await query.ToListAsync(); + return Ok(StoreService.ToStorefrontDto("Main", rows, domain.Apex)); + } + + /// GET api/storefronts/v1/season/{seasonId} — + /// season-pass tier list, sourced from StoreItems tagged + /// Storefront = "season:{id}". + [HttpGet("api/storefronts/v1/season/{seasonId:int}")] + public async Task Season(int seasonId) + { + var key = $"season:{seasonId}"; + var tiers = await db.StoreItems + .Where(s => s.IsActive && s.Storefront == key) + .OrderBy(s => s.Price) + .Select(s => new + { + TierId = s.Id, + s.DisplayName, + s.Description, + s.Price, + CurrencyType = s.CurrencyType, + s.ImageName, + }) + .ToListAsync(); + return Ok(new + { + SeasonId = seasonId, + SeasonType = seasonId, + Active = tiers.Count > 0, + StartsAt = DateTime.UtcNow.AddDays(-30), + EndsAt = DateTime.UtcNow.AddDays(30), + Tiers = tiers, + }); + } + + /// GET api/storefronts/v1/objectives — season + /// objective progress for the caller, keyed + /// season:{n}. + [HttpGet("api/storefronts/v1/objectives")] + public async Task Objectives() + { + var pid = this.CurrentPlayerId(); + if (pid is not long me) return Ok(Array.Empty()); + var rows = await db.ObjectiveProgress + .Where(o => o.PlayerId == me && o.Key.StartsWith("season:")) + .Select(o => new { Key = o.Key, IsCompleted = o.IsCompleted, ClearedAt = o.ClearedAt }) + .ToListAsync(); + return Ok(rows); + } + + /// POST api/storefronts/v1/objectives — + /// Storefronts.CompleteObjectives uploads completion + /// records and expects a BalanceUpdateResponseDTO back. + /// We ship the wrapper with the required Balance/CurrencyType/ + /// BalanceType trio so BalanceResponseDTO.Deserialize + /// doesn't throw, plus an empty BalanceUpdates. + [HttpPost("api/storefronts/v1/objectives")] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] + [Authorize] + public async Task CompleteObjectives() + { + var pid = this.RequireCurrentPlayerId(); + var records = await ReadStorefrontObjectiveRecordsAsync(); + var total = 0; + foreach (var record in records ?? new()) + { + if (record.CompletionPercentage <= 0f) continue; + var rewardKey = $"storefront-objective:{record.ObjectiveType}:{DateTime.UtcNow:yyyyMMdd}"; + if (await MarkRewardOnceAsync(pid, rewardKey)) + { + var award = record.ObjectiveType switch + { + 10 or 11 or 12 => 25, + 13 => 150, + 14 => 50, + 15 => 75, + 4000 or 4001 => 50, + _ => Math.Max(5, (int)Math.Round(25 * Math.Clamp(record.CompletionPercentage, 0f, 1f))), + }; + total += award; + } + } + + if (total > 0) + { + await level.AwardXpAsync(pid, total, "storefront-objectives"); + await level.GrantCurrencyAsync(pid, 2, total, "storefront-objectives"); + } + var balance = await level.GetBalanceAsync(pid, 2); + return Ok(BalanceUpdateResponse(balance, 2, new[] + { + RewardModification(100, total, total, currentCount: total > 0 ? 1 : 0), + })); + } + + public sealed class StorefrontObjectiveCompletionRecord + { + public int ObjectiveType { get; set; } + public float CompletionPercentage { get; set; } + public long? RoomId { get; set; } + } + + public sealed class GrantBalanceRewardDto + { + public int CurrencyType { get; set; } = 2; + public List BalanceAdds { get; set; } = new(); + } + + public sealed class GrantBalanceRequest + { + public float Multiplier { get; set; } = 1f; + public int BalanceAddType { get; set; } = 1; + } + + [HttpPost("api/storefronts/v2/balance")] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] + [Authorize] + public async Task ModifyBalance() + { + var body = await ReadGrantBalanceRewardAsync(); + var pid = this.RequireCurrentPlayerId(); + var currencyType = body.CurrencyType == 0 ? 2 : body.CurrencyType; + var modifications = new List(); + var total = 0; + + foreach (var add in body.BalanceAdds ?? new()) + { + var config = BalanceAddConfigValues(currencyType, add.BalanceAddType); + var multiplier = Math.Clamp(add.Multiplier <= 0 ? 1f : add.Multiplier, 0f, config.MaxPartialMultiplier); + var baseAward = config.IgnorePartialMultiplier + ? config.BaseAward + : (int)Math.Round(config.BaseAward * multiplier); + baseAward = Math.Max(0, baseAward); + if (baseAward == 0) continue; + total += baseAward; + modifications.Add(RewardModification(add.BalanceAddType, baseAward, baseAward, currentCount: 1)); + } + + if (total > 0) + { + await level.GrantCurrencyAsync(pid, currencyType, total, "storefront-balance-reward"); + await level.AwardXpAsync(pid, Math.Max(10, total / 2), "storefront-balance-reward"); + } + + var balance = await level.GetBalanceAsync(pid, currencyType); + return Ok(BalanceUpdateResponse(balance, currencyType, modifications)); + } + + public sealed class BalanceAddTypeRequest { public long Amount { get; set; } } + + /// POST api/storefronts/v1/balanceAddType/{type}/{playerId} + /// — admin-only currency credit. Grants via + /// . + [HttpPost("api/storefronts/v1/balanceAddType/{type:int}/{playerId:long}")] + [Authorize] + public async Task BalanceAddTypeAdmin(int type, long playerId, [FromBody] BalanceAddTypeRequest? body) + { + var me = this.RequireCurrentPlayerId(); + var isAdmin = await db.Players.Where(p => p.Id == me).Select(p => p.IsAdmin).FirstOrDefaultAsync(); + if (!isAdmin) return Forbid(); + + var amount = body?.Amount ?? 0L; + if (amount == 0) return Ok(new { Added = 0L }); + var newBalance = await level.GrantCurrencyAsync(playerId, type, amount, $"balanceAddType:{me}"); + return Ok(new + { + CurrencyType = type, + Balance = newBalance, + BalanceType = 0, + Platform = 0, + }); + } + + private static object BalanceAddConfig(int currencyType, int balanceAddType) + { + var cfg = BalanceAddConfigValues(currencyType, balanceAddType); + return new + { + CurrencyType = currencyType, + BalanceAddType = balanceAddType, + cfg.BaseAward, + BonusAwardMin = 0, + BonusAwardMax = 0, + RateLimitType = 0, + IgnorePartialMultiplier = cfg.IgnorePartialMultiplier, + MaxPartialMultiplier = (int)Math.Ceiling(cfg.MaxPartialMultiplier), + RateLimit = 0, + BalanceInGiftBox = false, + }; + } + + private static (int BaseAward, float MaxPartialMultiplier, bool IgnorePartialMultiplier) + BalanceAddConfigValues(int currencyType, int balanceAddType) => balanceAddType switch + { + 1 => (100, 10f, false), // DirectBalanceWithMultiplier: Stunt Runner / banked loot. + 10 => (25, 1f, true), // NUXChallenge. + 11 => (100, 1f, true), // AllNUXChallenges. + 100 => (25, 1f, true), // DailyChallenge. + 101 => (150, 1f, true), // AllDailyChallenges. + 200 => (50, 1f, true), // FinishActivity. + 1000 => (100, 1f, true), // WonGame. + 1001 => (25, 1f, true), // LostGame. + _ => (25, 1f, true), + }; + + private async Task ReadGrantBalanceRewardAsync() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + var dto = new GrantBalanceRewardDto + { + CurrencyType = int.TryParse(form["CurrencyType"], out var ct) ? ct : 2, + }; + if (int.TryParse(form["BalanceAddType"], out var bat) || int.TryParse(form["balanceAddType"], out bat)) + { + dto.BalanceAdds.Add(new GrantBalanceRequest + { + BalanceAddType = bat, + Multiplier = float.TryParse(form["Multiplier"], out var mult) ? mult : 1f, + }); + } + return dto; + } + + try + { + var dto = await JsonSerializer.DeserializeAsync( + Request.Body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return dto ?? new GrantBalanceRewardDto(); + } + catch + { + return new GrantBalanceRewardDto(); + } + } + + private async Task> ReadStorefrontObjectiveRecordsAsync() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + if (!int.TryParse(form["objectiveType"], out var objectiveType) + && !int.TryParse(form["ObjectiveType"], out objectiveType)) + { + return new(); + } + return new() + { + new StorefrontObjectiveCompletionRecord + { + ObjectiveType = objectiveType, + CompletionPercentage = float.TryParse(form["completionPercentage"], out var pct) + || float.TryParse(form["CompletionPercentage"], out pct) + ? pct + : 1f, + RoomId = long.TryParse(form["roomId"], out var roomId) + || long.TryParse(form["RoomId"], out roomId) + ? roomId + : null, + }, + }; + } + + try + { + var rows = await JsonSerializer.DeserializeAsync>( + Request.Body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return rows ?? new(); + } + catch + { + return new(); + } + } + + private async Task MarkRewardOnceAsync(long playerId, string rewardKey) + { + var key = $"reward:{rewardKey}"; + if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == playerId && o.Key == key && o.IsCompleted)) + return false; + db.ObjectiveProgress.Add(new ObjectiveProgressEntity + { + PlayerId = playerId, + Key = key, + IsCompleted = true, + ClearedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + return true; + } + + private static object RewardModification(int balanceAddType, int baseAward, int total, int currentCount) => new + { + BalanceAddType = balanceAddType, + BaseAward = baseAward, + BonusAward = 0, + RateLimit = 0, + CurrentCount = currentCount, + Total = total, + BalanceType = 0, + BalanceInGiftBox = false, + }; + + private static object BalanceUpdateResponse(long balance, int currencyType, IEnumerable modifications) => new + { + Balance = balance, + CurrencyType = currencyType, + BalanceType = 0, + BalanceUpdates = new[] + { + new + { + UpdateResponse = 0, + Data = modifications.Where(m => m is not null).ToArray(), + }, + }, + }; +} diff --git a/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseManagementController.cs b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseManagementController.cs new file mode 100644 index 0000000..c33a086 --- /dev/null +++ b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseManagementController.cs @@ -0,0 +1,195 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Controllers.API.TestCaseManagement; + +/// +/// api.rec.net/api/testcasemanagement/* — internal QA tooling. URL +/// surface verified against +/// Cpp2IL_ISIL/.../RecNet/TestCaseManagement.txt: +/// +/// GET api/testcasemanagement/v1/testpasssummary → List<TestPass> +/// GET api/testcasemanagement/v1/testpass/{id} → TestPass +/// GET api/testcasemanagement/v1/testcase/{id} → TestCase +/// POST api/testcasemanagement/v1/testcase/{id}/claim +/// POST api/testcasemanagement/v1/testcase/{id}/unclaim +/// POST api/testcasemanagement/v1/testcase/{id}/status +/// +/// Wire DTOs match RecNet.TestCase + RecNet.TestPass +/// exactly: +/// +/// • TestCase: Id(string), Key, Title, Description, RoomName, +/// Status(TestCaseStatus enum 0=NotYetTested/1=Claimed/2=Failed/3=Passed), +/// MinNumAssignedPlayers, AssignedPlayerIds(List<int>), +/// AssignedPlayerNames, Tags, JiraUrl, JiraBugUrl. +/// • TestPass: Id(uint), Name, Description, StartDate, EndDate?, +/// WasManuallyClosed, TestCases(List<TestCase>), Tags, +/// NumTestCases, NumPassedTestCases, NumFailedTestCases. +/// +/// Routes are reachable by anyone (the watch only renders this tab +/// when the player has the developer role; client-side gate). The +/// claim/status mutations no-op if the caller isn't authenticated. +/// +[ApiController] +public class TestCaseManagementController(DorkNetDbContext db) : ControllerBase +{ + [HttpGet("api/testcasemanagement/v1/testpasssummary")] + public async Task TestPassSummaries() + { + var passes = await db.TestPasses.ToListAsync(); + var caseCountsByPass = await db.TestCases + .Where(c => c.TestPassId != null) + .GroupBy(c => c.TestPassId!.Value) + .Select(g => new PassCounts(g.Key, g.Count(), + g.Count(c => c.Status == 3), g.Count(c => c.Status == 2))) + .ToDictionaryAsync(x => x.PassId); + return Ok(passes.Select(p => ToWirePass(p, new List(), caseCountsByPass))); + } + + [HttpGet("api/testcasemanagement/v1/testpass/{id:long}")] + public async Task TestPass(long id) + { + if (id < 0 || id > uint.MaxValue) return NotFound(); + var pid = (uint)id; + var pass = await db.TestPasses.FirstOrDefaultAsync(p => p.Id == pid); + if (pass is null) return NotFound(); + var cases = await db.TestCases.Where(c => c.TestPassId == pid).ToListAsync(); + var counts = new Dictionary + { + [pid] = new(pid, cases.Count, cases.Count(c => c.Status == 3), cases.Count(c => c.Status == 2)), + }; + return Ok(ToWirePass(pass, cases, counts)); + } + + private record PassCounts(uint PassId, int Total, int Passed, int Failed); + + [HttpGet("api/testcasemanagement/v1/testcase/{id}")] + public async Task TestCase(string id) + { + var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id); + if (tc is null) return NotFound(); + return Ok(ToWireCase(tc)); + } + + [HttpPost("api/testcasemanagement/v1/testcase/{id}/claim")] + [Authorize] + public async Task Claim(string id) + { + var pid = this.RequireCurrentPlayerId(); + var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id); + if (tc is null) return NotFound(); + + var player = await db.Players.Where(p => p.Id == pid).Select(p => p.Username).FirstOrDefaultAsync(); + var ids = ParseIntList(tc.AssignedPlayerIdsJson); + var names = ParseStringList(tc.AssignedPlayerNamesJson); + if (!ids.Contains((int)pid)) + { + ids.Add((int)pid); + names.Add(player ?? $"Player {pid}"); + tc.AssignedPlayerIdsJson = JsonSerializer.Serialize(ids); + tc.AssignedPlayerNamesJson = JsonSerializer.Serialize(names); + } + tc.Status = 1; // Claimed + tc.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWireCase(tc)); + } + + [HttpPost("api/testcasemanagement/v1/testcase/{id}/unclaim")] + [Authorize] + public async Task Unclaim(string id) + { + var pid = this.RequireCurrentPlayerId(); + var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id); + if (tc is null) return NotFound(); + var ids = ParseIntList(tc.AssignedPlayerIdsJson); + var names = ParseStringList(tc.AssignedPlayerNamesJson); + var idx = ids.IndexOf((int)pid); + if (idx >= 0) + { + ids.RemoveAt(idx); + if (idx < names.Count) names.RemoveAt(idx); + tc.AssignedPlayerIdsJson = JsonSerializer.Serialize(ids); + tc.AssignedPlayerNamesJson = JsonSerializer.Serialize(names); + } + if (ids.Count == 0 && tc.Status == 1) tc.Status = 0; // back to NotYetTested + tc.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWireCase(tc)); + } + + public sealed class StatusUpdateRequest + { + public int NewStatus { get; set; } + } + + [HttpPost("api/testcasemanagement/v1/testcase/{id}/status")] + [Authorize] + public async Task UpdateStatus(string id, [FromBody] StatusUpdateRequest req) + { + var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id); + if (tc is null) return NotFound(); + if (req.NewStatus is < 0 or > 3) return BadRequest("invalid status"); + tc.Status = req.NewStatus; + tc.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ToWireCase(tc)); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private static List ParseIntList(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(); + try { return JsonSerializer.Deserialize>(json) ?? new(); } + catch { return new(); } + } + + private static List ParseStringList(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(); + try { return JsonSerializer.Deserialize>(json) ?? new(); } + catch { return new(); } + } + + private static object ToWireCase(TestCaseEntity c) => new + { + c.Id, + c.Key, + c.Title, + c.Description, + c.RoomName, + c.Status, + c.MinNumAssignedPlayers, + AssignedPlayerIds = ParseIntList(c.AssignedPlayerIdsJson), + AssignedPlayerNames = ParseStringList(c.AssignedPlayerNamesJson), + Tags = ParseStringList(c.TagsJson), + c.JiraUrl, + c.JiraBugUrl, + }; + + private static object ToWirePass(TestPassEntity p, List cases, + IDictionary counts) + { + var c = counts.TryGetValue(p.Id, out var v) ? v : new PassCounts(p.Id, 0, 0, 0); + return new + { + p.Id, + p.Name, + p.Description, + p.StartDate, + p.EndDate, + p.WasManuallyClosed, + TestCases = cases.Select(ToWireCase), + Tags = ParseStringList(p.TagsJson), + NumTestCases = c.Total, + NumPassedTestCases = c.Passed, + NumFailedTestCases = c.Failed, + }; + } +} diff --git a/DorkNet.Server/Controllers/API/Version/VersionController.cs b/DorkNet.Server/Controllers/API/Version/VersionController.cs new file mode 100644 index 0000000..132c4c7 --- /dev/null +++ b/DorkNet.Server/Controllers/API/Version/VersionController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DorkNet.Server.Controllers.API.Version; + +[ApiController] +public class VersionController : ControllerBase +{ + // VersionStatus enum: ValidForPlay=0, ValidForMenu=1, UpdateRequired=2 + private static readonly object ValidForPlay = new { VersionStatus = 0 }; + + [HttpGet("api/version/v1")] + [HttpGet("api/version")] + public ActionResult GetV1() => Ok(ValidForPlay); + + // Actual path the 2020 build uses + [HttpGet("api/versioncheck/v4")] + [HttpGet("api/versioncheck/v3")] + [HttpGet("api/versioncheck/v2")] + [HttpGet("api/versioncheck/v1")] + [HttpGet("api/versioncheck")] + public ActionResult GetCheck() => Ok(ValidForPlay); +} diff --git a/DorkNet.Server/Controllers/Accounts/AccountsController.cs b/DorkNet.Server/Controllers/Accounts/AccountsController.cs new file mode 100644 index 0000000..8c8369a --- /dev/null +++ b/DorkNet.Server/Controllers/Accounts/AccountsController.cs @@ -0,0 +1,573 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Auth; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Accounts; + +/// +/// accounts.rec.net — account management. +/// Shapes match dump.cs: +/// RecNet.Account { AccountId, RawUsername, Username, DisplayName, +/// ProfileImage, TreatAsJunior, HasBirthday, Platforms } +/// RecNet.SelfAccount Account + { Email, Phone, Birthday, JuniorState, +/// ParentAccountId } +/// RecNet.RecNetResult { Success, Error } +/// Accounts.CreateAccountResponse RecNetResult + { Account } +/// +[ApiController] +public class AccountsController( + PlayerService playerService, + AuthService authService, + OrphanAccountTracker orphans, + DorkNetDbContext db, + NotificationService notifications, + ServerSettingsService settings, + ILogger logger) : ControllerBase +{ + [HttpGet("/account/v1/savedlogins")] + [HttpGet("/account/v2/savedlogins")] + [HttpGet("/account/v1/cachedlogins")] + public async Task GetSavedLogins( + [FromQuery] int platform = 0, + [FromQuery] string? platformId = null) + { + var players = await playerService.GetCachedLoginsAsync(platform, platformId); + return Ok(players.Select(p => new CachedLogin + { + Platform = platform, + PlatformId = platformId ?? string.Empty, + AccountId = (int)p.Id, + LastLoginTime = p.LastSeenAt, + RequirePassword = false, + }).ToList()); + } + + // Accounts.GetLocalAccount → SelfAccount shape. + // The 2020 client calls the unversioned /account/me path (observed in + // server logs); v1/v2 forms are kept for older builds and api.rec.net + // bridging. Returns SelfAccount, NOT base Account (this is the *self* + // endpoint — RecNet.Login.<>c.b__35_4 expects a SelfAccount). + [HttpGet("/account/me")] + [HttpGet("/account/v1/me")] + [HttpGet("/account/v2/me")] + [HttpGet("/account/v1/account")] + public async Task GetMe() + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); + } + + /// + /// Resolves the player tied to the bearer token on the request. Returns + /// null when no/invalid bearer is supplied — the caller decides whether + /// that is a 401 (every "self" endpoint should be) or a noisier + /// fallback. Anonymous-account creation was previously the fallback + /// path; that's gone now because every caller has gone through + /// `/account/create` (deviceId-bound) before reaching us. + /// + private async Task GetCurrentPlayerAsync() + { + var auth = Request.Headers.Authorization.ToString(); + if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; + var token = auth["Bearer ".Length..].Trim(); + var id = authService.ValidateToken(token); + if (id is not long playerId) return null; + return await playerService.GetByIdAsync(playerId); + } + + // Accounts.GetAccountById → Account shape. The 2020 watch posts to + // the BARE /account/{id} path (no v1 prefix) when + // resolving a room's creator from RoomDetails — verified in the + // output_log "Failed to GetAccountById: HTTP Error 404" trace where + // PlayerDetailsWatchUIFlow.SetAccount NREs because the GetAccountById + // promise rejected with 404. Accept both shapes. + [HttpGet("/account/{accountId:long}")] + [HttpGet("/account/v1/{accountId:long}")] + [HttpGet("/account/v1/account/{accountId:long}")] + public async Task GetAccountById(long accountId) + { + var player = await playerService.GetByIdAsync(accountId); + if (player is null) return NotFound(); + return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); + } + + // Accounts.GetAccountByUsername + [HttpGet("/account/v1/username/{username}")] + public async Task GetAccountByUsername(string username) + { + var player = await playerService.GetByUsernameAsync(username); + if (player is null) return NotFound(); + return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); + } + + // Accounts.CreateAccount — actual URL observed in server logs: + // POST accounts.rec.net/account/create + // body: platform=0&platformId=&deviceId= + // Game expects CreateAccountResponse {Success, Error, Account}. Lambda + // b__29_0 NPEs if response.Account is null after deserializing. + // Clean PascalCase here — multi-case keys appear to confuse LitJson's typed + // path so its TypeMap.Add throws "Key: none" or leaves Account unpopulated. + [HttpPost("/account/create")] + [HttpPost("/account/v1/create")] + [HttpPost("/account/v1")] + [HttpPost("/account/v2/create")] + public async Task CreateAccount( + [FromForm] int platform, + [FromForm] string? platformId, + [FromForm] string? deviceId) + { + // Admin kill-switch. Watch's b__29_0 lambda + // (Accounts_NestedType___c.txt:311) branches on Success: false + // → ErroredPromise with the Error string surfaced to + // the player; true → reads response.Account. Account stays a + // non-null stub even on refusal so LitJson's typed Deserialize + // path doesn't fault before the Success check runs. + if (await settings.AreSignupsDisabledAsync()) + { + logger.LogInformation( + "[accounts] account/create refused — signups disabled (device={Device} platform={Platform} platformId={PlatformId})", + deviceId, platform, platformId); + return Ok(new CreateAccountResponse + { + Success = false, + Error = "Account creation is currently disabled by the server admin.", + }); + } + + // The 2020 watch's "Create new account" UI and its boot-time + // "ensure my account exists" probe BOTH hit this endpoint with + // identical request bodies. To support multi-account-per-device + // we always create a brand-new row; the boot-flow case (where + // the user actually wants their cached account) is handled by + // the OrphanAccountTracker — when the next cached_login picks + // a DIFFERENT account_id, that handler deletes this just- + // created row. + var player = await playerService.CreateNewAccountAsync( + deviceId: deviceId, + platform: platform, + platformId: platformId); + + orphans.TrackCreation(deviceId, platformId, player.Id); + + logger.LogInformation( + "[accounts] account/create created {Id} ({Username}) for device {Device} platform={Platform} platformId={PlatformId}", + player.Id, player.Username, deviceId, platform, platformId); + + return Ok(new CreateAccountResponse + { + Success = true, + Error = string.Empty, + Account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName), + }); + } + + // Accounts.SearchForAccounts — used by the watch's typeahead AND the + // localhost public site's account search. Anonymous-safe (no JWT + // required) so the front-facing site can call it directly. + [HttpGet("/account/v1/search")] + [Microsoft.AspNetCore.Authorization.AllowAnonymous] + public async Task Search([FromQuery] string? query, [FromQuery] int take = 20) + { + if (string.IsNullOrWhiteSpace(query)) return Ok(Array.Empty()); + var clamped = Math.Clamp(take, 1, 50); + var matches = await playerService.SearchAsync(query.Trim(), clamped); + return Ok(matches + .Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName)) + .ToList()); + } + + // Accounts.GetAccountsBulk / GetAccountsFromPlatformIds / FromEmails / FromPhones + // Form body: Ids=1,2,3 (BestHTTP HTTPFormBase.AddField). Accept both + // GET and POST and any content type so route matching succeeds even + // when the client sends multipart vs urlencoded. + [HttpPost("/account/v1/bulk")] + [HttpGet("/account/v1/bulk")] + [HttpPost("/account/bulk")] + [HttpGet("/account/bulk")] + [HttpPost("/account/v1/platformids")] + [HttpPost("/account/v1/emails")] + [HttpPost("/account/v1/phones")] + [HttpPost("/account/v1/byplatformid")] + public async Task Bulk() + { + // The watch calls this route family with payloads keyed by + // either Ids (account ids), Emails (email addresses), or + // Phones (E.164 strings) depending on which Accounts.GetXxx + // helper fired it. Read all three plus the lowercase / singular + // variants the cached-account hydration uses. + string? rawIds = null; + string? rawEmails = null; + string? rawPhones = null; + try + { + if (Request.HasFormContentType) + { + rawIds = NonEmptyOrNull(Request.Form["Ids"].ToString()) + ?? NonEmptyOrNull(Request.Form["ids"].ToString()) + ?? NonEmptyOrNull(Request.Form["Id"].ToString()) + ?? NonEmptyOrNull(Request.Form["id"].ToString()); + rawEmails = NonEmptyOrNull(Request.Form["Emails"].ToString()) + ?? NonEmptyOrNull(Request.Form["emails"].ToString()) + ?? NonEmptyOrNull(Request.Form["Email"].ToString()) + ?? NonEmptyOrNull(Request.Form["email"].ToString()); + rawPhones = NonEmptyOrNull(Request.Form["Phones"].ToString()) + ?? NonEmptyOrNull(Request.Form["phones"].ToString()) + ?? NonEmptyOrNull(Request.Form["Phone"].ToString()) + ?? NonEmptyOrNull(Request.Form["phone"].ToString()); + } + } + catch { /* form parse may fail on non-form bodies */ } + rawIds ??= NonEmptyOrNull(Request.Query["Ids"].ToString()) + ?? NonEmptyOrNull(Request.Query["ids"].ToString()) + ?? NonEmptyOrNull(Request.Query["Id"].ToString()) + ?? NonEmptyOrNull(Request.Query["id"].ToString()); + + static string? NonEmptyOrNull(string? s) => string.IsNullOrWhiteSpace(s) ? null : s; + + // Build the set of player ids to hydrate from whatever the + // caller sent. Each source contributes additively — the watch + // never mixes (Ids + Emails) in one call, but the union is + // harmless when it happens. + var ids = new HashSet(); + if (rawIds is not null) + { + foreach (var n in rawIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (long.TryParse(n, out var v) && v > 0) ids.Add(v); + } + if (rawEmails is not null) + { + var emails = rawEmails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(e => e.ToLowerInvariant()) + .Where(e => e.Length > 0) + .ToList(); + if (emails.Count > 0) + { + var matched = await db.Players + .Where(p => p.Email != null && emails.Contains(p.Email.ToLower())) + .Select(p => p.Id) + .ToListAsync(); + foreach (var id in matched) ids.Add(id); + } + } + if (rawPhones is not null) + { + var phones = rawPhones.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(p => p.Length > 0) + .ToList(); + if (phones.Count > 0) + { + var matched = await db.Players + .Where(p => p.Phone != null && phones.Contains(p.Phone)) + .Select(p => p.Id) + .ToListAsync(); + foreach (var id in matched) ids.Add(id); + } + } + if (ids.Count == 0) return Ok(Array.Empty()); + + // Strip ids the caller has blocked, and ids that have blocked the + // caller. This prevents `bulk` from being used as a cheap oracle + // ("who blocked me?"). When the caller is anonymous (no JWT) we + // skip the filter entirely — `bulk` is also used during boot for + // public profile hydration before the player has authenticated. + var me = this.CurrentPlayerId(); + if (me is long callerId) + { + var blocked = await db.Relationships + .Where(r => r.Status == RelationshipStatus.Blocked && + ((r.RequesterId == callerId && ids.Contains(r.TargetId)) || + (r.TargetId == callerId && ids.Contains(r.RequesterId)))) + .Select(r => r.RequesterId == callerId ? r.TargetId : r.RequesterId) + .ToListAsync(); + foreach (var b in blocked) ids.Remove(b); + } + + var accounts = new List(); + foreach (var id in ids) + { + var p = await playerService.GetByIdAsync(id); + if (p is not null) + accounts.Add(BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName)); + } + return Ok(accounts); + } + + // Accounts.GetNameGenerationOptions → NameGenDTO {Nouns, Adjectives} + [HttpGet("/account/v1/namegen")] + [HttpGet("/account/v1/namegeneration")] + public IActionResult NameGen() => Ok(new + { + Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" }, + Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" }, + }); + + // Phone-confirm is the one mutation we don't persist — there's no + // SMS provider in the loop. Acknowledge silently. + [HttpPost("/account/v1/confirmphone")] + public IActionResult ConfirmPhone() => + Ok(new RecNetResult { Success = true, Error = string.Empty }); + + /// POST /account/v1/birthday — sets the player's + /// birthday and updates IsJunior accordingly. Rec Room's junior + /// cutoff is <13 years old; we recompute it on every birthday + /// write so a freshly turned 13 player loses the junior flag the + /// next time they update. + [HttpPost("/account/v1/birthday")] + [HttpPost("/account/me/birthday")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task SetBirthday([FromForm(Name = "birthday")] string? birthday) + { + if (string.IsNullOrWhiteSpace(birthday) || + !DateTime.TryParse(birthday, out var bday)) + return Ok(new RecNetResult { Success = false, Error = "invalid_date" }); + + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + player.Birthday = bday.Date; + player.IsJunior = AgeYears(bday) < 13; + await db.SaveChangesAsync(); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + /// POST /account/v1/email — set or clear contact email. + /// Persisted verbatim; format isn't validated server-side, the + /// in-game email keyboard does it. + [HttpPost("/account/v1/email")] + [HttpPost("/account/me/email")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task SetEmail([FromForm(Name = "email")] string? email) + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + player.Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(); + await db.SaveChangesAsync(); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + /// POST /account/v1/phone — set or clear contact phone. + [HttpPost("/account/v1/phone")] + [HttpPost("/account/me/phone")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task SetPhone([FromForm(Name = "phone")] string? phone) + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + player.Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim(); + await db.SaveChangesAsync(); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + /// POST /account/v1/profileimage — body: imageName=... + /// or fileName=... The client first POSTs the bytes to + /// storage.rec.net/upload with FileType=Image, gets back a + /// Filename, then sends that filename here. We persist it on + /// PlayerEntity.ProfileImageName; reads from /account/me + + /// /account/v1/{id} surface it. + [HttpPost("/account/v1/profileimage")] + [HttpPost("/account/me/profileimage")] + [HttpPost("/profileimage")] + [HttpPut("/account/v1/profileimage")] + [HttpPut("/account/me/profileimage")] + [HttpPut("/profileimage")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task SetProfileImage( + [FromForm(Name = "imageName")] string? imageName, + [FromForm(Name = "fileName")] string? fileName, + [FromForm(Name = "image")] string? imageAlt) + { + // Fall back to query string for the rare client paths that + // send these fields as URL params instead of form fields. + var name = imageName ?? fileName ?? imageAlt + ?? Request.Query["imageName"].ToString() + ?? Request.Query["fileName"].ToString(); + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + player.ProfileImageName = string.IsNullOrWhiteSpace(name) ? null : name.Trim(); + await db.SaveChangesAsync(); + logger.LogInformation( + "[accounts] profile image set: player={PlayerId} → {Name}", + player.Id, player.ProfileImageName ?? ""); + await NotifyAccountImageChangedAsync(player); + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + + private Task NotifyAccountImageChangedAsync(PlayerEntity player) + { + var account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName); + var selfAccount = BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName); + return Task.WhenAll( + notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account), + notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount)); + } + + private static int AgeYears(DateTime birthday) + { + var today = DateTime.UtcNow.Date; + var age = today.Year - birthday.Year; + if (birthday.Date > today.AddYears(-age)) age--; + return age; + } + + // Everyone is a dev for now — exposes the in-game debug console + dev + // tools. Per user request: easier debugging while iterating on the + // private server. Flip back to false later if you want shipping behavior. + [HttpGet("/account/v1/{accountId:long}/isdeveloper")] + [HttpGet("/account/me/isdeveloper")] + public IActionResult IsDeveloper(long accountId = 0) => Ok(new { IsDeveloper = true }); + + /// + /// PUT /account/me/username — body: username=NewName + /// Real handler that persists the change to the DB. Was previously + /// going to GlobalCatchAllController and returning the shotgun, which + /// meant username changes didn't actually save. + /// + [HttpPut("/account/me/username")] + [HttpPost("/account/me/username")] + [HttpPost("/account/v1/username")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task ChangeUsername([FromForm(Name = "username")] string? username) + { + if (string.IsNullOrWhiteSpace(username)) + return Ok(new { success = false, error = "Username required" }); + + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + await playerService.UpdateUsernameAsync(player.Id, username.Trim()); + var updated = await playerService.GetByIdAsync(player.Id); + return Ok(new + { + success = true, + error = "", + account = new + { + accountId = (int)player.Id, + username = updated?.Username ?? username, + displayName = updated?.DisplayName ?? username, + }, + }); + } + + /// + /// PUT /account/me/displayname — same flow as username but only updates + /// the DisplayName field (Username stays as-is). + /// + [HttpPut("/account/me/displayname")] + [HttpPost("/account/me/displayname")] + [HttpPost("/account/v1/displayname")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task ChangeDisplayName([FromForm(Name = "displayName")] string? displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + return Ok(new { success = false, error = "DisplayName required" }); + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + await playerService.UpdateDisplayNameAsync(player.Id, displayName.Trim()); + return Ok(new { success = true, error = "" }); + } + + /// + /// PUT /account/me/bio — body: bio=... + /// + [HttpPut("/account/me/bio")] + [HttpPost("/account/me/bio")] + [HttpPost("/account/v1/bio")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task ChangeBio([FromForm(Name = "bio")] string? bio) + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + await playerService.UpdateBioAsync(player.Id, bio ?? string.Empty); + return Ok(new { success = true, error = "" }); + } + + // ParentalControl.Deserialize at RVA 0x14543A0 — required keys: + // accountId (int, GetKey) + // disallowInAppPurchases (bool, GetKey) + // Returning {} crashes the client with + // "Failed to GetLocalAccountParentalControl: Malformed Response". + [HttpGet("/parentalcontrol/me")] + [HttpGet("/parentalcontrol/v1/me")] + public async Task ParentalControlMe() + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + return Ok(new + { + accountId = (int)player.Id, + disallowInAppPurchases = false, + }); + } + + [HttpGet("/parentalcontrol/{accountId:long}")] + [HttpGet("/parentalcontrol/v1/{accountId:long}")] + public IActionResult ParentalControlById(long accountId) => Ok(new + { + accountId = (int)accountId, + disallowInAppPurchases = false, + }); + + [HttpGet("/account/v1/me/disallowiap")] + public IActionResult DisallowIap() => Ok(new { Disallow = false }); + + /// GET /account/{id}/bio — public bio lookup. Watch URL + /// from Cpp2IL_ISIL/.../RecNet/Accounts.txt line 5686: + /// "account/{0}/bio" (no v1 prefix). Wire shape from + /// BioDTO.txt: required keys "accountId" (int, Util.GetKey) + /// and "bio" (string, optional via the GetKeyOrDefault- + /// flavoured 0x180EE6770 helper). Returning the previous + /// { Bio = ... } PascalCase shape made the watch's + /// BioDTO.Deserialize KeyNotFoundException on + /// accountId, which surfaced as "Failed to GetBio for + /// accountId N: Malformed Response" in the client log. + [HttpGet("/account/{accountId:long}/bio")] + [HttpGet("/account/v1/{accountId:long}/bio")] + public async Task GetBio(long accountId) + { + var p = await playerService.GetByIdAsync(accountId); + return Ok(new { accountId = (int)accountId, bio = p?.Bio ?? string.Empty }); + } + + [HttpGet("/account/v1/{accountId:long}/platformid")] + public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L }); + + // Previously had a /{*path} catch-all that returned [] / {}. Removed — + // unknown accounts URLs now 404 so we notice them in logs and wire + // them properly rather than silently masking missing endpoints. + + private static RecNetAccount BuildAccount(long id, string username, string? displayName, string? profileImage = null) + => new() + { + AccountId = (int)id, + RawUsername = username, + Username = username, + DisplayName = displayName ?? username, + ProfileImage = profileImage ?? string.Empty, + TreatAsJunior = false, + HasBirthday = true, + Platforms = 1, + }; + + private static RecNetSelfAccount BuildSelfAccount(long id, string username, string? displayName, string? profileImage = null) + => new() + { + AccountId = (int)id, + RawUsername = username, + Username = username, + DisplayName = displayName ?? username, + ProfileImage = profileImage ?? string.Empty, + TreatAsJunior = false, + HasBirthday = true, + Platforms = 1, + Email = string.Empty, + Phone = string.Empty, + Birthday = new DateTime(2000, 1, 1), + JuniorState = 0, + ParentAccountId = null, + }; +} diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs new file mode 100644 index 0000000..b7c5b45 --- /dev/null +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -0,0 +1,2920 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Hubs; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Admin; + +/// +/// api.rec.net/api/admin/v1/* — privileged moderation endpoints. Every +/// action is gated by , which checks +/// ; every state mutation is logged +/// to for audit replay. +/// +/// The first account created on a fresh DB gets IsAdmin = true +/// (see ) so a +/// freshly-bootstrapped private server always has at least one root +/// admin without manual SQL. +/// +[ApiController] +[Route("api/admin/v1")] +[Authorize] +[AdminOnly] +public class AdminController( + DorkNetDbContext db, + NotificationService notifications, + LevelService level, + CommunityBoardService communityBoard, + RoomBlobNormalizerService roomBlobNormalizer, + HtrAssetMirrorService htrMirror, + OnlinePresenceService onlinePresence, + PlayerPresenceService playerPresence, + PlayerLogService playerLog, + ServerSettingsService serverSettings, + IObjectStorage storage, + DomainConfig domain, + ILogger adminLogger) : ControllerBase +{ + private long CurrentAdminId => this.RequireCurrentPlayerId(); + + // ── Player request logs ────────────────────────────────────────────── + + /// Most recent HTTP requests made by a single player, newest + /// first. Backed by 's Redis ring buffer + /// (in-process fallback for dev) — capped at + /// entries per + /// player with a 7-day key TTL. Used by the admin UI's "Player logs" + /// tab to spot what API call a player was making when they hit a bug. + [HttpGet("players/{id:long}/logs")] + public ActionResult PlayerLogs(long id, [FromQuery] int take = 200) + { + var entries = playerLog.GetRecent(id, take); + return Ok(entries); + } + + // ── Players ────────────────────────────────────────────────────────── + + /// List players with online status, paginated. Used by the + /// admin watch tab. Default page size 50, max 200 to keep responses + /// reasonable. + [HttpGet("players")] + public async Task ListPlayers( + [FromQuery] string? query, [FromQuery] int take = 50, [FromQuery] int skip = 0) + { + take = Math.Clamp(take, 1, 200); + skip = Math.Max(0, skip); + + IQueryable q = db.Players.OrderByDescending(p => p.LastSeenAt); + if (!string.IsNullOrWhiteSpace(query)) + q = q.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query)); + + var page = await q.Skip(skip).Take(take).ToListAsync(); + var onlineSet = onlinePresence.OnlinePlayerIds().ToHashSet(); + return Ok(page.Select(p => new + { + p.Id, + p.Username, + p.DisplayName, + p.Email, + p.IsAdmin, + p.IsDeveloper, + p.IsCommunityTeam, + p.IsVerified, + p.IsJunior, + p.BannedUntil, + p.LastIp, + p.LastSeenAt, + p.CreatedAt, + p.Level, + p.XP, + p.ProfileImageName, + Online = onlineSet.Contains(p.Id), + })); + } + + /// Single-player detail — bundles the player record plus + /// their currency balances so the admin UI can render an edit page + /// in one round trip. + [HttpGet("players/{id:long}")] + public async Task GetPlayer(long id) + { + var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id); + if (p is null) return NotFound(); + var balances = await db.CurrencyBalances + .Where(c => c.PlayerId == id) + .Select(c => new { c.CurrencyType, c.Balance }) + .ToListAsync(); + var avatar = await db.Avatars + .Where(a => a.PlayerId == id) + .Select(a => new { a.OutfitSelections, a.HairColor, a.SkinColor, a.FaceFeatures }) + .FirstOrDefaultAsync(); + return Ok(new + { + p.Id, + p.Username, + p.DisplayName, + p.Bio, + p.Email, + p.IsAdmin, + p.IsDeveloper, + p.IsCommunityTeam, + p.IsVerified, + p.IsJunior, + p.BannedUntil, + p.LastIp, + p.LastSeenAt, + p.CreatedAt, + p.Level, + p.XP, + p.ProfileImageName, + Online = onlinePresence.OnlinePlayerIds().Contains(p.Id), + Balances = balances, + Avatar = avatar, + }); + } + + public sealed record BanRequest(int DurationDays, string? Reason); + + /// Ban a player for the given number of days. The + /// 401s any subsequent request + /// from the player until BannedUntil elapses; we also push + /// a ModerationKick notification so the watch sees the ban + /// immediately. + [HttpPost("players/{id:long}/ban")] + public async Task BanPlayer(long id, [FromBody] BanRequest body) + { + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id); + if (player is null) return NotFound(); + if (player.IsAdmin) return BadRequest("Cannot ban an admin. Demote first."); + + var days = Math.Clamp(body.DurationDays, 1, 3650); + player.BannedUntil = DateTime.UtcNow.AddDays(days); + + await LogAsync("ban_player", "player", id, body.Reason ?? ""); + await db.SaveChangesAsync(); + + await notifications.NotifyAsync(id, PushNotificationId.ModerationKick, + new { Reason = body.Reason ?? "", Until = player.BannedUntil }); + + return Ok(new { player.Id, player.BannedUntil }); + } + + [HttpPost("players/{id:long}/unban")] + public async Task UnbanPlayer(long id, [FromBody] BanRequest? body) + { + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id); + if (player is null) return NotFound(); + + player.BannedUntil = null; + await LogAsync("unban_player", "player", id, body?.Reason ?? ""); + await db.SaveChangesAsync(); + return Ok(new { player.Id, player.BannedUntil }); + } + + [HttpPost("players/{id:long}/promote")] + public async Task Promote(long id) + { + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id); + if (player is null) return NotFound(); + player.IsAdmin = true; + await LogAsync("promote_admin", "player", id, ""); + await db.SaveChangesAsync(); + return Ok(new { player.Id, player.IsAdmin }); + } + + [HttpPost("players/{id:long}/demote")] + public async Task Demote(long id) + { + // Refuse to demote yourself — guards against the last admin + // accidentally dropping privileges and being unable to recover. + if (id == CurrentAdminId) + return BadRequest("Cannot demote yourself. Have another admin do it."); + + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id); + if (player is null) return NotFound(); + player.IsAdmin = false; + await LogAsync("demote_admin", "player", id, ""); + await db.SaveChangesAsync(); + return Ok(new { player.Id, player.IsAdmin }); + } + + // ── Rooms ──────────────────────────────────────────────────────────── + + public sealed record DeleteRoomRequest(string? Reason); + + /// Soft-delete a room by setting State = 1 + /// (Archived). The room stops appearing in browse/search results; + /// its data is still retrievable for an undelete if needed. + /// GET api/admin/v1/diagnostics/room-images — + /// emergency diagnostic for "room tile shows smiley" symptoms. + /// Returns one row per seeded room with: the DB ImageName, whether + /// the corresponding file exists in data/images/, and the + /// fully-qualified URL the client would fetch. Lets us tell at a + /// glance whether the bug is (a) DB never got real CDN names from + /// the backfill, (b) PNG missing on disk, or (c) wrong URL host + /// being served by the config endpoint. + [HttpGet("diagnostics/room-images")] + public async Task DiagnosticsRoomImages() + { + var imgDir = Path.Combine(AppContext.BaseDirectory, "data", "images"); + var rooms = await db.Rooms + .Where(r => r.Id >= 100 && r.Id < 1000) + .OrderBy(r => r.Id) + .Select(r => new { r.Id, r.Name, r.ImageName }) + .ToListAsync(); + var rows = rooms.Select(r => + { + var path = Path.Combine(imgDir, r.ImageName ?? string.Empty); + return new + { + r.Id, + r.Name, + r.ImageName, + FileExists = !string.IsNullOrEmpty(r.ImageName) && System.IO.File.Exists(path), + FullPath = path, + FetchUrl = $"https://{domain.Sub("img")}/{r.ImageName}", + }; + }).ToList(); + return Ok(new + { + ImageDirOnDisk = imgDir, + ImageDirExists = Directory.Exists(imgDir), + ImageFileCount = Directory.Exists(imgDir) ? Directory.GetFiles(imgDir).Length : 0, + RoomImagesJsonExists = System.IO.File.Exists( + Path.Combine(AppContext.BaseDirectory, "data", "room_images.json")), + Rooms = rows, + }); + } + + /// GET api/admin/v1/players/{id}/clothing-debug — + /// inspect a single player's wardrobe state. Shows the raw + /// AvatarEntity columns the watch reads (OutfitSelections, + /// InventoryJson, HairColor, SkinColor), the parsed-and-filtered + /// list of items the player will see in their wardrobe drawer + /// (intersection of InventoryJson with the bundled safelist), + /// and any GUIDs in InventoryJson that AREN'T in the safelist + /// (those would crash the watch's parser if shipped — silently + /// dropped by AvatarItemsController.Get(), but worth knowing + /// they're sitting in the inventory). + /// + /// Mostly here as a one-call sanity check after the avatar + /// pipeline overhaul: hit it against the player ID, eyeball the + /// drawer-count, confirm the ratio matches what's in the DB. + [HttpGet("players/{id:long}/clothing-debug")] + public async Task ClothingDebug(long id) + { + var avatar = await db.Avatars + .Where(a => a.PlayerId == id) + .Select(a => new + { + a.OutfitSelections, + a.InventoryJson, + a.HairColor, + a.SkinColor, + a.FaceFeatures, + a.UpdatedAt, + }) + .FirstOrDefaultAsync(); + if (avatar is null) return NotFound(new { Message = $"No AvatarEntity for player {id}" }); + + var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids; + var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog; + + // Parse the player's inventory + intersect against the bundled + // safelist. Items that pass through end up in + // /api/avatar/v4/items as renderable wardrobe entries; items + // that fail are silently dropped at request time. + List inventoryGuids; + try + { + inventoryGuids = System.Text.Json.JsonSerializer + .Deserialize>(avatar.InventoryJson ?? "[]") ?? new(); + } + catch { inventoryGuids = new(); } + + var owned = inventoryGuids.Where(g => safe.Contains(g)).ToList(); + var unsafeGuids = inventoryGuids.Where(g => !safe.Contains(g)).ToList(); + + // Parse OutfitSelections (the equipped loadout) the same way + // the watch does — comma-separated, each segment a GUID. Show + // which slot each one resolves to + whether it's in the safe + // catalog. + var equipped = (avatar.OutfitSelections ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(g => g.Trim()) + .Where(g => g.Length > 0) + .Select(g => new + { + Guid = g, + InSafelist = safe.Contains(g), + FriendlyName = catalog.TryGetValue(g, out var c) ? c.FriendlyName : null, + }) + .ToList(); + + return Ok(new + { + PlayerId = id, + avatar.UpdatedAt, + avatar.HairColor, + avatar.SkinColor, + avatar.FaceFeatures, + OutfitSelections = avatar.OutfitSelections, + EquippedItems = equipped, + InventoryRaw = avatar.InventoryJson, + InventoryCount = inventoryGuids.Count, + OwnedAndRenderable = owned.Count, + OwnedAndRenderableGuids = owned, + UnsafeOwnedGuids = unsafeGuids, + CatalogTotal = catalog.Count, + SafelistTotal = safe.Count, + }); + } + + /// GET api/admin/v1/rooms — flat list of every + /// room in the DB (id + name + creator + blob count) for the admin + /// UI's room-pickers. Custom (AG) rooms first, then RR Originals; + /// each ordered by name. The blob count helps the .htr mirror + /// dropdown skip rooms that have no blobs to scan. + [HttpGet("rooms")] + public async Task ListAllRooms() + { + var rooms = await db.Rooms + .OrderByDescending(r => r.IsAGRoom) + .ThenBy(r => r.Name) + .Select(r => new + { + r.Id, + r.Name, + r.IsAGRoom, + r.IsDormRoom, + r.CreatorPlayerId, + BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id), + }) + .ToListAsync(); + return Ok(rooms); + } + + /// GET api/admin/v1/rooms/{id}/visitors — most + /// recent unique visitors of a room. Joins + /// against + /// so the admin UI can show "who's been here lately" with + /// usernames + per-player visit counts. + [HttpGet("rooms/{id:long}/visitors")] + public async Task RoomVisitors(long id, [FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 200); + var rows = await (from v in db.RoomVisits + join p in db.Players on v.PlayerId equals p.Id + where v.RoomId == id + orderby v.LastVisitAt descending + select new + { + PlayerId = v.PlayerId, + p.Username, + p.DisplayName, + v.VisitCount, + v.FirstVisitAt, + v.LastVisitAt, + }) + .Take(take) + .ToListAsync(); + return Ok(rows); + } + + [HttpDelete("rooms/{id:long}")] + public async Task DeleteRoom(long id, [FromBody] DeleteRoomRequest? body) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); + if (room is null) return NotFound(); + room.State = 1; // Archived + await LogAsync("delete_room", "room", id, body?.Reason ?? ""); + await db.SaveChangesAsync(); + return Ok(new { room.Id, room.State }); + } + + // The bulk "purge every custom room" endpoint was removed: a single + // wrong click could wipe every imported map in one shot. Per-room + // purge (below) is the only hard-delete path, gated by two + // independent room-name typed confirmations to make accidental + // wipes effectively impossible. + + public sealed record HardDeleteRoomRequest( + string? Reason, + // The admin UI prompts for the room name TWICE in separate + // inputs — both have to match the room's actual Name (exactly, + // case-sensitive) before the request will fire. Same pattern + // GitHub uses for repository deletion. + string? ConfirmName1, + string? ConfirmName2); + + /// POST api/admin/v1/rooms/{id}/purge — hard-delete a + /// single custom room. Refuses to act on TRUE RR Originals (owned + /// by the system seed account) or dorms; for those, the soft-delete + /// via DELETE /rooms/{id} is the only supported path so a fat + /// finger doesn't wipe canonical content. + /// + /// A user-cloned room often inherits IsAGRoom=true from its + /// source — that doesn't make it canonical, it just means the + /// matchmaker uses the AG-room join path. Gate purely on + /// CreatorPlayerId == SystemAccountId, which is the only + /// reliable "is this actually a seeded RR Original?" signal. + /// + /// Requires BOTH ConfirmName1 and ConfirmName2 in the + /// body to exactly match the room's current Name. Both must match + /// independently — the SPA renders two separate inputs and the + /// admin types the name twice. Returns 400 with + /// confirm_name_mismatch if either is missing/wrong so the + /// SPA can surface the error inline without leaking which one + /// failed. + [HttpPost("rooms/{id:long}/purge")] + public async Task HardDeleteRoom(long id, [FromBody] HardDeleteRoomRequest? body) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); + if (room is null) return NotFound(); + if (room.IsDormRoom) + return BadRequest(new { error = "refuses_to_hard_delete_dorm", note = "Dorms are per-player state; never purge." }); + if (room.IsAGRoom && room.CreatorPlayerId == PlayerService.SystemAccountId) + return BadRequest(new { error = "refuses_to_hard_delete_seeded_room", note = "This row is owned by the system seed account — soft-archive via DELETE /rooms/{id} instead." }); + + var confirm1 = body?.ConfirmName1 ?? string.Empty; + var confirm2 = body?.ConfirmName2 ?? string.Empty; + if (!string.Equals(confirm1, room.Name, StringComparison.Ordinal) + || !string.Equals(confirm2, room.Name, StringComparison.Ordinal)) + { + return BadRequest(new + { + error = "confirm_name_mismatch", + note = "Both ConfirmName1 and ConfirmName2 must exactly equal the room's current Name (case-sensitive).", + }); + } + + var scenes = await db.RoomScenes.Where(s => s.RoomId == id).ToListAsync(); + var roomBlobs = await db.RoomDataBlobs.Where(b => b.RoomId == id).ToListAsync(); + RoomDataBlobEntity? imageBlob = null; + if (!string.IsNullOrEmpty(room.ImageName)) + { + imageBlob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.RoomId == 0 && b.BlobName == room.ImageName); + } + + db.RoomScenes.RemoveRange(scenes); + db.RoomDataBlobs.RemoveRange(roomBlobs); + if (imageBlob is not null) db.RoomDataBlobs.Remove(imageBlob); + db.Rooms.Remove(room); + + await LogAsync("hard_delete_room", "room", id, + $"name={room.Name} reason={body?.Reason ?? ""} blobs={roomBlobs.Count} scenes={scenes.Count}"); + await db.SaveChangesAsync(); + + return Ok(new + { + deleted = id, + name = room.Name, + blobs = roomBlobs.Count, + scenes = scenes.Count, + thumbnail = imageBlob is not null, + }); + } + + // ── Leaderboards ───────────────────────────────────────────────────── + + /// Verified stat-channel metadata. The 2020 watch's + /// ConditionalSetStat_ReturningNewValue handles the + /// high-vs-low-is-better decision locally, so the server doesn't + /// enforce direction — this is purely for the admin SPA to render + /// channels sensibly. + /// + /// Only entries cross-referenced against decompilation live here. + /// Channel 7 is verified by + /// StuntRunnerManager.get_TotalTimeStatChannelForLeaderboard + /// (StuntRunnerManager.txt:220-228 returns the literal 7); the + /// stored value is milliseconds, hence ValueFormat = "time-ms". + /// Other channels intentionally stay un-named — they used to ship + /// with best-effort guesses ("Paintball wins", "Dodgeball wins", + /// etc.) that were almost certainly wrong, so the admin UI now + /// renders unknown channels as "Channel N" rather than misleading + /// labels. Add new entries here only when you've verified the + /// channel mapping in the decompiled client. + private static readonly Dictionary KnownStatChannels = new() + { + [7] = new("Stunt Runner total time", true, "time-ms"), + }; + + private sealed record StatChannelMeta(string Name, bool LowerIsBetter, string ValueFormat); + + /// GET api/admin/v1/leaderboards/channels — summary + /// of every stat channel that has at least one row. Used by the + /// Leaderboards SPA tab to populate the channel picker. + [HttpGet("leaderboards/channels")] + public async Task ListChannels() + { + var rows = await db.LeaderboardStats + .GroupBy(s => s.StatChannel) + .Select(g => new + { + Channel = g.Key, + EntryCount = g.Count(), + MinValue = g.Min(x => x.Value), + MaxValue = g.Max(x => x.Value), + }) + .OrderBy(r => r.Channel) + .ToListAsync(); + // DB-defined meta beats hardcoded KnownStatChannels so admins + // can rename / reassign channels live without a code change. + var dbMeta = await db.LeaderboardChannelMeta.ToDictionaryAsync(c => c.Channel); + var labelled = rows.Select(r => + { + dbMeta.TryGetValue(r.Channel, out var live); + KnownStatChannels.TryGetValue(r.Channel, out var fallback); + return new + { + r.Channel, + Name = live?.Name is { Length: > 0 } n ? n : (fallback?.Name ?? $"Channel {r.Channel}"), + RoomId = live?.RoomId ?? 0, + LowerIsBetter = live?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false, + ValueFormat = live?.ValueFormat ?? fallback?.ValueFormat ?? "count", + Verified = live is not null || fallback is not null, + r.EntryCount, + r.MinValue, + r.MaxValue, + }; + }).ToList(); + return Ok(new + { + Channels = labelled, + // Surface the known set as well so the SPA can offer + // "create a row in channel X" even when the channel is + // currently empty. + Known = KnownStatChannels.Select(kv => new + { + Channel = kv.Key, + kv.Value.Name, + kv.Value.LowerIsBetter, + kv.Value.ValueFormat, + }), + }); + } + + /// GET api/admin/v1/leaderboards/{channel} — every + /// stat row in a channel with the player's username, sorted by + /// the channel's natural direction (lower-is-better channels + /// sorted ascending, everything else descending). + [HttpGet("leaderboards/{channel:int}")] + public async Task ChannelDetail(int channel, [FromQuery] int take = 100) + { + take = Math.Clamp(take, 1, 1000); + var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel); + KnownStatChannels.TryGetValue(channel, out var fallback); + var name = liveMeta?.Name is { Length: > 0 } ln ? ln : (fallback?.Name ?? $"Channel {channel}"); + var lowerIsBetter = liveMeta?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false; + var valueFormat = liveMeta?.ValueFormat ?? fallback?.ValueFormat ?? "count"; + var query = db.LeaderboardStats + .Where(s => s.StatChannel == channel); + query = lowerIsBetter + ? query.OrderBy(s => s.Value) + : query.OrderByDescending(s => s.Value); + + var rows = await (from s in query.Take(take) + join p in db.Players on s.PlayerId equals p.Id into pj + from p in pj.DefaultIfEmpty() + select new + { + s.Id, + s.PlayerId, + Username = p == null ? null : p.Username, + DisplayName = p == null ? null : p.DisplayName, + s.Value, + s.UpdatedAt, + }).ToListAsync(); + return Ok(new + { + Channel = channel, + Name = name, + RoomId = liveMeta?.RoomId ?? 0, + LowerIsBetter = lowerIsBetter, + ValueFormat = valueFormat, + Verified = liveMeta is not null || fallback is not null, + Entries = rows.Select((r, i) => new + { + Rank = i + 1, + r.Id, + r.PlayerId, + r.Username, + r.DisplayName, + r.Value, + r.UpdatedAt, + }), + }); + } + + public sealed record SetLeaderboardScoreRequest(long PlayerId, int Value); + + /// POST api/admin/v1/leaderboards/{channel}/score + /// — upsert a single player's score in a channel. + [HttpPost("leaderboards/{channel:int}/score")] + public async Task SetScore(int channel, [FromBody] SetLeaderboardScoreRequest body) + { + var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => + s.PlayerId == body.PlayerId && s.StatChannel == channel); + if (row is null) + { + row = new LeaderboardStatEntity + { + PlayerId = body.PlayerId, + StatChannel = channel, + Value = body.Value, + }; + db.LeaderboardStats.Add(row); + } + else + { + row.Value = body.Value; + row.UpdatedAt = DateTime.UtcNow; + } + await LogAsync("set_leaderboard_score", "player", body.PlayerId, $"channel={channel} value={body.Value}"); + await db.SaveChangesAsync(); + return Ok(new { row.PlayerId, row.StatChannel, row.Value }); + } + + [HttpDelete("leaderboards/{channel:int}/score/{playerId:long}")] + public async Task DeleteScore(int channel, long playerId) + { + var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => + s.PlayerId == playerId && s.StatChannel == channel); + if (row is null) return NotFound(); + db.LeaderboardStats.Remove(row); + await LogAsync("delete_leaderboard_score", "player", playerId, $"channel={channel}"); + await db.SaveChangesAsync(); + return Ok(new { deleted = playerId, channel }); + } + + /// DELETE api/admin/v1/leaderboards/{channel} — + /// wipe every entry in a stat channel. Used for season resets or + /// to clear test data. + [HttpDelete("leaderboards/{channel:int}")] + public async Task WipeChannel(int channel) + { + var rows = await db.LeaderboardStats.Where(s => s.StatChannel == channel).ToListAsync(); + if (rows.Count == 0) return Ok(new { wiped = 0, channel }); + db.LeaderboardStats.RemoveRange(rows); + await LogAsync("wipe_leaderboard_channel", "system", channel, $"entries={rows.Count}"); + await db.SaveChangesAsync(); + return Ok(new { wiped = rows.Count, channel }); + } + + // ── Rec Room Originals (RROs) ──────────────────────────────────────── + + /// GET api/admin/v1/rooms/originals — the canonical + /// Rec Room Originals (rooms whose CreatorPlayerId == + /// PlayerService.SystemAccountId). Backs the RRO admin tab + /// which lets admins flip per-room properties (cloning, max + /// players, description) without scrolling through the global + /// rooms list. + [HttpGet("rooms/originals")] + public async Task ListOriginals() + { + var rooms = await db.Rooms + .Where(r => r.CreatorPlayerId == PlayerService.SystemAccountId) + .OrderBy(r => r.Name) + .Select(r => new + { + r.Id, + r.Name, + r.Description, + r.ImageName, + r.Accessibility, + r.CloningAllowed, + r.IsAGRoom, + r.IsDormRoom, + r.State, + r.TagsCsv, + r.HotScore, + r.VisitCount, + r.VisitorCount, + r.CheerCount, + r.CurrentDataBlobName, + SceneCount = db.RoomScenes.Count(s => s.RoomId == r.Id), + BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id), + }) + .ToListAsync(); + return Ok(rooms); + } + + public sealed record UpdateRoomPropsRequest( + string? Description, + bool? CloningAllowed, + int? Accessibility, + string? TagsCsv, + double? HotScore, + int? State, + string? ImageName); + + /// POST api/admin/v1/rooms/{id}/props — partial + /// update of room properties. Only the non-null fields in the + /// body get written; the rest are left untouched. Backs the + /// RRO edit form and is also usable for regular custom rooms. + [HttpPost("rooms/{id:long}/props")] + public async Task UpdateRoomProps(long id, [FromBody] UpdateRoomPropsRequest body) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); + if (room is null) return NotFound(); + var changes = new List(); + if (body.Description is string desc) + { + room.Description = desc; + changes.Add($"description={desc.Length} chars"); + } + if (body.CloningAllowed is bool c) { room.CloningAllowed = c; changes.Add($"cloning={c}"); } + if (body.Accessibility is int a) { room.Accessibility = a; changes.Add($"accessibility={a}"); } + if (body.TagsCsv is string tags) { room.TagsCsv = tags; changes.Add($"tags={tags}"); } + if (body.HotScore is double h) { room.HotScore = h; changes.Add($"hot={h}"); } + if (body.State is int st) { room.State = st; changes.Add($"state={st}"); } + if (body.ImageName is string img) { room.ImageName = img; changes.Add($"image={img}"); } + + if (changes.Count == 0) return Ok(new { room.Id, unchanged = true }); + + await LogAsync("update_room_props", "room", id, string.Join(", ", changes)); + await db.SaveChangesAsync(); + return Ok(new { room.Id, room.Name, applied = changes }); + } + + // ── Room detail (unified per-room view backing the admin SPA) ───────── + + /// GET api/admin/v1/rooms/{id} — every column on the + /// room plus owner/co-owner/mod/host display info, blob count, scene + /// count, current data blob name, and the latest CheerCount. The + /// admin SPA's per-room detail page consumes this in one shot rather + /// than fanning out to the legacy /rooms, /rooms/originals, + /// /playerReputation endpoints separately. + [HttpGet("rooms/{id:long}")] + public async Task GetRoomDetail(long id) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); + if (room is null) return NotFound(); + + var sceneCount = await db.RoomScenes.CountAsync(s => s.RoomId == id); + var blobCount = await db.RoomDataBlobs.CountAsync(b => b.RoomId == id); + var roles = await db.RoomRoles + .Where(r => r.RoomId == id) + .OrderBy(r => r.Role).ThenBy(r => r.GrantedAt) + .ToListAsync(); + + // Hydrate display names for creator + every role grant in one + // hit so the UI doesn't have to follow up with /players/{id}. + var playerIds = roles.Select(r => r.PlayerId) + .Append(room.CreatorPlayerId) + .Distinct() + .ToList(); + var players = await db.Players + .Where(p => playerIds.Contains(p.Id)) + .Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName }) + .ToListAsync(); + + object PlayerInfo(long pid) => + players.FirstOrDefault(p => p.Id == pid) is { } p + ? new { id = p.Id, username = p.Username, displayName = p.DisplayName, profileImageName = p.ProfileImageName } + : new { id = pid, username = $"#{pid}", displayName = "", profileImageName = (string?)null }; + + return Ok(new + { + room.Id, + room.Name, + room.Description, + room.ImageName, + room.State, + room.Accessibility, + room.IsAGRoom, + room.IsDormRoom, + room.CloningAllowed, + room.SupportsLevelVoting, + room.SupportsVRLow, + room.SupportsMobile, + room.SupportsScreens, + room.SupportsWalkVR, + room.SupportsTeleportVR, + room.AllowsJuniors, + room.DisableMicAutoMute, + room.RoomWarningMask, + room.CustomRoomWarning, + room.TagsCsv, + room.CheerCount, + room.FavoriteCount, + room.VisitCount, + room.VisitorCount, + room.HotScore, + room.LocationReplicationId, + room.CurrentDataBlobName, + room.CreatedAt, + room.UpdatedAt, + sceneCount, + blobCount, + owner = PlayerInfo(room.CreatorPlayerId), + roles = roles.Select(r => new + { + r.Id, + r.PlayerId, + r.Role, + r.Accepted, + r.GrantedByPlayerId, + r.GrantedAt, + player = PlayerInfo(r.PlayerId), + }).ToList(), + }); + } + + public sealed record AddRoleRequest(long PlayerId, int Role, bool Accepted = true); + + /// POST api/admin/v1/rooms/{id}/roles — grant a + /// (PlayerId, Role) pair on the room. Role: 0=CoOwner, 1=Moderator, + /// 2=Host. Idempotent — re-granting the same role flips Accepted + /// only. + [HttpPost("rooms/{id:long}/roles")] + public async Task AddRoomRole(long id, [FromBody] AddRoleRequest body) + { + if (body is null) return BadRequest("missing body"); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); + if (room is null) return NotFound(); + if (body.PlayerId <= 0) return BadRequest("missing player"); + if (body.Role is < 0 or > 2) return BadRequest("invalid role"); + + var existing = await db.RoomRoles.FirstOrDefaultAsync(r => + r.RoomId == id && r.PlayerId == body.PlayerId && r.Role == body.Role); + if (existing is null) + { + db.RoomRoles.Add(new RoomRoleEntity + { + RoomId = id, + PlayerId = body.PlayerId, + Role = body.Role, + Accepted = body.Accepted, + GrantedByPlayerId = CurrentAdminId, + }); + } + else + { + existing.Accepted = body.Accepted; + } + await LogAsync("room_role_add", "room", id, $"player={body.PlayerId} role={body.Role}"); + await db.SaveChangesAsync(); + return Ok(new { ok = true }); + } + + /// DELETE api/admin/v1/rooms/{id}/roles/{playerId}/{role} + /// — revoke a specific role grant. No-op if the row doesn't exist. + [HttpDelete("rooms/{id:long}/roles/{playerId:long}/{role:int}")] + public async Task RemoveRoomRole(long id, long playerId, int role) + { + var row = await db.RoomRoles.FirstOrDefaultAsync(r => + r.RoomId == id && r.PlayerId == playerId && r.Role == role); + if (row is null) return Ok(new { ok = true, deleted = 0 }); + db.RoomRoles.Remove(row); + await LogAsync("room_role_remove", "room", id, $"player={playerId} role={role}"); + await db.SaveChangesAsync(); + return Ok(new { ok = true, deleted = 1 }); + } + + public sealed record TransferOwnerRequest(long NewCreatorPlayerId); + + /// POST api/admin/v1/rooms/{id}/owner — reassign the + /// room's CreatorPlayerId. The previous owner is automatically added + /// as a CoOwner so the original creator keeps a foothold on their + /// own room. Reject self-transfers. + [HttpPost("rooms/{id:long}/owner")] + public async Task TransferOwner(long id, [FromBody] TransferOwnerRequest body) + { + if (body is null || body.NewCreatorPlayerId <= 0) return BadRequest("missing new owner"); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); + if (room is null) return NotFound(); + if (room.CreatorPlayerId == body.NewCreatorPlayerId) return Ok(new { ok = true, unchanged = true }); + + var previousOwner = room.CreatorPlayerId; + room.CreatorPlayerId = body.NewCreatorPlayerId; + room.UpdatedAt = DateTime.UtcNow; + + // Auto-grant the former owner CoOwner (id=0) so they don't lose + // access to a room they built. Idempotent — re-running this in + // a fast undo-redo flow won't accumulate dupes. + if (previousOwner > 0 && !await db.RoomRoles.AnyAsync(r => + r.RoomId == id && r.PlayerId == previousOwner && r.Role == 0)) + { + db.RoomRoles.Add(new RoomRoleEntity + { + RoomId = id, + PlayerId = previousOwner, + Role = 0, + Accepted = true, + GrantedByPlayerId = CurrentAdminId, + }); + } + + await LogAsync("room_transfer_owner", "room", id, $"from={previousOwner} to={body.NewCreatorPlayerId}"); + await db.SaveChangesAsync(); + return Ok(new { ok = true }); + } + + /// GET api/admin/v1/rooms/{id}/instances — active + /// Photon instances for one specific room. Subset of the global + /// /instances endpoint pre-filtered to one room; the admin + /// SPA's room detail page embeds these so admins don't have to + /// scroll a global instance list looking for matches. + /// + /// The masterPlayerId field is best-effort: Photon's master + /// is whoever has the lowest ActorNumber in the room (i.e. + /// the first to join). The server doesn't see ActorNumbers, but + /// the player with the lowest DorkNet player id among current + /// participants is a reasonable proxy on dorms (creator joined + /// first) and pretty good on user rooms. For non-dorm rooms with + /// the creator absent, the value just identifies a stable "lead" + /// participant — the room won't fall apart if it's not the actual + /// Photon master, and admins primarily use the badge to know + /// which row to click "Pull in" against to land in the right + /// shard. + [HttpGet("rooms/{id:long}/instances")] + public async Task GetRoomInstances(long id) + { + var room = await db.Rooms.Where(r => r.Id == id) + .Select(r => new { r.Id, r.CreatorPlayerId, r.IsDormRoom }) + .FirstOrDefaultAsync(); + + var onlinePids = onlinePresence.OnlinePlayerIds().ToList(); + var byInstance = new Dictionary pids)>(); + foreach (var pid in onlinePids) + { + var r = playerPresence.GetRoom(pid); + if (r is null || r.RoomId != id) continue; + if (!byInstance.TryGetValue(r.RoomInstanceId, out var slot)) + { + slot = (r.RoomInstanceId, r.RoomId, r.SubRoomId, r.Name ?? "", r.PhotonRoomId ?? "", r.PhotonRegionId ?? "", r.Location ?? "", r.MaxCapacity, r.IsPrivate, new List()); + } + slot.pids.Add(pid); + byInstance[r.RoomInstanceId] = slot; + } + if (byInstance.Count == 0) return Ok(Array.Empty()); + + var allPids = byInstance.Values.SelectMany(v => v.pids).Distinct().ToList(); + var players = await db.Players + .Where(p => allPids.Contains(p.Id)) + .Select(p => new { p.Id, p.Username, p.DisplayName }) + .ToDictionaryAsync(p => p.Id); + + return Ok(byInstance.Values.Select(i => + { + // Master heuristic: dorm owner if present, else lowest pid. + long master; + if (room is { IsDormRoom: true } && i.pids.Contains(room.CreatorPlayerId)) + master = room.CreatorPlayerId; + else + master = i.pids.OrderBy(p => p).FirstOrDefault(); + return new + { + roomInstanceId = i.roomInstanceId, + roomId = i.roomId, + subRoomId = i.subRoomId, + roomName = i.roomName, + photonRoomId = i.photonRoomId, + photonRegionId = i.photonRegionId, + location = i.location, + maxCapacity = i.maxCapacity, + isPrivate = i.isPrivate, + masterPlayerId = master, + participants = i.pids.Select(pid => players.TryGetValue(pid, out var p) + ? new { id = p.Id, username = p.Username, displayName = p.DisplayName, isMaster = pid == master } + : new { id = pid, username = $"#{pid}", displayName = "", isMaster = pid == master }).ToList(), + }; + })); + } + + public sealed record PullIntoInstanceRequest(long PlayerId); + + /// POST api/admin/v1/rooms/{id}/instances/{instanceId}/pull + /// — pull a specific player into one already-active instance of this + /// room. Wraps the existing player force-join flow but pre-fills the + /// instance and validates that the target instance is actually live + /// in this room. Useful for "drop me into the shard the streamer is + /// already in" without having to dig the instance id out by hand. + [HttpPost("rooms/{id:long}/instances/{instanceId:long}/pull")] + public async Task PullIntoInstance(long id, long instanceId, [FromBody] PullIntoInstanceRequest body) + { + if (body is null || body.PlayerId <= 0) return BadRequest("missing player"); + + // Find any participant currently in the target instance — we + // need their RoomInstanceDto to know which Photon shard to + // route the pulled player into. If nobody's online in the + // instance anymore the instance has effectively died; reject. + DorkNet.Server.Controllers.Match.RoomInstanceDto? sample = null; + foreach (var pid in onlinePresence.OnlinePlayerIds()) + { + var r = playerPresence.GetRoom(pid); + if (r is not null && r.RoomId == id && r.RoomInstanceId == instanceId) + { + sample = r; + break; + } + } + if (sample is null) return NotFound(new { error = "instance_not_active" }); + + // Re-stamp presence on the target so their next heartbeat / + // goto sees us pointing at this instance. Push a SignalR + // SubscriptionUpdateGameSession so the watch's matchmaking + // updates LocalRoomInstance immediately. + playerPresence.SetRoom(body.PlayerId, sample); + await notifications.NotifyAsync(body.PlayerId, + PushNotificationId.SubscriptionUpdateGameSession, + sample); + await LogAsync("instance_pull", "instance", instanceId, $"player={body.PlayerId} room={id}"); + await db.SaveChangesAsync(); + return Ok(new { ok = true, instanceId, roomInstanceId = sample.RoomInstanceId }); + } + + /// POST api/admin/v1/rooms/{id}/instances/{instanceId}/close + /// — kick every player currently in the instance via ModerationKick + /// pushes and clear their presence so the server-side view matches. + /// The Photon room itself isn't directly destroyed (we don't have + /// Photon admin credentials); it dies on its own once the last + /// participant disconnects. In practice every kick fires within + /// the same second, so the room is empty seconds later. + [HttpPost("rooms/{id:long}/instances/{instanceId:long}/close")] + public async Task CloseInstance(long id, long instanceId, [FromBody] KickRequest? body) + { + var reason = body?.Reason ?? "Instance closed by admin"; + var kicked = new List(); + foreach (var pid in onlinePresence.OnlinePlayerIds().ToList()) + { + var r = playerPresence.GetRoom(pid); + if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue; + await notifications.NotifyAsync(pid, PushNotificationId.ModerationKick, new { Reason = reason }); + playerPresence.Clear(pid); + kicked.Add(pid); + } + if (kicked.Count > 0) + { + await LogAsync("instance_close", "instance", instanceId, $"room={id} kicked={kicked.Count}"); + await db.SaveChangesAsync(); + } + return Ok(new { ok = true, kicked }); + } + + /// GET api/admin/v1/rooms/{id}/leaderboards — every + /// stat channel mapped to this room via + /// , plus the row count + /// per channel and the top scorer. Channels that report scores but + /// haven't been mapped yet show up in + /// instead. + [HttpGet("rooms/{id:long}/leaderboards")] + public async Task GetRoomLeaderboards(long id) + { + var metas = await db.LeaderboardChannelMeta + .Where(c => c.RoomId == id) + .OrderBy(c => c.Channel) + .ToListAsync(); + if (metas.Count == 0) return Ok(Array.Empty()); + + var channels = metas.Select(m => m.Channel).ToList(); + var counts = await db.LeaderboardStats + .Where(s => channels.Contains(s.StatChannel)) + .GroupBy(s => s.StatChannel) + .Select(g => new { Channel = g.Key, Count = g.Count(), Best = g.Max(x => x.Value), Worst = g.Min(x => x.Value) }) + .ToListAsync(); + + return Ok(metas.Select(m => + { + var stat = counts.FirstOrDefault(c => c.Channel == m.Channel); + return new + { + m.Channel, + m.Name, + m.LowerIsBetter, + m.ValueFormat, + EntryCount = stat?.Count ?? 0, + BestValue = stat is null ? (long?)null : (m.LowerIsBetter ? stat.Worst : stat.Best), + }; + })); + } + + public sealed record UpsertChannelMetaRequest(int Channel, long RoomId, string Name, bool LowerIsBetter, string? ValueFormat); + + /// POST api/admin/v1/leaderboards/meta — register or + /// rename a stat channel + assign it to a room. Idempotent; updates + /// the existing row when the channel already has metadata. Does NOT + /// touch the value rows themselves — admins can register a channel + /// before any scores are reported. + [HttpPost("leaderboards/meta")] + public async Task UpsertChannelMeta([FromBody] UpsertChannelMetaRequest body) + { + if (body is null) return BadRequest("missing body"); + var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == body.Channel); + if (row is null) + { + row = new LeaderboardChannelMetaEntity { Channel = body.Channel }; + db.LeaderboardChannelMeta.Add(row); + } + row.RoomId = body.RoomId; + row.Name = body.Name ?? string.Empty; + row.LowerIsBetter = body.LowerIsBetter; + row.ValueFormat = string.IsNullOrWhiteSpace(body.ValueFormat) ? "count" : body.ValueFormat; + row.UpdatedAt = DateTime.UtcNow; + await LogAsync("leaderboard_meta_upsert", "channel", body.Channel, $"room={body.RoomId} name={row.Name}"); + await db.SaveChangesAsync(); + return Ok(new { row.Channel, row.RoomId, row.Name, row.LowerIsBetter, row.ValueFormat }); + } + + /// DELETE api/admin/v1/leaderboards/meta/{channel} — + /// remove the metadata row. The channel goes back to showing as + /// "Channel N" / unscoped. Existing score rows are kept. + [HttpDelete("leaderboards/meta/{channel:int}")] + public async Task DeleteChannelMeta(int channel) + { + var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel); + if (row is null) return Ok(new { ok = true, deleted = 0 }); + db.LeaderboardChannelMeta.Remove(row); + await LogAsync("leaderboard_meta_remove", "channel", channel, ""); + await db.SaveChangesAsync(); + return Ok(new { ok = true, deleted = 1 }); + } + + /// GET api/admin/v1/leaderboards/orphans — every + /// stat channel id that has at least one score row but no meta row. + /// Used by the admin Leaderboards UI to surface "you have data for + /// channel 11 — want to give it a name and assign it to a room?". + /// Critical for figuring out which channels Stunt Runner courses + /// actually use: load a course, drive it, then orphans surfaces + /// the channel id the game just reported. + [HttpGet("leaderboards/orphans")] + public async Task LeaderboardOrphans() + { + var mapped = await db.LeaderboardChannelMeta.Select(c => c.Channel).ToListAsync(); + var rows = await db.LeaderboardStats + .Where(s => !mapped.Contains(s.StatChannel)) + .GroupBy(s => s.StatChannel) + .Select(g => new { Channel = g.Key, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) }) + .OrderByDescending(r => r.LastSeen) + .ToListAsync(); + return Ok(rows); + } + + // ── Server settings (runtime toggles) ──────────────────────────────── + + /// GET api/admin/v1/settings — current server-wide + /// toggle state. Single row, single round trip. + [HttpGet("settings")] + public async Task GetServerSettings() + { + var row = await serverSettings.GetAsync(); + return Ok(new + { + row.SignupsDisabled, + row.UpdatedAt, + }); + } + + public sealed record SignupsToggleRequest(bool Disabled); + + /// POST api/admin/v1/settings/signups — flip the + /// server-wide account-creation kill switch. While disabled, both + /// POST /account/create and POST /api/account/v1/create + /// reply with Success=false + a user-facing error string and + /// the watch surfaces that to whoever tried to sign up. Existing + /// logins are untouched. + [HttpPost("settings/signups")] + public async Task SetSignupsDisabled([FromBody] SignupsToggleRequest body) + { + var row = await serverSettings.SetSignupsDisabledAsync(body.Disabled); + await LogAsync(body.Disabled ? "signups_disabled" : "signups_enabled", "system", 0, ""); + await db.SaveChangesAsync(); + return Ok(new + { + row.SignupsDisabled, + row.UpdatedAt, + }); + } + + // ── Audit log ──────────────────────────────────────────────────────── + + [HttpGet("audit")] + public async Task GetAuditLog([FromQuery] int take = 100) + { + take = Math.Clamp(take, 1, 500); + var rows = await db.AdminActions + .OrderByDescending(a => a.Timestamp) + .Take(take) + .ToListAsync(); + return Ok(rows); + } + + // ── Reports moderation ─────────────────────────────────────────────── + + /// List unresolved player reports, oldest-first. + /// Admins triage from this queue. + [HttpGet("reports")] + public async Task ListReports([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 200); + var rows = await db.Reports + .Where(r => r.ResolvedAt == null) + .OrderBy(r => r.CreatedAt) + .Take(take) + .ToListAsync(); + return Ok(rows); + } + + public sealed record ResolveReportRequest(string? Note); + + /// Mark a report as resolved with an optional note. + /// The actual moderation action (ban, warn, dismiss) is the + /// admin's call; this endpoint only closes the report row. + [HttpPost("reports/{id:long}/resolve")] + public async Task ResolveReport(long id, [FromBody] ResolveReportRequest? body) + { + var rep = await db.Reports.FirstOrDefaultAsync(r => r.Id == id); + if (rep is null) return NotFound(); + if (rep.ResolvedAt is not null) return Ok(new { already_resolved = true }); + + rep.ResolvedAt = DateTime.UtcNow; + rep.ResolverAdminId = CurrentAdminId; + rep.ResolutionNote = (body?.Note ?? string.Empty).Trim(); + await LogAsync("resolve_report", "report", id, rep.ResolutionNote); + await db.SaveChangesAsync(); + return Ok(new { rep.Id, rep.ResolvedAt }); + } + + // ── Kick / IP ban ──────────────────────────────────────────────────── + + public sealed record KickRequest(string? Reason); + + /// Force-disconnect a player from the SignalR hub and + /// signal them to reload via a ModerationKick push. Doesn't ban + /// — the player can immediately reconnect — but boots them out + /// of whatever room/session they're in. For temporary + /// moderation actions where a full ban is too heavy. + [HttpPost("players/{id:long}/kick")] + public async Task KickPlayer(long id, [FromBody] KickRequest? body) + { + await notifications.NotifyAsync(id, PushNotificationId.ModerationKick, + new { Reason = body?.Reason ?? "" }); + await LogAsync("kick_player", "player", id, body?.Reason ?? ""); + await db.SaveChangesAsync(); + return Ok(new { kicked = id }); + } + + public sealed record IpBanRequest(string Cidr, string? Reason, int? DurationDays); + + /// Add an IP-level ban. Optional duration in days + /// (null = permanent until removed). Format: dotted IPv4 or + /// CIDR like 1.2.3.0/24. + [HttpPost("ipbans")] + public async Task AddIpBan([FromBody] IpBanRequest body) + { + if (string.IsNullOrWhiteSpace(body.Cidr)) + return BadRequest("missing cidr"); + + var until = body.DurationDays is int days + ? DateTime.UtcNow.AddDays(Math.Clamp(days, 1, 3650)) + : (DateTime?)null; + + var entry = new IpBanEntity + { + Cidr = body.Cidr.Trim(), + Reason = (body.Reason ?? string.Empty).Trim(), + BannedByAdminId = CurrentAdminId, + Until = until, + }; + db.IpBans.Add(entry); + await LogAsync("ipban_add", "ip", 0, $"{body.Cidr} until {until:o}"); + await db.SaveChangesAsync(); + return Ok(entry); + } + + [HttpDelete("ipbans/{id:long}")] + public async Task RemoveIpBan(long id) + { + var ban = await db.IpBans.FirstOrDefaultAsync(b => b.Id == id); + if (ban is null) return NotFound(); + db.IpBans.Remove(ban); + await LogAsync("ipban_remove", "ip", id, ban.Cidr); + await db.SaveChangesAsync(); + return Ok(); + } + + [HttpGet("ipbans")] + public async Task ListIpBans() + { + var rows = await db.IpBans + .OrderByDescending(b => b.BannedAt) + .ToListAsync(); + return Ok(rows); + } + + // ── Stats ──────────────────────────────────────────────────────────── + + /// Real-time server snapshot for the admin dashboard. + /// Every aggregate is one cheap query — designed to be hit by + /// the admin watch tab on a 5-second timer without melting the + /// SQLite write path. + [HttpGet("stats")] + public async Task Stats() + { + var totalPlayers = await db.Players.CountAsync(); + var totalRooms = await db.Rooms.CountAsync(); + var totalInventions = await db.Inventions.CountAsync(); + var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null); + var bannedNow = await db.Players.CountAsync(p => + p.BannedUntil != null && p.BannedUntil > DateTime.UtcNow); + var activeIpBans = await db.IpBans.CountAsync(b => + b.Until == null || b.Until > DateTime.UtcNow); + + var online = onlinePresence.OnlinePlayerIds().ToArray(); + + var topRooms = await db.Rooms + .OrderByDescending(r => r.VisitCount) + .Take(10) + .Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount }) + .ToListAsync(); + + var recentJoins = await db.Players + .OrderByDescending(p => p.CreatedAt) + .Take(10) + .Select(p => new { p.Id, p.Username, p.CreatedAt }) + .ToListAsync(); + + return Ok(new + { + Players = new + { + Total = totalPlayers, + OnlineNow = online.Length, + BannedNow = bannedNow, + }, + Rooms = new { Total = totalRooms, TopByVisits = topRooms }, + Inventions = totalInventions, + Moderation = new + { + OpenReports = openReports, + ActiveIpBans = activeIpBans, + }, + RecentJoins = recentJoins, + ServerTime = DateTime.UtcNow, + }); + } + + // ── Avatar / outfit / inventory grants ────────────────────────────── + + public sealed record OutfitRequest(string? OutfitSelections, string? FaceFeatures, string? HairColor, string? SkinColor); + + /// Get a player's current avatar so the admin UI can + /// pre-fill its edit form. Returns the same DTO shape as + /// GET api/avatar/v2/equipped/{id}. + [HttpGet("players/{id:long}/avatar")] + public async Task GetAvatar(long id) + { + var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id); + if (avatar is null) return NotFound(); + return Ok(new + { + avatar.PlayerId, + avatar.OutfitSelections, + avatar.FaceFeatures, + avatar.HairColor, + avatar.SkinColor, + avatar.UpdatedAt, + }); + } + + /// POST api/admin/v1/players/{id}/avatar/reset — + /// wipe a player's equipped outfit + inventory back to the canonical + /// 2020 starter values. Recovery path for the + /// "watch crashes after ~10 minutes" symptom where a player has an + /// item GUID (e.g. Eye_MirrorShades_PinkClear) whose + /// outfits_assets bundle isn't shipped with this build, so + /// .SpawnOutfitImposter throws a + /// Dependency Exception from Addressables and the engine eventually + /// tears down the local player. Resetting clears those entries + /// from and + /// so the next avatar + /// load only references items the local build has on disk. + [HttpPost("players/{id:long}/avatar/reset")] + public async Task ResetAvatar(long id) + { + var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id); + if (avatar is null) + { + avatar = new AvatarEntity { PlayerId = id }; + db.Avatars.Add(avatar); + } + avatar.OutfitSelections = PlayerService.StarterOutfitSelections; + avatar.InventoryJson = PlayerService.StarterInventoryJson; + avatar.HairColor = PlayerService.StarterHairColor; + avatar.SkinColor = PlayerService.StarterSkinColor; + avatar.UpdatedAt = DateTime.UtcNow; + + await LogAsync("reset_avatar", "player", id, ""); + await db.SaveChangesAsync(); + + // Push a profile update so the affected watch re-fetches its + // avatar without waiting for the next scene load. + await notifications.NotifyAsync(id, + PushNotificationId.SubscriptionUpdateProfile, + new { Reason = "AdminAvatarReset" }); + + return Ok(new + { + avatar.PlayerId, + avatar.OutfitSelections, + avatar.HairColor, + avatar.SkinColor, + }); + } + + /// Replace another player's outfit / hair / skin colour. + /// Pushes SubscriptionUpdateProfile so the affected watch + /// re-fetches its avatar and the new clothes propagate to other + /// players in the same room. + [HttpPost("players/{id:long}/avatar")] + public async Task SetAvatar(long id, [FromBody] OutfitRequest body) + { + var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id); + if (avatar is null) + { + avatar = new AvatarEntity { PlayerId = id }; + db.Avatars.Add(avatar); + } + if (body.OutfitSelections is not null) avatar.OutfitSelections = body.OutfitSelections; + if (body.FaceFeatures is not null) avatar.FaceFeatures = body.FaceFeatures; + if (body.HairColor is not null) avatar.HairColor = body.HairColor; + if (body.SkinColor is not null) avatar.SkinColor = body.SkinColor; + avatar.UpdatedAt = DateTime.UtcNow; + + await LogAsync("set_avatar", "player", id, body.OutfitSelections ?? ""); + await db.SaveChangesAsync(); + + await notifications.NotifyAsync(id, + PushNotificationId.SubscriptionUpdateProfile, + new { Reason = "AdminAvatarUpdate" }); + + return Ok(new { avatar.PlayerId, avatar.OutfitSelections, avatar.HairColor, avatar.SkinColor }); + } + + public sealed record GrantItemRequest(string ItemId, int Quantity); + + /// Append an item GUID to the player's inventory JSON list. + /// The client treats InventoryJson as the truth source for + /// what they own; once granted, the next avatar fetch shows the + /// item available in the customisation menu. + [HttpPost("players/{id:long}/inventory/grant")] + public async Task GrantItem(long id, [FromBody] GrantItemRequest body) + { + if (string.IsNullOrWhiteSpace(body.ItemId)) return BadRequest("missing itemId"); + var qty = Math.Clamp(body.Quantity, 1, 1000); + + var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id); + if (avatar is null) + { + avatar = new AvatarEntity { PlayerId = id }; + db.Avatars.Add(avatar); + } + + // Inventory wire format: JSON array of { itemId, quantity }. Merge + // by itemId so repeat grants don't duplicate rows. + var list = ParseInventory(avatar.InventoryJson); + var existing = list.FirstOrDefault(e => e.ItemId == body.ItemId); + if (existing is null) + list.Add(new InventoryEntry { ItemId = body.ItemId, Quantity = qty }); + else + existing.Quantity += qty; + avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list); + avatar.UpdatedAt = DateTime.UtcNow; + + await LogAsync("grant_item", "player", id, $"{body.ItemId} x{qty}"); + await db.SaveChangesAsync(); + + await notifications.NotifyAsync(id, + PushNotificationId.SubscriptionUpdateProfile, + new { Reason = "InventoryGrant", body.ItemId, Quantity = qty }); + + return Ok(new { granted = body.ItemId, quantity = qty }); + } + + public sealed record CurrencyRequest(int CurrencyType, long Amount, string? Reason); + + /// Adjust a player's currency wallet. Positive amounts + /// grant; negative deduct (clamped to zero by + /// ). The client's + /// next storefronts/balance fetch returns the new value. + [HttpPost("players/{id:long}/currency")] + public async Task GrantCurrency(long id, [FromBody] CurrencyRequest body) + { + var newBalance = await level.GrantCurrencyAsync( + id, body.CurrencyType, body.Amount, body.Reason ?? "admin_grant"); + await LogAsync("grant_currency", "player", id, + $"type={body.CurrencyType} amount={body.Amount}"); + await db.SaveChangesAsync(); + return Ok(new { id, body.CurrencyType, balance = newBalance }); + } + + public sealed record XpRequest(int Amount, string? Reason); + + /// Grant XP to a player. Triggers level-up rewards via + /// if the new XP crosses a + /// level threshold. + [HttpPost("players/{id:long}/xp")] + public async Task GrantXp(long id, [FromBody] XpRequest body) + { + var (lvl, xp) = await level.AwardXpAsync(id, body.Amount, body.Reason ?? "admin_grant"); + await LogAsync("grant_xp", "player", id, $"amount={body.Amount}"); + await db.SaveChangesAsync(); + return Ok(new { id, level = lvl, xp }); + } + + public sealed record ProfileFlagsRequest( + bool? IsVerified, + bool? IsDeveloper, + bool? IsJunior, + bool? IsCommunityTeam); + + /// Toggle the verified / developer / community-team / + /// junior flags on a player profile. Verified shows up as the + /// blue-checkmark badge on the watch's profile card; IsDeveloper + /// and IsCommunityTeam both unlock the watch's overhead-badge + /// slider (see ) + /// — once unlocked, the player picks "Community Team" or + /// "Developer" themselves from the in-watch settings menu and the + /// label renders above their head for every other player in the + /// room. We expose both as separate admin flags so the audit log + /// preserves the distinction even though the 2020 watch can't + /// enforce one badge over the other. + [HttpPost("players/{id:long}/flags")] + public async Task SetFlags(long id, [FromBody] ProfileFlagsRequest body) + { + var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id); + if (p is null) return NotFound(); + if (body.IsVerified is bool v) p.IsVerified = v; + if (body.IsDeveloper is bool d) p.IsDeveloper = d; + if (body.IsJunior is bool j) p.IsJunior = j; + if (body.IsCommunityTeam is bool ct) p.IsCommunityTeam = ct; + await LogAsync("set_flags", "player", id, + $"verified={p.IsVerified} dev={p.IsDeveloper} communityteam={p.IsCommunityTeam} junior={p.IsJunior}"); + await db.SaveChangesAsync(); + return Ok(new { p.Id, p.IsVerified, p.IsDeveloper, p.IsCommunityTeam, p.IsJunior }); + } + + public sealed record DisplayNameRequest(string DisplayName); + + /// Force-set a player's display name (admin override of + /// the normal username-uniqueness flow). + [HttpPost("players/{id:long}/displayName")] + public async Task SetDisplayName(long id, [FromBody] DisplayNameRequest body) + { + var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id); + if (p is null) return NotFound(); + if (string.IsNullOrWhiteSpace(body.DisplayName)) return BadRequest("empty"); + p.DisplayName = body.DisplayName.Trim(); + await LogAsync("set_display_name", "player", id, p.DisplayName); + await db.SaveChangesAsync(); + return Ok(new { p.Id, p.DisplayName }); + } + + private sealed class InventoryEntry + { + [System.Text.Json.Serialization.JsonPropertyName("itemId")] + public string ItemId { get; set; } = string.Empty; + [System.Text.Json.Serialization.JsonPropertyName("quantity")] + public int Quantity { get; set; } + } + + private static List ParseInventory(string? json) + { + if (string.IsNullOrWhiteSpace(json) || json == "[]") return new(); + try + { + return System.Text.Json.JsonSerializer.Deserialize>(json) ?? new(); + } + catch + { + return new(); + } + } + + // ── Avatar catalog (gifting picker source) ─────────────────────────── + + /// GET api/admin/v1/avatar-items — flat list of every + /// renderable avatar item, keyed by the GUID the inventory grant + /// endpoint expects. Backs the Gift page's item picker so admins can + /// browse by friendly name instead of pasting GUIDs by hand. Read-only. + /// Catalog source is data/avatar_item_lookup.json, parsed once + /// at startup by . + [HttpGet("avatar-items")] + public ActionResult ListAvatarItems() + { + var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog; + var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids; + var rows = catalog + .Select(kv => new + { + Guid = kv.Key, + Slot = kv.Value.AvatarItemType, + FriendlyName = kv.Value.FriendlyName, + Tooltip = kv.Value.Tooltip, + Rarity = kv.Value.Rarity, + Safe = safe.Contains(kv.Key), + }) + .Where(r => r.Safe) // hide GUIDs that would crash the watch's parser + .OrderBy(r => r.Slot) + .ThenBy(r => r.FriendlyName) + .ToList(); + return Ok(rows); + } + + // ── Send gift package (in-game gift box popup) ──────────────────────── + + public sealed record SendGiftRequest( + string? AvatarItemGuid, + int? AvatarItemType, // 0 = Outfit, 1 = HairDye (per RecNet.Avatars+AvatarItemType) + int? CurrencyType, // 1 = Tokens, 2 = Coins (0 = none) + int? Currency, + int? Xp, + int? Level, + string? Message, + int? Rarity); // 0 Common, 10 Uncommon, 20 Rare, 30 Epic, 50 Legendary + + /// POST api/admin/v1/players/{id}/gift — drop a + /// into the recipient's gift inbox. + /// The watch's RecNet.Avatars.DowloadGiftPackages loop picks + /// it up on the next poll and shows the standard gift-box popup; + /// the player taps "open" to fire LocalConsumeGiftPackage, + /// which hits + /// and finally writes the avatar item into InventoryJson. + /// We DO NOT touch InventoryJson here — that bypasses the gift + /// flow and the player never sees the popup. + /// + /// Also pushes a + /// over SignalR so an online player sees the toast immediately + /// without waiting for the next /gifts poll. + [HttpPost("players/{id:long}/gift")] + public async Task SendGift(long id, [FromBody] SendGiftRequest body) + { + var recipient = await db.Players.FirstOrDefaultAsync(p => p.Id == id); + if (recipient is null) return NotFound(); + + var hasItem = !string.IsNullOrWhiteSpace(body.AvatarItemGuid); + var hasCurrency = body.CurrencyType is int ct && ct > 0 && body.Currency is int c && c > 0; + var hasXp = body.Xp is int xp && xp > 0; + var hasLevel = body.Level is int lvl && lvl > 0; + if (!hasItem && !hasCurrency && !hasXp && !hasLevel) + return BadRequest(new { error = "empty_gift", message = "Provide at least one of: avatar item, currency, XP, level." }); + + // Wire shape MUST match the working /api/avatar/v2/gifts/generate + // flow exactly. The watch's ParseGiftPackageReceived + // (Avatars.txt:616) gates on IsValid + SupportsCurrentPlatform, + // then runs the gift through GiftManager. Two deviations from + // /generate were breaking us: + // 1. FromPlayerId set to the admin's id — the watch's P2P-gift + // rendering path looks up sender info; if that lookup + // doesn't return what the popup renderer expects, the toast + // still fires from the SignalR push but the box never + // materializes. /generate uses null; we match. + // 2. IsGifted = true tagged it as a player-to-player gift, + // which routes through a different code path entirely + // (separate inbox / different popup). /generate leaves it + // at the default false; we match. + // CRITICAL invariants for AvatarItemDescOrHairDyeDesc, both + // verified against the in-game crash trace: + // + // 1. AvatarItemType MUST be null when no avatar item is in the + // gift. Setting it to 0 (Outfit) with an empty desc routes + // GiftManager.DequeueGift past the HasAvatarItemOrHairDye + // gate into OutfitManager.MarkGiftPackageItemAvailableOnCurrentPlatform("") + // → AvatarItem.FromRecNetString("") → "empty RecNet string" + // AvatarParseException. + // + // 2. When there IS an item, AvatarItemDescOrHairDyeDesc must + // have at least 4 comma-separated parts. The client's + // FromRecNetString(string) splits on `,` then constructs + // ArraySegment(parts, 1, 3) — parts.Length < 4 triggers + // ArgumentException at ArraySegmentEnumerator.get_Current + // (output_log.txt:1567). Bare GUIDs crash; we suffix `,,,` + // for three empty default customisations. See + // AvatarItemsController.cs:31-40 for the canonical comment. + var gift = new GiftPackageEntity + { + RecipientPlayerId = id, + FromPlayerId = null, + AvatarItemType = hasItem ? (body.AvatarItemType ?? 0) : (int?)null, + AvatarItemDescOrHairDyeDesc = hasItem + ? NormalizeAvatarItemDesc(body.AvatarItemGuid!.Trim()) + : string.Empty, + CurrencyType = hasCurrency ? body.CurrencyType!.Value : 0, + Currency = hasCurrency ? body.Currency!.Value : 0, + Xp = hasXp ? body.Xp!.Value : 0, + Level = hasLevel ? body.Level!.Value : 0, + GiftContext = 0, + GiftRarity = body.Rarity ?? 0, + Message = string.IsNullOrWhiteSpace(body.Message) + ? "A gift from the admins." + : body.Message.Trim(), + Platform = -1, + PackageVariant = "Standard", + PackageMaterial = string.Empty, + IsValid = true, + SupportsCurrentPlatform = true, + }; + db.GiftPackages.Add(gift); + await LogAsync("send_gift", "player", id, + $"item={(hasItem ? gift.AvatarItemDescOrHairDyeDesc : "-")} " + + $"currency={(hasCurrency ? $"{gift.CurrencyType}×{gift.Currency}" : "-")} " + + $"xp={gift.Xp} level={gift.Level}"); + await db.SaveChangesAsync(); + + // GiftPackageReceivedImmediate (31), NOT GiftPackageReceived (30). + // GiftManager.OnGiftPackageReceivedEvent (GiftManager.txt:501) takes + // a `showImmediately` bool — true for id 31, false for id 30. When + // false the handler returns *without* calling ReceiveGifts, so no + // box pops. Admin gifts want the box now, not "next time you open + // the gift inbox", so push 31. The watch's RecNet.Avatars handler + // registers BOTH ids (Avatars.txt:177 + 192), and 31 is the only + // one that ends up invoking the GiftManager popup path. + await notifications.NotifyAsync(id, + PushNotificationId.GiftPackageReceivedImmediate, + new + { + gift.Id, + gift.FromPlayerId, + gift.AvatarItemType, + AvatarItemDesc = gift.AvatarItemDescOrHairDyeDesc, + gift.AvatarItemDescOrHairDyeDesc, + gift.EquipmentPrefabName, + gift.EquipmentModificationGuid, + gift.CurrencyType, + gift.Currency, + gift.Xp, + gift.Level, + gift.GiftContext, + gift.GiftRarity, + gift.Message, + gift.Platform, + gift.PackageMaterial, + gift.PackageVariant, + gift.Consumed, + gift.IsValid, + gift.ErrorMessage, + gift.SupportsCurrentPlatform, + gift.IsGifted, + }); + + return Ok(new { gift.Id, gift.RecipientPlayerId }); + } + + /// Pad a bare avatar item GUID with three empty default + /// customisations so the watch's AvatarItem.FromRecNetString + /// doesn't crash on ArraySegment(parts, 1, 3) when the + /// string only contains the GUID. Idempotent — descs that already + /// have at least 4 comma-separated parts are returned untouched. + private static string NormalizeAvatarItemDesc(string descOrGuid) + { + if (string.IsNullOrWhiteSpace(descOrGuid)) return string.Empty; + var commaCount = 0; + foreach (var ch in descOrGuid) if (ch == ',') commaCount++; + return commaCount >= 3 ? descOrGuid : descOrGuid + new string(',', 3 - commaCount); + } + + // ── Pending gift inbox (clean-up) ──────────────────────────────────── + + /// GET api/admin/v1/players/{id}/gifts — list the + /// recipient's pending (unconsumed) gift packages. Used by the admin + /// Gift page to surface broken / abandoned gifts that the watch + /// can't render so we can delete them. Includes consumed ones too + /// in case we want to audit what's been opened. + [HttpGet("players/{id:long}/gifts")] + public async Task ListPlayerGifts(long id, [FromQuery] bool includeConsumed = false) + { + IQueryable q = db.GiftPackages.Where(g => g.RecipientPlayerId == id); + if (!includeConsumed) q = q.Where(g => !g.Consumed); + var rows = await q + .OrderByDescending(g => g.CreatedAt) + .Take(200) + .Select(g => new + { + g.Id, + g.RecipientPlayerId, + g.FromPlayerId, + g.AvatarItemType, + g.AvatarItemDescOrHairDyeDesc, + g.CurrencyType, + g.Currency, + g.Xp, + g.Level, + g.GiftContext, + g.GiftRarity, + g.Message, + g.Consumed, + g.ConsumedAt, + g.IsValid, + g.IsGifted, + g.CreatedAt, + }) + .ToListAsync(); + return Ok(rows); + } + + /// DELETE api/admin/v1/gifts/{id} — hard-delete a + /// gift package row. Use when a gift is broken (wrong shape, won't + /// render its box in the watch) or was a mis-fire from a test send. + /// Does not affect the player's actual inventory if they'd already + /// consumed it — that write happened during consume and is final. + [HttpDelete("gifts/{id:long}")] + public async Task DeleteGift(long id) + { + var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id); + if (gift is null) return NotFound(); + var recipient = gift.RecipientPlayerId; + db.GiftPackages.Remove(gift); + await LogAsync("delete_gift", "gift", id, $"recipient={recipient} consumed={gift.Consumed}"); + await db.SaveChangesAsync(); + return Ok(new { deleted = id }); + } + + /// POST api/admin/v1/players/{id}/gifts/clear — + /// nuke every unconsumed gift for a player. Useful after a burst of + /// broken admin-test gifts to reset the inbox to a clean state. + /// Consumed gifts are left alone (they're history, not state). + [HttpPost("players/{id:long}/gifts/clear")] + public async Task ClearPlayerGifts(long id) + { + var pending = await db.GiftPackages + .Where(g => g.RecipientPlayerId == id && !g.Consumed) + .ToListAsync(); + db.GiftPackages.RemoveRange(pending); + await LogAsync("clear_gifts", "player", id, $"removed={pending.Count}"); + await db.SaveChangesAsync(); + return Ok(new { removed = pending.Count }); + } + + // ── Password reset (admin override) ────────────────────────────────── + + public sealed record SetPasswordRequest(string NewPassword); + + /// Admin-set a new password for any account. Hashes with + /// BCrypt and persists onto . + /// The audit row records the action but never the plaintext. + /// Refuses passwords shorter than 8 chars — same floor as the + /// self-service flow. + [HttpPost("players/{id:long}/password")] + public async Task SetPassword(long id, [FromBody] SetPasswordRequest body) + { + if (string.IsNullOrWhiteSpace(body.NewPassword) || body.NewPassword.Length < 8) + return BadRequest(new { error = "password_too_short", min = 8 }); + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id); + if (player is null) return NotFound(); + player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword); + await LogAsync("set_password", "player", id, ""); + await db.SaveChangesAsync(); + // Push a logout so any active sessions on the old password get + // dropped — the watch re-authenticates with the new password. + await notifications.NotifyAsync(id, PushNotificationId.Logout, + new { Reason = "AdminPasswordReset" }); + return Ok(new { id }); + } + + // ── Live instance viewer ───────────────────────────────────────────── + + /// GET api/admin/v1/instances — list every active room + /// instance an online player is currently sitting in. Joins the live + /// online-player set from against + /// (which holds the last-known + /// per player, TTL'd to 45 seconds) and + /// groups by RoomInstanceId. Empty list when no one is online. + [HttpGet("instances")] + public async Task ListInstances() + { + var onlineIds = onlinePresence.OnlinePlayerIds().ToHashSet(); + if (onlineIds.Count == 0) return Ok(Array.Empty()); + + // Bulk-pull names so we can render usernames per participant. + var nameMap = await db.Players + .Where(p => onlineIds.Contains(p.Id)) + .Select(p => new { p.Id, p.Username, p.DisplayName }) + .ToDictionaryAsync(p => p.Id, p => new { p.Username, p.DisplayName }); + + var roomNameMap = await db.Rooms + .Select(r => new { r.Id, r.Name }) + .ToDictionaryAsync(r => r.Id, r => r.Name); + + var grouped = new Dictionary(); + foreach (var pid in onlineIds) + { + var room = playerPresence.GetRoom(pid); + if (room is null) continue; + if (!grouped.TryGetValue(room.RoomInstanceId, out var g)) + { + g = new InstanceGroup + { + RoomInstanceId = room.RoomInstanceId, + RoomId = room.RoomId, + SubRoomId = room.SubRoomId, + RoomName = roomNameMap.GetValueOrDefault(room.RoomId, $"#{room.RoomId}"), + PhotonRoomId = room.PhotonRoomId, + PhotonRegionId = room.PhotonRegionId, + Location = room.Location, + MaxCapacity = room.MaxCapacity, + IsPrivate = room.IsPrivate, + Participants = new(), + }; + grouped[room.RoomInstanceId] = g; + } + nameMap.TryGetValue(pid, out var nm); + g.Participants.Add(new InstanceParticipant + { + Id = pid, + Username = nm?.Username ?? $"#{pid}", + DisplayName = nm?.DisplayName ?? $"#{pid}", + }); + } + + return Ok(grouped.Values + .OrderByDescending(g => g.Participants.Count) + .ThenBy(g => g.RoomName)); + } + + private sealed class InstanceGroup + { + public long RoomInstanceId { get; set; } + public long RoomId { get; set; } + public long SubRoomId { get; set; } + public string RoomName { get; set; } = ""; + public string PhotonRoomId { get; set; } = ""; + public string PhotonRegionId { get; set; } = ""; + public string Location { get; set; } = ""; + public int MaxCapacity { get; set; } + public bool IsPrivate { get; set; } + public List Participants { get; set; } = new(); + } + + private sealed class InstanceParticipant + { + public long Id { get; set; } + public string Username { get; set; } = ""; + public string DisplayName { get; set; } = ""; + } + + // ── Force-join (experimental) ──────────────────────────────────────── + + /// + /// Force-join body. RoomInstanceId + PhotonRoomId are + /// optional — when omitted, the watch lands in a fresh matchmade + /// instance of the room. When provided (from the Instances browser + /// → "send into this match" flow), we target that exact Photon room + /// so the admin can drop a player into a specific public or private + /// instance other players are already in. + /// + public sealed record ForceJoinRequest( + long RoomId, + long? SubRoomId, + long? RoomInstanceId, + string? PhotonRoomId, + string? PhotonRegionId); + + /// POST api/admin/v1/players/{id}/forcejoin — rewrite + /// the target player's entry to + /// the destination room (and optionally a specific Photon instance), + /// then push SubscriptionUpdateGameSession + SubscriptionUpdateRoom + /// so the watch's cached RoomInstance + RoomDetails match the new + /// server-side state. The watch's + /// OnPresenceHeartbeatResponse detects the change on its + /// next heartbeat and refreshes its cache; pair with + /// POST players/{id}/kick to also drop the player out of + /// their current Photon match so they re-matchmake into the new + /// target on reconnect. + /// + /// When RoomInstanceId + PhotonRoomId are supplied, + /// the player lands in that EXACT instance (public or private). Use + /// the Instances browser to enumerate available Photon rooms and + /// pick one. With both fields omitted, a fresh deterministic + /// instance id is used so the player matchmakes normally. + [HttpPost("players/{id:long}/forcejoin")] + public async Task ForceJoin(long id, [FromBody] ForceJoinRequest body) + { + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id); + if (player is null) return NotFound(); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == body.RoomId); + if (room is null) return NotFound(new { error = "room_not_found" }); + + // Build the destination RoomInstance. When the admin picked a + // specific instance from the Instances browser, RoomInstanceId + // + PhotonRoomId arrive together — use them verbatim so the + // player lands in THAT match, not a fresh one. Otherwise mint + // deterministic defaults that match what /goto/room would + // produce for a first join (roomId*100k + subId base; bare + // ^roomname_id photon room name). + var subRoomId = body.SubRoomId ?? 0; + var photonRoomId = !string.IsNullOrEmpty(body.PhotonRoomId) + ? body.PhotonRoomId + : $"^{room.Name.ToLowerInvariant()}_{room.Id}"; + var roomInstanceId = body.RoomInstanceId + ?? (room.Id * 100_000L + subRoomId); + var photonRegionId = !string.IsNullOrEmpty(body.PhotonRegionId) + ? body.PhotonRegionId + : "us"; + + var roomInstance = new DorkNet.Server.Controllers.Match.RoomInstanceDto + { + RoomInstanceId = roomInstanceId, + RoomId = room.Id, + SubRoomId = subRoomId, + Location = room.LocationReplicationId, + PhotonRegionId = photonRegionId, + PhotonRoomId = photonRoomId, + Name = room.Name, + MaxCapacity = 8, + IsFull = false, + IsPrivate = false, + IsInProgress = true, + }; + + playerPresence.SetRoom(id, roomInstance); + + // Two pushes back-to-back: GameSession update changes the + // watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance + // → UpdateCachedRoomInstance), and SubscriptionUpdateRoom + // refreshes the watch's RoomDetails cache so the room-info + // panel doesn't show stale data after the warp. + await notifications.NotifyAsync(id, + PushNotificationId.SubscriptionUpdateGameSession, roomInstance); + await notifications.NotifyAsync(id, + PushNotificationId.SubscriptionUpdateRoom, + new + { + Room = new { Id = room.Id, Name = room.Name }, + RoomId = room.Id, + SubRoomId = subRoomId, + Reason = "AdminForceJoin", + }); + + await LogAsync("force_join", "player", id, + $"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}"); + await db.SaveChangesAsync(); + return Ok(new + { + id, + roomId = body.RoomId, + subRoomId, + roomInstanceId, + photonRoomId, + }); + } + + // ── System ─────────────────────────────────────────────────────────── + + public sealed record SetMotdRequest(string Message); + + /// Push a server-maintenance broadcast to every connected + /// player. The MOTD config value is changed via appsettings.json + /// (server restart required); this is for ad-hoc announcements. + [HttpPost("broadcast")] + public async Task Broadcast([FromBody] SetMotdRequest body) + { + if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message"); + await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance, + new { Message = body.Message }); + await LogAsync("broadcast", "system", 0, body.Message); + await db.SaveChangesAsync(); + return Ok(); + } + + // ── Store catalog management ───────────────────────────────────────── + + /// Canonical storefront keys the admin UI can assign to + /// store items. Exact giftdrop:N keys target a single + /// in-room shelf; rro and all are shared groups. + [HttpGet("storefronts")] + public ActionResult ListStorefronts() => + Ok(StoreService.GetStorefrontDefinitions().Select(s => new + { + s.Key, + s.StorefrontType, + s.DisplayName, + s.Scope, + })); + + /// List every item in the catalog (including inactive + /// rows) so the admin UI can render the full catalog and let an + /// admin flip flags / edit prices. + [HttpGet("storeitems")] + public async Task ListStoreItems( + [FromQuery] string? storefront, [FromQuery] string? category, + [FromQuery] int take = 200, [FromQuery] int skip = 0) + { + take = Math.Clamp(take, 1, 500); + skip = Math.Max(0, skip); + IQueryable q = db.StoreItems; + if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront); + if (!string.IsNullOrWhiteSpace(category)) q = q.Where(i => i.Category == category); + var rows = await q.OrderBy(i => i.Storefront).ThenBy(i => i.Category).ThenBy(i => i.Price) + .Skip(skip).Take(take).ToListAsync(); + return Ok(rows); + } + + public sealed record StoreItemUpsertRequest( + string? Slug, string? DisplayName, string? Description, + string? Category, string? ImageName, int? CurrencyType, + long? Price, bool? IsActive, bool? IsLimitedTime, + DateTime? AvailableUntil, string? Storefront); + + /// Create a new catalog item. + [HttpPost("storeitems")] + public async Task CreateStoreItem([FromBody] StoreItemUpsertRequest body) + { + if (string.IsNullOrWhiteSpace(body.Slug)) return BadRequest("missing_slug"); + if (string.IsNullOrWhiteSpace(body.DisplayName)) return BadRequest("missing_display_name"); + var existing = await db.StoreItems.AnyAsync(i => i.Slug == body.Slug); + if (existing) return Conflict(new { error = "slug_taken" }); + var item = new StoreItemEntity + { + Slug = body.Slug.Trim(), + DisplayName = body.DisplayName.Trim(), + Description = (body.Description ?? string.Empty).Trim(), + Category = (body.Category ?? "accessory").Trim().ToLowerInvariant(), + ImageName = body.ImageName ?? string.Empty, + CurrencyType = body.CurrencyType ?? 2, + Price = Math.Max(0, body.Price ?? 0), + IsActive = body.IsActive ?? true, + IsLimitedTime = body.IsLimitedTime ?? false, + AvailableUntil = body.AvailableUntil, + Storefront = (body.Storefront ?? "main").Trim(), + }; + db.StoreItems.Add(item); + await LogAsync("create_store_item", "storeitem", 0, item.Slug); + await db.SaveChangesAsync(); + return Ok(item); + } + + /// Patch an existing catalog item — any null field is + /// left untouched so the admin UI can do partial edits. + [HttpPost("storeitems/{id:long}")] + [HttpPut("storeitems/{id:long}")] + public async Task UpdateStoreItem(long id, [FromBody] StoreItemUpsertRequest body) + { + var item = await db.StoreItems.FirstOrDefaultAsync(i => i.Id == id); + if (item is null) return NotFound(); + if (body.DisplayName is not null) item.DisplayName = body.DisplayName.Trim(); + if (body.Description is not null) item.Description = body.Description.Trim(); + if (body.Category is not null) item.Category = body.Category.Trim().ToLowerInvariant(); + if (body.ImageName is not null) item.ImageName = body.ImageName; + if (body.CurrencyType is int ct) item.CurrencyType = ct; + if (body.Price is long p) item.Price = Math.Max(0, p); + if (body.IsActive is bool a) item.IsActive = a; + if (body.IsLimitedTime is bool lt) item.IsLimitedTime = lt; + if (body.AvailableUntil.HasValue) item.AvailableUntil = body.AvailableUntil; + if (body.Storefront is not null) item.Storefront = body.Storefront.Trim(); + item.UpdatedAt = DateTime.UtcNow; + await LogAsync("update_store_item", "storeitem", id, item.Slug); + await db.SaveChangesAsync(); + return Ok(item); + } + + /// Delete a catalog item. Soft-flag is recommended via + /// IsActive=false; hard delete is here for cleanup of test + /// items. + [HttpDelete("storeitems/{id:long}")] + public async Task DeleteStoreItem(long id) + { + var item = await db.StoreItems.FirstOrDefaultAsync(i => i.Id == id); + if (item is null) return NotFound(); + db.StoreItems.Remove(item); + await LogAsync("delete_store_item", "storeitem", id, item.Slug); + await db.SaveChangesAsync(); + return Ok(new { deleted = id }); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private Task LogAsync(string action, string targetType, long targetId, string reason) + { + db.AdminActions.Add(new AdminActionEntity + { + AdminPlayerId = CurrentAdminId, + Action = action, + TargetType = targetType, + TargetId = targetId, + Reason = reason, + }); + return Task.CompletedTask; + } + + // ── Community Board ────────────────────────────────────────────── + // Backs the dorm community board panel. The watch hits + // /api/communityboard/v1/current; admins edit here. + + /// GET the current board state. Same shape the watch + /// receives — handy for the admin UI to populate its form. + [HttpGet("communityboard")] + public async Task> GetCommunityBoard() + => Ok(await communityBoard.GetAsync()); + + /// POST a full replacement state. Any null nested objects + /// (FeaturedPlayer / FeaturedRoomGroup / CurrentAnnouncement) + /// clear that section. Lists default to empty if omitted. + [HttpPost("communityboard")] + public async Task> SetCommunityBoard( + [FromBody] CommunityBoardState body) + { + body.InstagramImages ??= new(); + body.Videos ??= new(); + var saved = await communityBoard.UpdateAsync(body); + await LogAsync("set_community_board", "global", 0, + $"announcement=\"{saved.CurrentAnnouncement?.Message ?? ""}\" " + + $"featuredPlayer={saved.FeaturedPlayer?.Id} " + + $"videos={saved.Videos.Count} instagram={saved.InstagramImages.Count}"); + return Ok(saved); + } + + /// POST api/admin/v1/communityboard/instagram/upload + /// — accept a single image file, persist it as a global + /// at RoomId=0 under a + /// generated hash filename, return the filename + a ready-to-paste + /// img.* URL. The Community Board SPA section pipes a file picker + /// into this so admins don't have to know what an + /// ImageName is — they just drop a JPG/PNG and we wire it up. + /// + /// Cap: 25 MB. Anything larger is almost certainly a mistake for an + /// Instagram-strip tile and would also push us close to the CF edge + /// limit on a non-chunked upload. + [HttpPost("communityboard/instagram/upload")] + [DisableRequestSizeLimit] + [RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)] + public async Task UploadInstagramImage([FromForm(Name = "file")] IFormFile? file) + { + if (file is null || file.Length == 0) + return BadRequest(new { error = "missing_file" }); + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp")) + return BadRequest(new { error = "unsupported_extension", allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" } }); + + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + var bytes = ms.ToArray(); + + // Hashed filename so re-uploads of the same content don't + // proliferate and so the URL is content-addressed. Prefix + // `cb_insta_` to keep these distinguishable from room-image + // blobs at a glance when grepping the table. + using var sha = System.Security.Cryptography.SHA1.Create(); + var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant(); + var imageName = $"cb_insta_{hash[..24]}{ext}"; + + var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName); + if (existing is null) + { + var (bucket, key) = BlobRouter.Route(imageName); + await storage.PutAsync(bucket, key, bytes, + ext == ".png" ? "image/png" : "image/jpeg"); + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = 0, + BlobName = imageName, + UploadedByPlayerId = CurrentAdminId, + UploadedAt = DateTime.UtcNow, + }); + await LogAsync("cb_insta_upload", "community_board", 0, $"image={imageName} bytes={bytes.Length}"); + await db.SaveChangesAsync(); + } + + // Use the configured apex (DomainConfig) so the returned URL + // works whether the admin is on localhost, rec.net, or a future + // deployment apex — driven solely by the DORKNET_DOMAIN env var. + return Ok(new + { + imageName, + imageUrl = $"https://{domain.Sub("img")}/{imageName}", + bytes = bytes.Length, + reused = existing is not null, + }); + } + + /// POST api/admin/v1/communityboard/video/upload — + /// accept a single video file and persist it as a global + /// at RoomId=0 under a hashed + /// cb_video_* name. The board's Videos section binds the + /// returned blobName straight into VideoState.BlobName + /// so the watch's CommunityBoardVideoData resolves it to + /// cdn.{apex}/<blobName>. + /// + /// Cap: 100 MB — picks the same ceiling Cloudflare enforces at the + /// edge so a non-chunked POST never sees a 413. Anything bigger + /// should be hosted externally and pasted into SourceUrl. + // [RequestSizeLimit] alone is enough — pairing it with + // [DisableRequestSizeLimit] was undefined-behaviour territory + // (Microsoft docs explicitly say "don't combine them"). Setting + // 100 MB at the action keeps Kestrel from clipping the body at its + // 30 MB default while still capping uploads under Cloudflare's + // free-tier edge limit. + [HttpPost("communityboard/video/upload")] + [RequestFormLimits(MultipartBodyLengthLimit = 100_000_000, ValueLengthLimit = 100_000_000)] + [RequestSizeLimit(100_000_000)] + public async Task UploadCommunityBoardVideo([FromForm(Name = "file")] IFormFile? file) + { + if (file is null || file.Length == 0) + return BadRequest(new { error = "missing_file" }); + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (ext is not (".mp4" or ".webm" or ".mov" or ".m4v")) + return BadRequest(new { + error = "unsupported_extension", + allowed = new[] { ".mp4", ".webm", ".mov", ".m4v" }, + }); + + var declaredSize = file.Length; + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + var bytes = ms.ToArray(); + + // Catch the "Kestrel/Cloudflare clipped the body" case BEFORE + // we hash + write — IFormFile.Length reports what the multipart + // parser saw in the part header, so a mismatch with how many + // bytes the stream actually produced is a transport-level + // truncation and the resulting blob would be the corrupted file + // the user saw on download. + if (bytes.LongLength != declaredSize) + { + adminLogger.LogError( + "[cb-video-upload] size mismatch — multipart says {Declared:N0} bytes, stream produced {Read:N0} bytes (file={Name})", + declaredSize, bytes.LongLength, file.FileName); + return BadRequest(new { + error = "upload_truncated", + declaredBytes = declaredSize, + receivedBytes = bytes.LongLength, + }); + } + + // Content-addressed name keeps re-uploads from proliferating — + // identical bytes resolve to the same row + S3 object regardless + // of original filename. `cb_video_` prefix keeps it greppable. + using var sha = System.Security.Cryptography.SHA1.Create(); + var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant(); + var blobName = $"cb_video_{hash[..24]}{ext}"; + var (bucket, key) = BlobRouter.Route(blobName); + + // We were previously skipping S3 PUT when the DB row already + // existed. That created a zombie state: if the first upload's + // PUT silently failed (Garage outage, bad creds) the row was + // committed but the bytes weren't, and every subsequent + // upload of the same content returned `reused: true` without + // ever fixing the missing object. Verify S3 has the object; + // re-PUT if it doesn't. + var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName); + var alreadyOnS3 = existing is not null && await storage.ExistsAsync(bucket, key); + + if (!alreadyOnS3) + { + await storage.PutAsync(bucket, key, bytes, MimeFromExt(ext)); + // Post-PUT HEAD: Garage occasionally accepts a PutObject + // and returns 200 without persisting (auth misconfig, + // bucket policy reject). HeadObject is the cheapest way to + // confirm the bytes are actually queryable before we + // pretend the upload succeeded. + if (!await storage.ExistsAsync(bucket, key)) + { + adminLogger.LogError( + "[cb-video-upload] PUT returned OK but HEAD says {Bucket}/{Key} missing — Garage may be silently dropping", + bucket, key); + return StatusCode(500, new { error = "s3_put_lost", bucket, key }); + } + adminLogger.LogInformation( + "[cb-video-upload] stored {Bucket}/{Key} ({Bytes:N0} bytes, ext={Ext})", + bucket, key, bytes.LongLength, ext); + } + if (existing is null) + { + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = 0, + BlobName = blobName, + UploadedByPlayerId = CurrentAdminId, + UploadedAt = DateTime.UtcNow, + }); + await LogAsync("cb_video_upload", "community_board", 0, $"video={blobName} bytes={bytes.Length}"); + await db.SaveChangesAsync(); + } + + return Ok(new + { + blobName, + videoUrl = $"https://{domain.Sub("cdn")}/video/{blobName}", + bytes = bytes.Length, + reused = existing is not null && alreadyOnS3, + repaired = existing is not null && !alreadyOnS3, + }); + } + + /// POST api/admin/v1/communityboard/videothumb/upload + /// — accept a single image and persist it under a hashed + /// cb_thumb_* name for use as 's + /// ThumbnailBlobName. Mechanically identical to the Instagram + /// upload endpoint, just with a different prefix so thumbnail vs. + /// gallery images are distinguishable at a glance in the blob table. + /// + /// Cap: 25 MB. The watch renders thumbnails at small sizes; bigger + /// is wasted bandwidth. + [HttpPost("communityboard/videothumb/upload")] + [DisableRequestSizeLimit] + [RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)] + public async Task UploadCommunityBoardVideoThumbnail( + [FromForm(Name = "file")] IFormFile? file) + { + if (file is null || file.Length == 0) + return BadRequest(new { error = "missing_file" }); + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp")) + return BadRequest(new { + error = "unsupported_extension", + allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" }, + }); + + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + var bytes = ms.ToArray(); + + using var sha = System.Security.Cryptography.SHA1.Create(); + var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant(); + var blobName = $"cb_thumb_{hash[..24]}{ext}"; + + var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName); + if (existing is null) + { + var (bucket, key) = BlobRouter.Route(blobName); + await storage.PutAsync(bucket, key, bytes, + ext == ".png" ? "image/png" : ext == ".webp" ? "image/webp" : "image/jpeg"); + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = 0, + BlobName = blobName, + UploadedByPlayerId = CurrentAdminId, + UploadedAt = DateTime.UtcNow, + }); + await LogAsync("cb_videothumb_upload", "community_board", 0, $"thumb={blobName} bytes={bytes.Length}"); + await db.SaveChangesAsync(); + } + + return Ok(new + { + blobName, + imageUrl = $"https://{domain.Sub("img")}/{blobName}", + bytes = bytes.Length, + reused = existing is not null, + }); + } + + // MIME mapping for the video upload — the CDN serves whatever + // Content-Type we wrote to S3, so getting this right makes the + // watch's HTTPVideoStreamPlayer accept the response without + // sniffing. Default to mp4 since that's what most modern exporters + // produce; unknown extensions fall back to octet-stream which the + // watch usually still handles but is worth flagging. + private static string MimeFromExt(string ext) => ext switch + { + ".mp4" => "video/mp4", + ".m4v" => "video/mp4", + ".webm" => "video/webm", + ".mov" => "video/quicktime", + _ => "application/octet-stream", + }; + + // ── Loading screen tips ────────────────────────────────────────────── + + public sealed class LoadingScreenTipDto + { + public long? Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string ImageName { get; set; } = string.Empty; + public int Context { get; set; } + public int PlatformMask { get; set; } = -1; + public string RoomNamesCsv { get; set; } = string.Empty; + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; + } + + /// GET api/admin/v1/loadingscreentips — list every + /// tip (including inactive ones) for the admin SPA. + [HttpGet("loadingscreentips")] + public async Task ListLoadingScreenTips() + { + var rows = await db.LoadingScreenTips + .OrderBy(t => t.SortOrder).ThenBy(t => t.Id) + .AsNoTracking() + .ToListAsync(); + return Ok(rows.Select(ToLoadingScreenTipDto)); + } + + /// POST api/admin/v1/loadingscreentips — create + /// a new tip. Returns the persisted row including its assigned + /// Id so the SPA can update its local state. + [HttpPost("loadingscreentips")] + public async Task CreateLoadingScreenTip([FromBody] LoadingScreenTipDto body) + { + var entity = new Data.Entities.LoadingScreenTipEntity + { + Title = (body.Title ?? string.Empty).Trim(), + Message = (body.Message ?? string.Empty).Trim(), + ImageName = (body.ImageName ?? string.Empty).Trim(), + Context = body.Context, + PlatformMask = body.PlatformMask, + RoomNamesCsv = NormaliseCsv(body.RoomNamesCsv), + SortOrder = body.SortOrder, + IsActive = body.IsActive, + }; + db.LoadingScreenTips.Add(entity); + await db.SaveChangesAsync(); + await LogAsync("create_loading_tip", "global", entity.Id, entity.Title); + return Ok(ToLoadingScreenTipDto(entity)); + } + + /// PUT api/admin/v1/loadingscreentips/{id} — update + /// an existing tip. + [HttpPut("loadingscreentips/{id:long}")] + public async Task UpdateLoadingScreenTip(long id, [FromBody] LoadingScreenTipDto body) + { + var row = await db.LoadingScreenTips.FirstOrDefaultAsync(t => t.Id == id); + if (row is null) return NotFound(); + row.Title = (body.Title ?? string.Empty).Trim(); + row.Message = (body.Message ?? string.Empty).Trim(); + row.ImageName = (body.ImageName ?? string.Empty).Trim(); + row.Context = body.Context; + row.PlatformMask = body.PlatformMask; + row.RoomNamesCsv = NormaliseCsv(body.RoomNamesCsv); + row.SortOrder = body.SortOrder; + row.IsActive = body.IsActive; + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + await LogAsync("update_loading_tip", "global", id, row.Title); + return Ok(ToLoadingScreenTipDto(row)); + } + + /// DELETE api/admin/v1/loadingscreentips/{id} — hard + /// delete a tip. (Toggling IsActive=false via the update + /// endpoint is the soft-delete path.) + [HttpDelete("loadingscreentips/{id:long}")] + public async Task DeleteLoadingScreenTip(long id) + { + var row = await db.LoadingScreenTips.FirstOrDefaultAsync(t => t.Id == id); + if (row is null) return NotFound(); + db.LoadingScreenTips.Remove(row); + await db.SaveChangesAsync(); + await LogAsync("delete_loading_tip", "global", id, row.Title); + return Ok(new { deleted = id }); + } + + /// POST api/admin/v1/loadingscreentips/upload — + /// accept an image file (jpg/png/webp), persist it via the same + /// content-addressed flow as the Community Board uploader, and + /// return the BlobName + img.* URL. The SPA pipes this into the + /// tip's ImageName field. + [HttpPost("loadingscreentips/upload")] + [DisableRequestSizeLimit] + [RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)] + public async Task UploadLoadingScreenTipImage([FromForm(Name = "file")] IFormFile? file) + { + if (file is null || file.Length == 0) + return BadRequest(new { error = "missing_file" }); + var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); + if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp")) + return BadRequest(new { error = "unsupported_extension", allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" } }); + + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + var bytes = ms.ToArray(); + + using var sha = System.Security.Cryptography.SHA1.Create(); + var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant(); + var imageName = $"loadingtip_{hash[..24]}{ext}"; + + var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName); + if (existing is null) + { + var (bucket, key) = BlobRouter.Route(imageName); + await storage.PutAsync(bucket, key, bytes, + ext == ".png" ? "image/png" : "image/jpeg"); + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = 0, + BlobName = imageName, + UploadedByPlayerId = CurrentAdminId, + UploadedAt = DateTime.UtcNow, + }); + await LogAsync("loading_tip_upload", "global", 0, $"image={imageName} bytes={bytes.Length}"); + await db.SaveChangesAsync(); + } + + return Ok(new + { + imageName, + imageUrl = $"https://{domain.Sub("img")}/{imageName}", + bytes = bytes.Length, + reused = existing is not null, + }); + } + + private static LoadingScreenTipDto ToLoadingScreenTipDto(Data.Entities.LoadingScreenTipEntity t) => new() + { + Id = t.Id, + Title = t.Title, + Message = t.Message, + ImageName = t.ImageName, + Context = t.Context, + PlatformMask = t.PlatformMask, + RoomNamesCsv = t.RoomNamesCsv, + SortOrder = t.SortOrder, + IsActive = t.IsActive, + }; + + private static string NormaliseCsv(string? csv) + { + if (string.IsNullOrWhiteSpace(csv)) return string.Empty; + var parts = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return string.Join(",", parts); + } + + // ── Room import ────────────────────────────────────────────────────── + + /// POST api/admin/v1/rooms/import — multipart upload + /// of an archived multi-scene room. Each .room file (one per scene) + /// is uploaded with its filename including the scene folder, e.g. + /// Lobby/abc.room or Ch1_Headfirst/def.room. The form + /// can either send the per-file folder via webkitRelativePath + /// (browser folder picker preserves it) or via the filename itself. + /// + /// Server inserts: + /// 1. A row for the new room (id 1000+). + /// 2. One per uploaded file, + /// named {roomname}_{scenefolder}.room (lowercased). + /// 3. One per scene with the entry + /// scene at OrderIndex=0 and the rest in folder order. + /// + /// Default RoomSceneLocationId for every scene is the + /// MakerRoom (Basement) GUID — the canonical 2020 custom-room + /// canvas. If the import was a 2020-era export of an apartment + /// scene, edit + /// per-row after import. + /// + /// Returns 400 if a room with this name already exists or no + /// files were uploaded. + [HttpPost("rooms/import")] + [DisableRequestSizeLimit] + [RequestFormLimits(MultipartBodyLengthLimit = 500_000_000)] + public async Task ImportRoom( + [FromForm(Name = "name")] string? name, + [FromForm(Name = "description")] string? description, + [FromForm(Name = "entryScene")] string? entryScene, + [FromForm(Name = "creatorPlayerId")] long? creatorPlayerId, + [FromForm(Name = "scenePaths")] List? scenePaths, + [FromForm(Name = "files")] List? files, + [FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs) + { + // Per-import "run blob bytes through the 2020 protobuf normaliser" + // toggle, mirroring RoomZipImportController. Default OFF — see + // that file's notes for why the round-trip currently crashes the + // watch on load. Diagnostic Normalize() call still runs so its + // parse-OK / parse-FAIL log line remains in the import telemetry. + var normalize = normalizeBlobs ?? false; + if (string.IsNullOrWhiteSpace(name)) + return BadRequest(new { error = "name_required" }); + if (files is null || files.Count == 0) + return BadRequest(new { error = "no_files" }); + + var roomName = name.Trim(); + if (await db.Rooms.AnyAsync(r => r.Name == roomName)) + return BadRequest(new { error = "duplicate_name", message = $"A room named '{roomName}' already exists." }); + + // Pair each uploaded file with its scene folder name. Browsers + // submit folder uploads as files with FileName = "Lobby/blob.room" + // when webkitdirectory is used; fall back to a parallel + // scenePaths[] form field when the client supplies it explicitly + // (e.g. uploading individual files via JS FormData with the + // scene name set per entry). + // + // Each blob is also passed through RoomBlobNormalizerService + // (round-trip via the full 2020 PersistedRoomData schema). That + // strips redundant default-value encodings the modern client + // emits — without this, the watch's stricter 2020 parser logs + // "Error attempting to parse room data stream" and boots the + // player to dorm. Normalize() falls back to the original bytes + // if parse fails, so a corrupted upload doesn't drop content. + var perScene = new List<(string SceneName, string OriginalName, byte[] Bytes, RoomBlobNormalizerService.Result Norm)>(); + for (int i = 0; i < files.Count; i++) + { + var f = files[i]; + // FormFile.FileName is "scenefolder/blob.room" when uploaded + // via webkitdirectory. ScenePaths (if provided) overrides. + var sceneName = scenePaths is { } sp && i < sp.Count && !string.IsNullOrWhiteSpace(sp[i]) + ? sp[i].Trim() + : ExtractSceneFolder(f.FileName); + if (string.IsNullOrWhiteSpace(sceneName)) + { + return BadRequest(new { + error = "missing_scene_folder", + message = $"File '{f.FileName}' has no scene folder. Either upload via folder picker, or supply scenePaths[] alongside files[]." + }); + } + using var ms = new MemoryStream(); + await f.CopyToAsync(ms); + var raw = ms.ToArray(); + var norm = roomBlobNormalizer.Normalize(raw); + var persistedBytes = normalize ? norm.Bytes : raw; + adminLogger.LogInformation( + "[import] scene={Scene} file={File} raw={Raw:N0} → normalized={Out:N0} ({Status}) persistedFrom={Source}", + sceneName, f.FileName, raw.Length, norm.Bytes.Length, + norm.Normalized ? "ok" : $"fail: {norm.Error}", + normalize ? "normaliser" : "raw"); + perScene.Add((sceneName, f.FileName, persistedBytes, norm)); + } + + // Pick entry scene: explicit override → "Lobby" if present → + // alphabetically-first folder. + var sceneNames = perScene.Select(s => s.SceneName).ToList(); + var entry = !string.IsNullOrWhiteSpace(entryScene) + ? perScene.FirstOrDefault(s => string.Equals(s.SceneName, entryScene, StringComparison.OrdinalIgnoreCase)) + : default; + if (entry == default) + { + entry = perScene.FirstOrDefault(s => string.Equals(s.SceneName, "Lobby", StringComparison.OrdinalIgnoreCase)); + } + if (entry == default) entry = perScene.OrderBy(s => s.SceneName).First(); + + // Allocate a new id in the user-room range (>=1000). + var newRoomId = (await db.Rooms + .Where(r => r.Id >= 1000) + .Select(r => (long?)r.Id) + .MaxAsync() ?? 999) + 1; + var creator = creatorPlayerId ?? CurrentAdminId; + const string makerRoomLocation = "a75f7547-79eb-47c6-8986-6767abcb4f92"; + + // Build BlobName per scene. + var slug = roomName.ToLowerInvariant().Replace(' ', '_'); + string MakeBlobName(string sceneName) => $"{slug}_{sceneName.ToLowerInvariant()}.room"; + + // Reorder so entry scene is OrderIndex=0. + var ordered = new List<(string SceneName, string OriginalName, byte[] Bytes, RoomBlobNormalizerService.Result Norm)> { entry }; + ordered.AddRange(perScene.Where(s => s.SceneName != entry.SceneName)); + + using var tx = await db.Database.BeginTransactionAsync(); + try + { + // Insert blobs — bytes go to S3, DB only stores metadata. + foreach (var s in ordered) + { + var blobName = MakeBlobName(s.SceneName); + var (bucket, key) = BlobRouter.Route(blobName); + await storage.PutAsync(bucket, key, s.Bytes, "application/octet-stream"); + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = newRoomId, + BlobName = blobName, + UploadedByPlayerId = creator, + UploadedAt = DateTime.UtcNow, + }); + } + + // Insert Room row pointing at entry scene's blob. + var entryBlob = MakeBlobName(entry.SceneName); + db.Rooms.Add(new RoomEntity + { + Id = newRoomId, + Name = roomName, + Description = string.IsNullOrWhiteSpace(description) + ? $"Imported via admin upload ({ordered.Count} scenes)" + : description.Trim(), + CreatorPlayerId = creator, + ImageName = string.Empty, + State = 0, + Accessibility = 1, + IsAGRoom = false, + IsDormRoom = false, + CloningAllowed = true, + LocationReplicationId = makerRoomLocation, + TagsCsv = "community", + CheerCount = 0, + FavoriteCount = 0, + VisitCount = 0, + VisitorCount = 0, + HotScore = 5.0, + CurrentDataBlobName = entryBlob, + }); + + // Insert RoomScenes rows. + int idx = 0; + foreach (var s in ordered) + { + db.RoomScenes.Add(new RoomSceneEntity + { + RoomId = newRoomId, + OrderIndex = idx++, + Name = s.SceneName, + RoomSceneLocationId = makerRoomLocation, + DataBlobName = MakeBlobName(s.SceneName), + MaxPlayers = 8, + IsSandbox = false, + CanMatchmakeInto = true, + DataModifiedAt = DateTime.UtcNow, + }); + } + + await db.SaveChangesAsync(); + await tx.CommitAsync(); + } + catch + { + await tx.RollbackAsync(); + throw; + } + + await LogAsync("import_room", "room", newRoomId, + $"name={roomName} scenes={ordered.Count} entry={entry.SceneName}"); + + // Fire-and-forget: scan the just-imported scene blobs for + // Holotar/AudioSampler .htr references and download each from + // cdn.rec.net into RoomDataBlobs (BlobName="{hash}.htr", + // RoomId=0). The watch's holotar projectors and audio + // samplers can then pull their content from cdn.localhost + // instead of failing on the public CDN. Doing this in the + // background keeps the upload response snappy (the .htr + // download set for a YarrHarrHeist-sized room is ~25 MB). + htrMirror.EnqueueAsync( + ordered.Select(s => s.Bytes).ToList(), + contextLabel: $"room={roomName}"); + + return Ok(new + { + roomId = newRoomId, + roomName, + sceneCount = ordered.Count, + entryScene = entry.SceneName, + normalizedSceneCount = ordered.Count(s => s.Norm.Normalized), + normalizationFailures = ordered.Where(s => !s.Norm.Normalized) + .Select(s => new { scene = s.SceneName, error = s.Norm.Error }), + htrMirrorStarted = true, + scenes = ordered.Select((s, i) => new + { + orderIndex = i, + name = s.SceneName, + blobName = MakeBlobName(s.SceneName), + rawBytes = s.Norm.InputBytes, + bytes = s.Bytes.Length, + normalized = s.Norm.Normalized, + }), + }); + } + + /// POST api/admin/v1/rooms/{id}/mirror-htr — + /// re-run the HoloTar / AudioSampler .htr mirror for an existing + /// room's blobs. Useful to (a) backfill assets for rooms imported + /// before the auto-mirror existed, (b) debug whether + /// HtrAssetMirrorService can actually extract refs from the C# + /// generated 2020 PersistedRoomData schema. Synchronous (awaits the + /// download stream) so the response carries the result counts — + /// the import-time path uses fire-and-forget instead. + [HttpPost("rooms/{roomId:long}/mirror-htr")] + public async Task MirrorHtrForRoom(long roomId) + { + // Stream bytes back from S3 — DB carries only metadata now. + var blobNames = await db.RoomDataBlobs + .Where(b => b.RoomId == roomId) + .Select(b => b.BlobName) + .ToListAsync(); + if (blobNames.Count == 0) return NotFound(new { error = "no_blobs", roomId }); + var blobs = new List(blobNames.Count); + foreach (var blobName in blobNames) + { + var (bucket, key) = BlobRouter.Route(blobName); + var bytes = await storage.GetAsync(bucket, key); + if (bytes is { Length: > 0 }) blobs.Add(bytes); + } + if (blobs.Count == 0) return NotFound(new { error = "blobs_missing_in_s3", roomId, expected = blobNames.Count }); + var result = await htrMirror.MirrorAsync(blobs, contextLabel: $"manual room={roomId}"); + await LogAsync("mirror_htr", "room", roomId, + $"refs={result.TotalRefs} skipped={result.AlreadyMirrored} inserted={result.Inserted} parseFails={result.RoomBlobParseFailures}"); + return Ok(new + { + roomId, + scannedBlobs = blobs.Count, + uniqueRefs = result.TotalRefs, + alreadyMirrored = result.AlreadyMirrored, + downloaded = result.Inserted, + roomParseFailures = result.RoomBlobParseFailures, + assetDownloadFailures = result.AssetDownloadFailures, + }); + } + + /// Pull the immediate-parent folder out of a multi-segment + /// filename like "Lobby/abc.room" or "YarrHarrHeist/Lobby/abc.room". + /// Returns the second-to-last segment (the file's parent folder). + /// Returns null if the filename has no folder component. + private static string? ExtractSceneFolder(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) return null; + // Browsers may use either separator depending on platform. + var parts = fileName.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) return null; + // The .room file's parent folder is the scene name. + return parts[^2]; + } +} diff --git a/DorkNet.Server/Controllers/Admin/AdminLoginController.cs b/DorkNet.Server/Controllers/Admin/AdminLoginController.cs new file mode 100644 index 0000000..697a0ae --- /dev/null +++ b/DorkNet.Server/Controllers/Admin/AdminLoginController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Admin; + +/// +/// admin.rec.net/api/admin/v1/login — same-origin login endpoint for +/// the browser-based admin UI. Wraps a username+password verify +/// against PlayerEntity.PasswordHash and returns the JWT pair the UI +/// can stash in localStorage. Refuses non-admin accounts up front so a +/// regular player password can't be used to scope into the admin tools +/// even if the JWT is valid against api/admin/v1/* (the AdminOnly +/// filter would catch it anyway, but the early reject saves a +/// confused-looking 403). +/// +/// Lives on admin.rec.net so the static admin page (also served from +/// admin.rec.net) can fetch with a same-origin request and skip CORS +/// entirely. +/// +[ApiController] +[Route("api/admin/v1")] +public class AdminLoginController( + DorkNetDbContext db, + AuthService authService) : ControllerBase +{ + public sealed record AdminLoginRequest(string Username, string Password); + + [HttpPost("login")] + public async Task Login([FromBody] AdminLoginRequest body) + { + if (string.IsNullOrWhiteSpace(body.Username) || string.IsNullOrWhiteSpace(body.Password)) + return BadRequest(new { error = "missing_credentials" }); + + var player = await db.Players.FirstOrDefaultAsync(p => p.Username == body.Username); + if (player is null || player.PasswordHash is null) + return Unauthorized(new { error = "invalid_credentials" }); + if (!BCrypt.Net.BCrypt.Verify(body.Password, player.PasswordHash)) + return Unauthorized(new { error = "invalid_credentials" }); + if (!player.IsAdmin) + return StatusCode(403, new { error = "not_admin" }); + + var (access, refresh) = authService.GenerateTokenPair(player.Id); + return Ok(new + { + access_token = access, + refresh_token = refresh, + account_id = player.Id, + username = player.Username, + display_name = player.DisplayName, + }); + } +} diff --git a/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs b/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs new file mode 100644 index 0000000..0e90dc1 --- /dev/null +++ b/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs @@ -0,0 +1,1209 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; +using Google.Protobuf; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Admin; + +/// +/// admin.rec.net/api/admin/v1/rooms/zip-bulk-import — preferred room +/// import path going forward. The legacy multi-file endpoint +/// (POST api/admin/v1/rooms/import) stays alongside this for +/// tooling that already targets it. +/// +/// The archive format mirrors RecNet's own room export shape so a +/// user can download a room from rec.net, drop the zip into the admin +/// page, and we re-create it with full fidelity — including the +/// .htr AudioSampler / Holotar assets already extracted into +/// each subroom folder so we never have to hit Rec Room's CDN: +/// +/// root.zip/ +/// Rooms/Room_<id>_<name>_<timestamp>/ +/// RoomDetails.json (full RecNet v4 details schema) +/// RoomImage.jpg | <hash>.jpg +/// Promo/Image/PromoImage_*.jpg +/// SubRooms/<SceneName>/ +/// Subroom.json (per-subroom manifest) +/// <hash>.room (referenced by Subroom.CurrentSave.DataBlob) +/// AudioSampler/<hash>.htr +/// Holotar/<hash>.htr +/// Image/PVImage_<hash>.jpg +/// Inventions/Invention_<id>_<name>_<timestamp>/ +/// Invention.json +/// InventionDetails.json +/// InventionVersion.json +/// InventionImage.jpg +/// <hash>.inv +/// +/// Entry-scene resolution: the order in RoomDetails.SubRooms[] +/// is the entry order (RecNet API convention). The first entry is the +/// entry scene. The admin can override with an explicit +/// RoomDetails.EntryScene field. +/// +[ApiController] +[Route("api/admin/v1/rooms")] +[Authorize] +[AdminOnly] +public class RoomZipImportController( + DorkNetDbContext db, + RoomBlobNormalizerService roomBlobNormalizer, + IObjectStorage storage, + ILogger logger) : ControllerBase +{ + private long CurrentAdminId => this.RequireCurrentPlayerId(); + + // ── Room-blob normaliser toggle ───────────────────────────────────── + // The normaliser re-encodes .room bytes through the 2020 schema to + // strip non-canonical encodings. It's been tripping the watch into + // "Error attempting to parse room data stream" — the generated 2020 + // protobuf bindings appear to be missing fields or to emit them in + // a wire-order the watch's parser disagrees with. + // + // Per-import flag (`normalizeBlobs` in the form / finalize body) + // defaults OFF. The SPA's importer surfaces it as a checkbox so an + // admin can opt back in once we're confident a particular zip's + // contents survive the re-encode. The diagnostic parse still runs + // either way (its parse-OK / parse-FAIL log line is useful), but + // when the flag is false the original bytes are what reach S3. + + /// Write a blob to S3 at its canonical (bucket, key) AND + /// record the metadata row in . + /// Bytes are NOT persisted on the DB row — S3 is the only place + /// they live. Previously the importer wrote bytes to the + /// Bytes column and skipped S3 entirely, so freshly + /// imported rooms were only reachable through the read-path's + /// DB-fallback branch. Centralised here so every importer call + /// site (scene saves, .htr/PV/polaroid assets, .meta sentinels, + /// invention saves, room thumbnails, promo images) ends up in + /// S3 the same way. + private async Task WriteBlobAsync( + long roomId, string blobName, byte[] bytes, long uploaderPlayerId, + string? referencesCsv = null, string? contentTypeOverride = null) + { + var (bucket, key) = BlobRouter.Route(blobName); + var contentType = contentTypeOverride ?? MimeFromName(blobName); + try + { + await storage.PutAsync(bucket, key, bytes, contentType); + } + catch (Exception ex) + { + logger.LogError(ex, + "[zip-import] S3 PUT failed bucket={Bucket} key={Key} blob={Blob} bytes={Bytes} — row will still be recorded so the import isn't lost", + bucket, key, blobName, bytes.Length); + } + + var entity = new RoomDataBlobEntity + { + RoomId = roomId, + BlobName = blobName, + UploadedByPlayerId = uploaderPlayerId, + UploadedAt = DateTime.UtcNow, + ReferencedFilenamesCsv = referencesCsv ?? string.Empty, + }; + db.RoomDataBlobs.Add(entity); + return entity; + } + + private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".mp4" => "video/mp4", + ".htr" => "application/octet-stream", + ".room" => "application/octet-stream", + ".meta" => "application/octet-stream", + ".inv" => "application/octet-stream", + ".dat" => "application/octet-stream", + _ => "application/octet-stream", + }; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + // ── RoomDetails.json DTOs ────────────────────────────────────────── + + public sealed class RoomDetailsDto + { + public long? RoomId { get; set; } + public string? Name { get; set; } + public string? FriendlyName { get; set; } + public string? Description { get; set; } + public string? ImageName { get; set; } + public int? WarningMask { get; set; } + public string? CustomWarning { get; set; } + public long? CreatorAccountId { get; set; } + public int? State { get; set; } + public int? Accessibility { get; set; } + public bool? SupportsLevelVoting { get; set; } + public bool? IsRRO { get; set; } + public bool? SupportsScreens { get; set; } + public bool? SupportsWalkVR { get; set; } + public bool? SupportsTeleportVR { get; set; } + public bool? SupportsVRLow { get; set; } + public bool? SupportsMobile { get; set; } + public bool? SupportsJuniors { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? PublishedAt { get; set; } + public bool? IsDorm { get; set; } + public int? MaxPlayers { get; set; } + public bool? CloningAllowed { get; set; } + public bool? DisableMicAutoMute { get; set; } + public StatsDto? Stats { get; set; } + public List? Tags { get; set; } + public List? SubRooms { get; set; } + public string? EntryScene { get; set; } // admin override; not in real exports + } + + public sealed class StatsDto + { + public int? CheerCount { get; set; } + public int? FavoriteCount { get; set; } + public int? VisitorCount { get; set; } + public int? VisitCount { get; set; } + } + + public sealed class TagDto + { + public string? Tag { get; set; } + public int? Type { get; set; } + public bool? IsPrimaryGenre { get; set; } + } + + public sealed class SubRoomDto + { + public long? SubRoomId { get; set; } + public string? UnitySceneId { get; set; } + public string? Name { get; set; } + public bool? IsSandbox { get; set; } + public int? MaxPlayers { get; set; } + public int? Accessibility { get; set; } + public CurrentSaveDto? CurrentSave { get; set; } + } + + public sealed class CurrentSaveDto + { + public long? SubRoomDataSaveId { get; set; } + public string? DataBlob { get; set; } + } + + // ── Invention DTOs ──────────────────────────────────────────────── + + public sealed class InventionJsonDto + { + public long? InventionId { get; set; } + public string? ReplicationId { get; set; } + public long? CreatorPlayerId { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public string? LongDescription { get; set; } + public string? ImageName { get; set; } + public int? UgcVersion { get; set; } + public int? CurrentVersionNumber { get; set; } + public int? LatestVersionNumber { get; set; } + public int? Accessibility { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? ModifiedAt { get; set; } + public DateTime? FirstPublishedAt { get; set; } + public long? CreationRoomId { get; set; } + public int? NumPlayersHaveUsedInRoom { get; set; } + public int? CheerCount { get; set; } + public int? CreatorPermission { get; set; } + public int? GeneralPermission { get; set; } + public bool? IsAgInvention { get; set; } + public bool? IsRecRoomApproved { get; set; } + } + + public sealed class InventionVersionDto + { + public int? VersionNumber { get; set; } + public string? BlobName { get; set; } + } + + public sealed class InventionDetailsDto + { + public List? Tags { get; set; } + } + + // ── Endpoint ────────────────────────────────────────────────────── + + [HttpPost("zip-bulk-import")] + [DisableRequestSizeLimit] + [RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)] // 2 GB + public async Task ZipImport( + [FromForm(Name = "archive")] IFormFile? archive, + [FromForm(Name = "creatorPlayerId")] long? creatorPlayerId, + [FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs) + { + if (archive is null || archive.Length == 0) + return BadRequest(new { error = "missing_archive" }); + + await using var stream = archive.OpenReadStream(); + return await RunImportAsync( + stream, archive.Length, creatorPlayerId, + normalizeBlobs: normalizeBlobs ?? false); + } + + // ── Chunked upload (Cloudflare 100MB workaround) ───────────────── + // + // The whole multipart-zip path 413s through Cloudflare's edge for + // anything over 100 MB on free/pro plans. Real RecNet exports + // routinely run 150-300 MB. Splitting the upload into ≤90 MB + // chunks keeps each request safely under the cap; the server + // appends chunks to a temp file and only invokes the import logic + // once the finalize call comes in. + // + // Sessions are in-memory and short-lived (admins use the importer + // synchronously). If the process restarts before finalize, the + // upload is lost — acceptable for an admin tool used by 1-2 people. + private static readonly System.Collections.Concurrent.ConcurrentDictionary Sessions = new(); + + public sealed class ZipUploadSession + { + public Guid Id { get; init; } + public string TempPath { get; init; } = string.Empty; + public long TotalBytes { get; set; } + public long ReceivedBytes { get; set; } + public string FileName { get; init; } = string.Empty; + public long AdminPlayerId { get; init; } + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; + } + + public sealed record ChunkInitRequest(string FileName, long TotalBytes); + + /// POST zip-upload-init — allocates a temp file and + /// returns an uploadId the SPA threads through subsequent + /// chunk POSTs. Capped at 2 GB total; opens a sparse temp file so + /// disk usage tracks actual bytes received. + [HttpPost("zip-upload-init")] + public ActionResult ChunkInit([FromBody] ChunkInitRequest body) + { + if (body.TotalBytes <= 0 || body.TotalBytes > 2_000_000_000) + return BadRequest(new { error = "invalid_size" }); + + // Prune stale sessions before allocating a new one. Anything + // older than 2 hours with no activity is abandoned. + foreach (var (sid, sess) in Sessions) + { + if (DateTime.UtcNow - sess.CreatedAt > TimeSpan.FromHours(2)) + { + Sessions.TryRemove(sid, out _); + try { if (System.IO.File.Exists(sess.TempPath)) System.IO.File.Delete(sess.TempPath); } catch { /* best effort */ } + } + } + + var id = Guid.NewGuid(); + var safeName = Path.GetFileName(body.FileName); + var tempPath = Path.Combine(Path.GetTempPath(), $"dorknet-ziplupload-{id:N}.zip"); + // Pre-allocate the file at full length so chunk writes at + // arbitrary offsets work without the OS having to grow it + // page-by-page during the random writes. + using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write)) + { + fs.SetLength(body.TotalBytes); + } + Sessions[id] = new ZipUploadSession + { + Id = id, + TempPath = tempPath, + TotalBytes = body.TotalBytes, + FileName = safeName, + AdminPlayerId = CurrentAdminId, + }; + return Ok(new { uploadId = id }); + } + + /// POST zip-upload-chunk/{id}?offset=N — writes the + /// posted body at offset in the session's temp file. + /// Per-call size capped at 100 MB (Kestrel-side, well below the + /// Cloudflare 100 MB edge cap so we never see a 413). Body is the + /// raw chunk bytes; the SPA's uploadZipInChunks helper sends + /// the right slice. + [HttpPost("zip-upload-chunk/{id:guid}")] + [DisableRequestSizeLimit] + [RequestSizeLimit(100_000_000)] + public async Task ChunkAppend(Guid id, [FromQuery] long offset) + { + if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" }); + if (session.AdminPlayerId != CurrentAdminId) return Forbid(); + if (offset < 0 || offset >= session.TotalBytes) + return BadRequest(new { error = "bad_offset", offset, total = session.TotalBytes }); + + await using var fs = new FileStream(session.TempPath, FileMode.Open, FileAccess.Write, FileShare.Read); + fs.Position = offset; + var written = 0L; + await Request.Body.CopyToAsync(fs); + written = fs.Position - offset; + session.ReceivedBytes = Math.Max(session.ReceivedBytes, fs.Position); + return Ok(new + { + uploadId = id, + offset, + written, + received = session.ReceivedBytes, + total = session.TotalBytes, + }); + } + + /// Body for zip-upload-finalize. + /// stamps every imported room/invention with that player as the + /// owner. and + /// , when non-null and + /// non-empty, restrict the import to only those top-level folders + /// inside the archive — the SPA's preview offers a checkbox per + /// room/invention so admins can skip a bad export's broken entry + /// without rebuilding the zip. Null/empty means "import + /// everything" (back-compat with older SPA builds). + public sealed record ChunkFinalizeRequest( + long? CreatorPlayerId, + List? SelectedRoomFolders, + List? SelectedInventionFolders, + bool? NormalizeBlobs); + + /// POST zip-upload-finalize/{id} — runs the existing + /// zip import on the assembled temp file then deletes it. Same + /// response shape as zip-bulk-import. + [HttpPost("zip-upload-finalize/{id:guid}")] + public async Task ChunkFinalize(Guid id, [FromBody] ChunkFinalizeRequest? body) + { + if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" }); + if (session.AdminPlayerId != CurrentAdminId) return Forbid(); + + try + { + await using var stream = System.IO.File.OpenRead(session.TempPath); + return await RunImportAsync( + stream, + session.TotalBytes, + body?.CreatorPlayerId, + body?.SelectedRoomFolders, + body?.SelectedInventionFolders, + normalizeBlobs: body?.NormalizeBlobs ?? false); + } + finally + { + Sessions.TryRemove(id, out _); + try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); } + catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); } + } + } + + /// DELETE zip-upload-abort/{id} — drop an in-flight + /// upload session (e.g. user navigated away). No-op if the id is + /// already gone. + [HttpDelete("zip-upload-abort/{id:guid}")] + public IActionResult ChunkAbort(Guid id) + { + if (Sessions.TryRemove(id, out var session)) + { + try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); } + catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); } + } + return Ok(); + } + + // ── Shared import core (called by both single-shot and chunked) ── + + private async Task RunImportAsync( + Stream archiveStream, + long archiveBytes, + long? creatorPlayerId, + List? selectedRoomFolders = null, + List? selectedInventionFolders = null, + bool normalizeBlobs = false) + { + var creator = creatorPlayerId ?? CurrentAdminId; + if (!await db.Players.AnyAsync(p => p.Id == creator)) + return BadRequest(new { error = "creator_not_found", creator }); + + using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false); + + // Index entries by normalized path. We do path-based lookups + // throughout (per-room folder, per-subroom folder, per-invention + // folder), so a flat dictionary keyed by full path is simplest. + var entryByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var e in zip.Entries) + { + if (e.FullName.EndsWith("/", StringComparison.Ordinal)) continue; + entryByPath[Normalize(e.FullName)] = e; + } + + // Find every room manifest (`Rooms//RoomDetails.json`). + // Folder names are like "Room_3163127_YarrHarrHeist_20260509_180319" + // but we don't parse anything out of them — RoomDetails.json is + // the source of truth. + var roomFolders = entryByPath.Keys + .Where(k => k.StartsWith("Rooms/", StringComparison.OrdinalIgnoreCase) + && k.EndsWith("/RoomDetails.json", StringComparison.OrdinalIgnoreCase)) + .Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length)) + .ToList(); + + // Inventions can live either at the zip root (Inventions/Invention___/) + // OR inside a room folder (Rooms/Room___/Inventions/Invention_…). + // Both layouts show up in the wild — handle either. + var inventionFolders = entryByPath.Keys + .Where(k => k.EndsWith("/Invention.json", StringComparison.OrdinalIgnoreCase) + && (k.StartsWith("Inventions/", StringComparison.OrdinalIgnoreCase) + || k.Contains("/Inventions/", StringComparison.OrdinalIgnoreCase))) + .Select(k => k[..^"/Invention.json".Length]) + .ToList(); + + if (roomFolders.Count == 0 && inventionFolders.Count == 0) + return BadRequest(new { error = "empty_archive_no_manifests" }); + + // If the admin supplied an explicit selection (non-null, + // non-empty), filter to just those folders. Comparison is + // case-insensitive and tolerates leading/trailing slashes that + // the client might have included. Anything in the selection + // that we can't match against the archive is just ignored — + // the per-room report will simply not include it. + if (selectedRoomFolders is { Count: > 0 }) + { + var allowed = selectedRoomFolders + .Select(s => Normalize(s).TrimEnd('/')) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + roomFolders = roomFolders.Where(f => allowed.Contains(f)).ToList(); + } + if (selectedInventionFolders is { Count: > 0 }) + { + var allowed = selectedInventionFolders + .Select(s => Normalize(s).TrimEnd('/')) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + inventionFolders = inventionFolders.Where(f => allowed.Contains(f)).ToList(); + } + + var roomReports = new List(); + long nextRoomId = (await db.Rooms + .Where(r => r.Id >= 1000) + .Select(r => (long?)r.Id) + .MaxAsync() ?? 999) + 1; + + foreach (var folder in roomFolders) + { + try + { + var result = await ImportRoomAsync(folder, nextRoomId, creator, entryByPath, normalizeBlobs); + roomReports.Add(result); + if (result.GetType().GetProperty("ok")!.GetValue(result) as bool? == true) + nextRoomId++; + } + catch (Exception ex) + { + logger.LogError(ex, "[zip-import] room folder='{Folder}' failed", folder); + roomReports.Add(new { folder, ok = false, error = ex.Message }); + } + } + + var inventionReports = new List(); + foreach (var folder in inventionFolders) + { + try { inventionReports.Add(await ImportInventionAsync(folder, creator, entryByPath)); } + catch (Exception ex) + { + logger.LogError(ex, "[zip-import] invention folder='{Folder}' failed", folder); + inventionReports.Add(new { folder, ok = false, error = ex.Message }); + } + } + + return Ok(new + { + archiveBytes, + roomCount = roomFolders.Count, + inventionCount = inventionFolders.Count, + rooms = roomReports, + inventions = inventionReports, + }); + } + + // ── Per-room import ─────────────────────────────────────────────── + + private async Task ImportRoomAsync( + string roomFolder, + long roomId, + long creator, + Dictionary entries, + bool normalizeBlobs) + { + // RoomDetails.json + var detailsKey = $"{roomFolder}/RoomDetails.json"; + var details = await ReadJsonAsync(entries[detailsKey]) ?? new RoomDetailsDto(); + var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : Path.GetFileName(roomFolder); + + if (await db.Rooms.AnyAsync(r => r.Name == name)) + return new { name, ok = false, error = "duplicate_name", folder = roomFolder }; + + // Subroom manifests + blobs. Walk each folder under + // /SubRooms// and pair its Subroom.json with + // the .room blob it references via CurrentSave.DataBlob, plus + // any AudioSampler/Holotar .htr files alongside. + var subroomRoot = $"{roomFolder}/SubRooms/"; + var scenes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var path in entries.Keys.Where(k => k.StartsWith(subroomRoot, StringComparison.OrdinalIgnoreCase))) + { + var rel = path.Substring(subroomRoot.Length); + var slash = rel.IndexOf('/'); + if (slash <= 0) continue; + var sceneName = rel.Substring(0, slash); + if (!scenes.ContainsKey(sceneName)) scenes[sceneName] = sceneName; + } + var resolvedScenes = new List(); + var missingScenes = new List(); + + foreach (var sceneName in scenes.Keys) + { + var sceneFolder = $"{subroomRoot}{sceneName}"; + var subroomJsonKey = $"{sceneFolder}/Subroom.json"; + if (!entries.TryGetValue(subroomJsonKey, out var subroomJsonEntry)) + { + missingScenes.Add($"{sceneName} (missing Subroom.json)"); + continue; + } + var manifest = await ReadJsonAsync(subroomJsonEntry) ?? new SubRoomDto(); + var blobFile = manifest.CurrentSave?.DataBlob; + if (string.IsNullOrWhiteSpace(blobFile)) + { + missingScenes.Add($"{sceneName} (no CurrentSave.DataBlob)"); + continue; + } + var blobKey = $"{sceneFolder}/{blobFile}"; + if (!entries.TryGetValue(blobKey, out var blobEntry)) + { + missingScenes.Add($"{sceneName} → {blobFile}"); + continue; + } + + var blobBytes = await ReadAllAsync(blobEntry); + // The normaliser still runs so we keep the parse-OK / parse-FAIL + // log line — useful telemetry even when we're not persisting + // its output. Per-import `normalizeBlobs` flag (defaulting + // off because the re-encode currently crashes the watch on + // load) decides which bytes actually reach S3. + var norm = roomBlobNormalizer.Normalize(blobBytes); + var persistedBytes = normalizeBlobs ? norm.Bytes : blobBytes; + + // Collect .htr asset blobs that live alongside this subroom. + var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>(); + foreach (var sub in new[] { "AudioSampler", "Holotar" }) + { + var prefix = $"{sceneFolder}/{sub}/"; + foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) + { + if (k.EndsWith(".htr", StringComparison.OrdinalIgnoreCase)) + htrAssets.Add((Path.GetFileName(k), e)); + } + } + // Image/ folder: persistence-view previews (PVImage_.ext) + // and — since the exporter consolidated layouts — also the + // placed-in-world polaroids that used to live in Polaroids/. + // + // The watch fetches BOTH URLs for the same content: + // * `img.localhost/PVImage_.png` — persistence-view preview + // * `img.localhost/.png` — placed polaroid (room blob + // references this bare-hash form, decoded directly from + // the .room protobuf in our sample) + // So for any `PVImage_`-prefixed file we save the bytes under + // BOTH BlobNames. Same file in S3/DB twice is cheap and saves + // us from missing one of the lookup paths. + const string pvPrefix = "PVImage_"; + var pvImages = new List<(string Name, ZipArchiveEntry Entry)>(); + var polaroids = new List<(string Name, ZipArchiveEntry Entry)>(); + var imageFolderPrefix = $"{sceneFolder}/Image/"; + foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(imageFolderPrefix, StringComparison.OrdinalIgnoreCase))) + { + var fileName = Path.GetFileName(k); + pvImages.Add((fileName, e)); + if (fileName.StartsWith(pvPrefix, StringComparison.OrdinalIgnoreCase)) + { + var bareHashName = fileName[pvPrefix.Length..]; + polaroids.Add((bareHashName, e)); + } + } + // Legacy fallback: older archives shipped polaroids as a + // separate Polaroids/ subfolder. Still support those so we + // don't regress on older test zips that haven't been re- + // exported. The persistence loop's de-dupe will collapse + // overlaps from the Image/-derived path. + var polaroidFolderPrefix = $"{sceneFolder}/Polaroids/"; + foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(polaroidFolderPrefix, StringComparison.OrdinalIgnoreCase))) + { + polaroids.Add((Path.GetFileName(k), e)); + } + + resolvedScenes.Add(new ResolvedSubroom( + Manifest: manifest, + SceneName: sceneName, + Bytes: persistedBytes, + RawBytes: blobBytes.Length, + // When the toggle is off we still report `norm.Normalized` — + // it reflects whether the diagnostic parse succeeded, not + // whether we actually applied the normalised output. + NormalizedOk: norm.Normalized, + HtrAssets: htrAssets, + PvImages: pvImages, + Polaroids: polaroids)); + logger.LogInformation( + "[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}", + name, sceneName, blobFile, blobBytes.Length, norm.Bytes.Length, + normalizeBlobs ? "normaliser" : "raw", + htrAssets.Count, pvImages.Count, polaroids.Count); + } + + if (resolvedScenes.Count == 0) + return new { name, ok = false, error = "no_resolvable_subrooms", missingScenes, folder = roomFolder }; + + // Entry resolution: order subrooms by manifest order if + // RoomDetails.SubRooms[] is present; otherwise by sceneName. + // EntryScene field overrides everything. + var manifestOrder = (details.SubRooms ?? new()) + .Where(s => !string.IsNullOrWhiteSpace(s.Name)) + .Select(s => s.Name!) + .ToList(); + if (manifestOrder.Count > 0) + { + resolvedScenes = resolvedScenes + .OrderBy(r => manifestOrder.IndexOf(r.SceneName) is int i && i >= 0 ? i : int.MaxValue) + .ThenBy(r => r.SceneName) + .ToList(); + } + else + { + resolvedScenes = resolvedScenes.OrderBy(r => r.SceneName).ToList(); + } + + // Entry resolution cascade: + // 1. Explicit `RoomDetails.EntryScene` override (admin-set, + // not in real exports). + // 2. .meta protobuf at the room root — RecNet's + // published-snapshot manifest. Field 2 is the entry + // subroom INDEX into RoomDetails.SubRooms[] (0-based). + // Decoded directly without a generated schema because + // the message is tiny (6 bytes total in the known sample). + // 3. First entry in manifest order. + // 4. Alphabetical fallback. + ResolvedSubroom? metaEntry = null; + int? metaIndex = null; + string? metaFileName = null; + var metaCandidate = entries + .Where(kv => kv.Key.StartsWith($"{roomFolder}/", StringComparison.OrdinalIgnoreCase) + && !kv.Key[$"{roomFolder}/".Length..].Contains('/') + && kv.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) + .Select(kv => kv.Value) + .FirstOrDefault(); + if (metaCandidate is not null) + { + metaFileName = Path.GetFileName(metaCandidate.FullName); + var metaBytes = await ReadAllAsync(metaCandidate); + metaIndex = TryReadEntrySubroomIndexFromMeta(metaBytes); + logger.LogInformation("[zip-import] room='{Room}' .meta={Meta} bytes={Bytes} entryIndex={Idx}", + name, metaFileName, metaBytes.Length, metaIndex); + if (metaIndex is int idx && idx >= 0 && idx < manifestOrder.Count) + { + var sceneAtIndex = manifestOrder[idx]; + metaEntry = resolvedScenes.FirstOrDefault(r => + string.Equals(r.SceneName, sceneAtIndex, StringComparison.OrdinalIgnoreCase)); + } + } + + ResolvedSubroom entry; + string entrySource; + if (!string.IsNullOrWhiteSpace(details.EntryScene) + && resolvedScenes.FirstOrDefault(r => string.Equals(r.SceneName, details.EntryScene, StringComparison.OrdinalIgnoreCase)) is { } overrideEntry) + { + entry = overrideEntry; entrySource = "RoomDetails.EntryScene"; + } + else if (metaEntry is not null) + { + entry = metaEntry; entrySource = $"meta_index={metaIndex}"; + } + else + { + entry = resolvedScenes[0]; entrySource = manifestOrder.Count > 0 ? "manifest_first" : "alphabetical_first"; + } + + // Image: try ImageName lookup first (RecNet-style hash filename), + // then RoomImage.* fallback (convenience format). + ZipArchiveEntry? imageEntry = null; + if (!string.IsNullOrWhiteSpace(details.ImageName) + && entries.TryGetValue($"{roomFolder}/{details.ImageName}", out var hashed)) + { + imageEntry = hashed; + } + else + { + imageEntry = entries.FirstOrDefault(kv => + kv.Key.StartsWith($"{roomFolder}/RoomImage.", StringComparison.OrdinalIgnoreCase)).Value; + } + + // Reorder so entry is first. Manifest order preserved for the rest. + var ordered = new List { entry }; + ordered.AddRange(resolvedScenes.Where(r => !ReferenceEquals(r, entry))); + + var slug = name.ToLowerInvariant().Replace(' ', '_'); + // Prefix with room__ so BlobRouter parks every .room scene + // save alongside its room__v*.dat versioned snapshots in + // saves/room//. Same routing parser handles both shapes; + // no per-extension special case. + string MakeBlobName(string sceneName) => $"room_{roomId}_{slug}_{sceneName.ToLowerInvariant()}.room"; + + // Use the entry subroom's UnitySceneId as the room's location. + var entryLocation = entry.Manifest.UnitySceneId ?? "a75f7547-79eb-47c6-8986-6767abcb4f92"; + + // Persist .htr assets and PV images at the GLOBAL level + // (RoomId=0) under their original hash filenames. The CDN + // / img.* controllers look up by exact blob name, so this + // matches what HtrAssetMirrorService would have produced if it + // had to fetch them from rec.net. + // + // We track three counters per asset type: + // * referenced — total unique filenames the archive points at + // * imported — newly inserted into RoomDataBlobs this run + // * alreadyInDb — already present from a prior import / mirror + // The third is important to surface: bulk-purge-custom keeps + // shared assets intentionally (other rooms might use them), + // so a re-import after purge will legitimately report 0 newly + // imported with non-zero already-in-DB. Reporting only "0 + // imported" reads as "no HTR uploaded" which is wrong. + var htrAssetCount = 0; + var pvImageCount = 0; + var polaroidCount = 0; + var allHtrAssets = ordered.SelectMany(s => s.HtrAssets).ToList(); + var allPvImages = ordered.SelectMany(s => s.PvImages).ToList(); + var allPolaroids = ordered.SelectMany(s => s.Polaroids).ToList(); + + // De-dupe within the archive first (same .htr can appear under + // multiple scenes), then de-dupe against the DB. Both layers + // matter for accurate reporting. + var uniqueHtrNames = allHtrAssets.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var uniquePvNames = allPvImages.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var uniquePolaroidNames = allPolaroids.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + var existingHtrNames = new HashSet( + await db.RoomDataBlobs + .Where(b => uniqueHtrNames.Contains(b.BlobName)) + .Select(b => b.BlobName).ToListAsync(), + StringComparer.OrdinalIgnoreCase); + var existingPvNames = new HashSet( + await db.RoomDataBlobs + .Where(b => uniquePvNames.Contains(b.BlobName)) + .Select(b => b.BlobName).ToListAsync(), + StringComparer.OrdinalIgnoreCase); + var existingPolaroidNames = new HashSet( + await db.RoomDataBlobs + .Where(b => uniquePolaroidNames.Contains(b.BlobName)) + .Select(b => b.BlobName).ToListAsync(), + StringComparer.OrdinalIgnoreCase); + var htrAlreadyInDbAtStart = existingHtrNames.Count; + var pvAlreadyInDbAtStart = existingPvNames.Count; + var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count; + + var imageBlobName = string.Empty; + + await using (var tx = await db.Database.BeginTransactionAsync()) + { + // Subroom blobs + foreach (var s in ordered) + { + await WriteBlobAsync(roomId, MakeBlobName(s.SceneName), s.Bytes, creator); + } + // HTR assets (shared content — keyed by hash, RoomId=0) + foreach (var (hname, hentry) in allHtrAssets) + { + if (existingHtrNames.Contains(hname)) continue; + existingHtrNames.Add(hname); + var bytes = await ReadAllAsync(hentry); + await WriteBlobAsync(0, hname, bytes, creator); + htrAssetCount++; + } + // PV images (persistence-view previews; shared) + foreach (var (pname, pentry) in allPvImages) + { + if (existingPvNames.Contains(pname)) continue; + existingPvNames.Add(pname); + var bytes = await ReadAllAsync(pentry); + await WriteBlobAsync(0, pname, bytes, creator); + pvImageCount++; + } + // Polaroids (placed in-world; watch fetches by bare hash). + foreach (var (polName, polEntry) in allPolaroids) + { + if (existingPolaroidNames.Contains(polName)) continue; + existingPolaroidNames.Add(polName); + var bytes = await ReadAllAsync(polEntry); + await WriteBlobAsync(0, polName, bytes, creator); + polaroidCount++; + } + // Room-root opaque marker files (e.g. .meta — 6-byte + // sentinel the exporter drops, purpose unclear but watching + // for fetches just in case). Rename to room__.meta + // so BlobRouter parks them in saves/room// alongside the + // versioned save snapshots — keeps every byte that belongs + // to a specific room in one S3 folder. + var rootMetaPrefix = $"{roomFolder}/"; + foreach (var (k, mentry) in entries.Where(kv => + kv.Key.StartsWith(rootMetaPrefix, StringComparison.OrdinalIgnoreCase) + && !kv.Key[rootMetaPrefix.Length..].Contains('/') + && kv.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) + { + var originalName = Path.GetFileName(k); + var fname = $"room_{roomId}_{originalName}"; + if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue; + var bytes = await ReadAllAsync(mentry); + await WriteBlobAsync(roomId, fname, bytes, creator); + } + + // Room image + if (imageEntry is not null) + { + var bytes = await ReadAllAsync(imageEntry); + var ext = Path.GetExtension(imageEntry.Name).ToLowerInvariant(); + if (string.IsNullOrEmpty(ext)) ext = ".jpg"; + imageBlobName = $"image_room_{roomId}{ext}"; + await WriteBlobAsync(0, imageBlobName, bytes, creator); + } + + // Promo images (kept under their original hashed names so + // the room's promo strip resolves them via img.* CDN). + var promoPrefix = $"{roomFolder}/Promo/Image/"; + foreach (var (k, pentry) in entries.Where(kv => kv.Key.StartsWith(promoPrefix, StringComparison.OrdinalIgnoreCase))) + { + var fname = Path.GetFileName(k); + if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue; + var bytes = await ReadAllAsync(pentry); + await WriteBlobAsync(0, fname, bytes, creator); + } + + db.Rooms.Add(new RoomEntity + { + Id = roomId, + Name = name, + Description = details.Description?.Trim() ?? $"Imported from zip ({ordered.Count} scenes)", + CreatorPlayerId = creator, + ImageName = imageBlobName, + State = details.State ?? 0, + Accessibility = details.Accessibility ?? 1, + IsAGRoom = false, + IsDormRoom = details.IsDorm ?? false, + CloningAllowed = details.CloningAllowed ?? true, + SupportsLevelVoting = details.SupportsLevelVoting ?? false, + SupportsVRLow = details.SupportsVRLow ?? true, + SupportsMobile = details.SupportsMobile ?? false, + SupportsScreens = details.SupportsScreens ?? true, + SupportsWalkVR = details.SupportsWalkVR ?? true, + SupportsTeleportVR = details.SupportsTeleportVR ?? true, + AllowsJuniors = details.SupportsJuniors ?? true, + RoomWarningMask = details.WarningMask ?? 0, + CustomRoomWarning = details.CustomWarning ?? string.Empty, + DisableMicAutoMute = details.DisableMicAutoMute ?? false, + LocationReplicationId = entryLocation, + TagsCsv = JoinTags(details.Tags), + CheerCount = details.Stats?.CheerCount ?? 0, + FavoriteCount = details.Stats?.FavoriteCount ?? 0, + VisitCount = details.Stats?.VisitCount ?? 0, + VisitorCount = details.Stats?.VisitorCount ?? 0, + HotScore = 5.0, + CurrentDataBlobName = MakeBlobName(entry.SceneName), + CreatedAt = details.CreatedAt ?? DateTime.UtcNow, + }); + + int idx = 0; + foreach (var s in ordered) + { + db.RoomScenes.Add(new RoomSceneEntity + { + RoomId = roomId, + OrderIndex = idx++, + Name = s.SceneName, + RoomSceneLocationId = s.Manifest.UnitySceneId ?? entryLocation, + DataBlobName = MakeBlobName(s.SceneName), + MaxPlayers = s.Manifest.MaxPlayers ?? 8, + IsSandbox = s.Manifest.IsSandbox ?? false, + CanMatchmakeInto = true, + DataModifiedAt = DateTime.UtcNow, + }); + } + + db.AdminActions.Add(new AdminActionEntity + { + AdminPlayerId = CurrentAdminId, + Action = "zip_import_room", + TargetType = "room", + TargetId = roomId, + Reason = $"name={name} scenes={ordered.Count} entry={entry.SceneName} src={entrySource} " + + $"htr={htrAssetCount}new+{htrAlreadyInDbAtStart}existed " + + $"pv={pvImageCount}new+{pvAlreadyInDbAtStart}existed " + + $"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed", + }); + await db.SaveChangesAsync(); + await tx.CommitAsync(); + } + + return new + { + name, + ok = true, + roomId, + folder = roomFolder, + sceneCount = ordered.Count, + entryScene = entry.SceneName, + entryDetectionSource = entrySource, + missingScenes = missingScenes.Count > 0 ? missingScenes : null, + tags = details.Tags?.Select(t => t.Tag).Where(t => !string.IsNullOrWhiteSpace(t)).ToList(), + imageBlob = string.IsNullOrEmpty(imageBlobName) ? null : imageBlobName, + htrAssets = new + { + referenced = uniqueHtrNames.Count, + newlyImported = htrAssetCount, + alreadyInDb = htrAlreadyInDbAtStart, + }, + pvImages = new + { + referenced = uniquePvNames.Count, + newlyImported = pvImageCount, + alreadyInDb = pvAlreadyInDbAtStart, + }, + polaroids = new + { + referenced = uniquePolaroidNames.Count, + newlyImported = polaroidCount, + alreadyInDb = polaroidAlreadyInDbAtStart, + }, + // Legacy fields kept for back-compat with anything that + // already reads them; new code should prefer htrAssets / + // pvImages / polaroids above. + htrAssetsImported = htrAssetCount, + pvImagesImported = pvImageCount, + stats = new + { + cheers = details.Stats?.CheerCount ?? 0, + favorites = details.Stats?.FavoriteCount ?? 0, + visitors = details.Stats?.VisitorCount ?? 0, + visits = details.Stats?.VisitCount ?? 0, + }, + scenes = ordered.Select((s, i) => new + { + orderIndex = i, + name = s.SceneName, + blobName = MakeBlobName(s.SceneName), + bytes = s.Bytes.Length, + rawBytes = s.RawBytes, + normalized = s.NormalizedOk, + unitySceneId = s.Manifest.UnitySceneId, + isSandbox = s.Manifest.IsSandbox, + maxPlayers = s.Manifest.MaxPlayers, + htrAssets = s.HtrAssets.Count, + pvImages = s.PvImages.Count, + }), + }; + } + + // ── Per-invention import ───────────────────────────────────────── + + private async Task ImportInventionAsync( + string folder, + long creator, + Dictionary entries) + { + // Invention.json + var meta = await ReadJsonAsync(entries[$"{folder}/Invention.json"]) + ?? new InventionJsonDto(); + InventionVersionDto version = entries.TryGetValue($"{folder}/InventionVersion.json", out var ve) + ? await ReadJsonAsync(ve) ?? new InventionVersionDto() + : new InventionVersionDto(); + InventionDetailsDto extras = entries.TryGetValue($"{folder}/InventionDetails.json", out var de) + ? await ReadJsonAsync(de) ?? new InventionDetailsDto() + : new InventionDetailsDto(); + + var name = !string.IsNullOrWhiteSpace(meta.Name) ? meta.Name!.Trim() : Path.GetFileName(folder); + + // Find the .inv blob — by manifest reference if present, else + // first .inv file in the invention folder. + ZipArchiveEntry? blobEntry = null; + if (!string.IsNullOrWhiteSpace(version.BlobName) + && entries.TryGetValue($"{folder}/{version.BlobName}", out var byManifest)) + { + blobEntry = byManifest; + } + else + { + blobEntry = entries.FirstOrDefault(kv => + kv.Key.StartsWith($"{folder}/", StringComparison.OrdinalIgnoreCase) + && kv.Key.EndsWith(".inv", StringComparison.OrdinalIgnoreCase)).Value; + } + if (blobEntry is null) + return new { name, ok = false, error = "no_inv_blob", folder }; + + // Image (optional) + ZipArchiveEntry? imageEntry = null; + if (!string.IsNullOrWhiteSpace(meta.ImageName) + && entries.TryGetValue($"{folder}/{meta.ImageName}", out var byHash)) + { + imageEntry = byHash; + } + else + { + imageEntry = entries.FirstOrDefault(kv => + kv.Key.StartsWith($"{folder}/InventionImage.", StringComparison.OrdinalIgnoreCase)).Value; + } + + var blobBytes = await ReadAllAsync(blobEntry); + + var entity = new InventionEntity + { + CreatorPlayerId = creator, + ReplicationId = !string.IsNullOrWhiteSpace(meta.ReplicationId) + ? meta.ReplicationId!.Trim() + : Guid.NewGuid().ToString("N"), + Name = name, + Description = meta.Description?.Trim() ?? string.Empty, + ImageName = string.Empty, // set below after we know the blob name + IsAgInvention = meta.IsAgInvention ?? true, + IsPublished = false, + Permission = meta.Accessibility ?? 0, + CreatorPermission = meta.CreatorPermission ?? 100, + GeneralPermission = meta.GeneralPermission ?? 0, + CurrentVersionNumber = meta.CurrentVersionNumber ?? version.VersionNumber ?? 1, + NumPlayersHaveUsedInRoom = meta.NumPlayersHaveUsedInRoom ?? 0, + CheerCount = meta.CheerCount ?? 0, + CreationRoomId = meta.CreationRoomId, + TagsCsv = JoinTags(extras.Tags), + CreatedAt = meta.CreatedAt ?? DateTime.UtcNow, + UpdatedAt = meta.ModifiedAt ?? DateTime.UtcNow, + FirstPublishedAt = meta.FirstPublishedAt, + }; + db.Inventions.Add(entity); + await db.SaveChangesAsync(); + + var blobName = $"invention_{entity.Id}_v{entity.CurrentVersionNumber}.dat"; + entity.CurrentBlobName = blobName; + await WriteBlobAsync(0, blobName, blobBytes, creator); + + string? imageBlobName = null; + if (imageEntry is not null) + { + var bytes = await ReadAllAsync(imageEntry); + var ext = Path.GetExtension(imageEntry.Name).ToLowerInvariant(); + if (string.IsNullOrEmpty(ext)) ext = ".jpg"; + imageBlobName = $"image_invention_{entity.Id}{ext}"; + entity.ImageName = imageBlobName; + await WriteBlobAsync(0, imageBlobName, bytes, creator); + } + + db.AdminActions.Add(new AdminActionEntity + { + AdminPlayerId = CurrentAdminId, + Action = "zip_import_invention", + TargetType = "invention", + TargetId = entity.Id, + Reason = $"name={name} blob={blobName} bytes={blobBytes.Length}", + }); + await db.SaveChangesAsync(); + + logger.LogInformation( + "[zip-import] invention id={Id} name={Name} bytes={Bytes:N0} image={Image}", + entity.Id, name, blobBytes.Length, imageBlobName ?? "(none)"); + + return new + { + name, + ok = true, + inventionId = entity.Id, + folder, + blobName, + imageBlob = imageBlobName, + bytes = blobBytes.Length, + tags = extras.Tags?.Select(t => t.Tag).Where(t => !string.IsNullOrWhiteSpace(t)).ToList(), + }; + } + + // ── Helpers ────────────────────────────────────────────────────── + + private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/'); + + private static async Task ReadAllAsync(ZipArchiveEntry entry) + { + await using var s = entry.Open(); + using var ms = new MemoryStream(); + await s.CopyToAsync(ms); + return ms.ToArray(); + } + + private static async Task ReadJsonAsync(ZipArchiveEntry entry) + { + await using var s = entry.Open(); + return await JsonSerializer.DeserializeAsync(s, JsonOpts); + } + + private static string JoinTags(List? tags) + { + if (tags is null || tags.Count == 0) return "community"; + var values = tags + .Select(t => t.Tag?.Trim()) + .Where(t => !string.IsNullOrEmpty(t)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + return values.Count > 0 ? string.Join(',', values!) : "community"; + } + + private sealed record ResolvedSubroom( + SubRoomDto Manifest, + string SceneName, + byte[] Bytes, + int RawBytes, + bool NormalizedOk, + List<(string Name, ZipArchiveEntry Entry)> HtrAssets, + List<(string Name, ZipArchiveEntry Entry)> PvImages, + List<(string Name, ZipArchiveEntry Entry)> Polaroids); + + /// Decode RecNet's room-snapshot manifest .meta and + /// return the entry-subroom index it encodes, or null if the file + /// isn't shaped like one. The full .proto schema isn't part + /// of the generated C# bindings, but the known-sample bytes are + /// trivially small and parseable by hand: + /// + /// 10 01 1A 02 08 01 + /// ┬──── ┬────────── + /// │ │ + /// │ └─ field 3 (length-delimited), inner = { field 1 (varint) = 1 } + /// └─ field 2 (varint) = 1 + /// + /// Empirically field 2 is the entry subroom INDEX into + /// RoomDetails.SubRooms[] (0-based), and field 3's nested + /// field 1 echoes the same value (probably "published version = + /// 1"). We only need field 2 for the entry-selection decision. + private static int? TryReadEntrySubroomIndexFromMeta(byte[] bytes) + { + if (bytes is null || bytes.Length == 0) return null; + try + { + using var input = new CodedInputStream(bytes); + while (!input.IsAtEnd) + { + var tag = input.ReadTag(); + var field = WireFormat.GetTagFieldNumber(tag); + var wire = WireFormat.GetTagWireType(tag); + if (field == 2 && wire == WireFormat.WireType.Varint) + { + return (int)input.ReadInt64(); + } + input.SkipLastField(); + } + } + catch + { + return null; + } + return null; + } +} diff --git a/DorkNet.Server/Controllers/Admin/StorageBackfillController.cs b/DorkNet.Server/Controllers/Admin/StorageBackfillController.cs new file mode 100644 index 0000000..3a96a38 --- /dev/null +++ b/DorkNet.Server/Controllers/Admin/StorageBackfillController.cs @@ -0,0 +1,230 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Admin; + +/// +/// admin.*/api/admin/v1/storage/backfill — one-shot migrator that +/// moves the legacy RoomDataBlobs.Bytes column out of the +/// database and into S3 at the canonical key from +/// . After every row's bytes have been +/// uploaded and verified, a follow-up EF migration drops the +/// Bytes column entirely. +/// +/// Why this exists: every controller that writes new content already +/// targets S3 directly (the importer, photo upload, profile image, +/// dorm save, room save, …). The Bytes column only holds +/// pre-cutover data — rooms imported before the S3-only refactor +/// landed. This endpoint drains that legacy column into S3 once and +/// then we never look at it again. +/// +/// Runs as a background task so the HTTP request returns immediately +/// even for a multi-GB drain. Progress streams to the server log and +/// to a small in-memory snapshot the UI +/// can poll. Idempotent — each row is checked against +/// first, and rows whose +/// bytes are already in S3 get their DB Bytes cleared without +/// re-upload. +/// +[ApiController] +[Route("api/admin/v1/storage")] +[Authorize] +[AdminOnly] +public class StorageBackfillController( + ILogger log, + IServiceScopeFactory scopeFactory) : ControllerBase +{ + private static readonly object _gate = new(); + private static BackfillStatus _status = BackfillStatus.Idle; + + /// POST /storage/backfill?dryRun=true — start a + /// drain pass. Returns 200 immediately; the actual work happens + /// in a background task. Re-POSTing while running returns 409. + /// Set ?dryRun=true on the first pass to log every + /// (row → bucket/key) decision without writing to S3 or clearing + /// the DB Bytes column. + [HttpPost("backfill")] + public IActionResult Start([FromQuery] bool dryRun = false) + { + lock (_gate) + { + if (_status.Running) + return Conflict(new { error = "already_running", since = _status.StartedAt }); + _status = BackfillStatus.Starting(dryRun); + } + _ = Task.Run(() => RunAsync(dryRun)); + return Ok(new { started = true, dryRun }); + } + + /// GET /storage/backfill/status — current + /// progress. Updated row-by-row during a run. + [HttpGet("backfill/status")] + public IActionResult Status() => Ok(_status); + + private async Task RunAsync(bool dryRun) + { + var startedAt = DateTime.UtcNow; + + try + { + // Phase 1: pull the list of row ids that still have bytes. + // Materialise just the ids (not the bytes) up-front so we + // don't hold a reader open across per-row S3 PUTs — that + // would conflict with the per-row ExecuteUpdateAsync on + // Npgsql, which doesn't allow a second command while a + // reader is mid-iteration. Bytes get fetched one row at a + // time below so RAM only holds one blob at a time. + // Filter on Bytes != null only — Npgsql can't translate + // .Length on a byte[] property (it sees it as Enumerable.Any + // and bails). Empty-byte-array rows don't exist in our + // schema; rows either hold real bytes or NULL after backfill. + // We still re-check Length on the materialised row before + // uploading, defensive against any future client-evaluated + // edge case. + List rowIds; + long total; + await using (var scope = scopeFactory.CreateAsyncScope()) + { + var dbScoped = scope.ServiceProvider.GetRequiredService(); + rowIds = await dbScoped.RoomDataBlobs + .AsNoTracking() + .Where(b => b.Bytes != null) + .OrderBy(b => b.Id) + .Select(b => b.Id) + .ToListAsync(); + total = rowIds.Count; + } + log.LogInformation( + "[backfill] starting — rowsWithBytes={Total} dryRun={DryRun}", + total, dryRun); + lock (_gate) _status = _status with { Total = total }; + + // Phase 2: per-row upload + clear. Each row gets its own + // short-lived DbContext so the reader/writer conflict is + // structurally impossible. Memory-bound at one row's + // bytes plus query overhead. + foreach (var rowId in rowIds) + { + await using var rowScope = scopeFactory.CreateAsyncScope(); + var rowDb = rowScope.ServiceProvider.GetRequiredService(); + var storageScoped = rowScope.ServiceProvider.GetRequiredService(); + + var row = await rowDb.RoomDataBlobs + .AsNoTracking() + .Where(b => b.Id == rowId) + .Select(b => new { b.Id, b.BlobName, b.Bytes }) + .FirstOrDefaultAsync(); + if (row is null || row.Bytes is null || row.Bytes.Length == 0) + { + Bump(s => s with { Skipped = s.Skipped + 1 }); + continue; + } + + var (bucket, key) = BlobRouter.Route(row.BlobName); + try + { + if (dryRun) + { + log.LogInformation( + "[backfill:dry] {Blob} → {Bucket}/{Key} ({Bytes} B)", + row.BlobName, bucket, key, row.Bytes.Length); + Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName }); + continue; + } + + // Upload unconditionally (S3 PUT is idempotent / + // overwriting). Verifying with ExistsAsync first + // would let us skip already-mirrored rows, but a + // mismatch between S3 and DB bytes (corrupted + // earlier upload, partial backfill, etc.) would + // leave the divergence in place. Re-uploading + // guarantees S3 == DB before we clear DB. + var contentType = MimeFromName(row.BlobName); + // Per-row timeout: 4 minutes generous enough for a + // 100 MB blob over a 5 Mbps origin link, well + // inside the 5-minute S3 SDK ceiling. Without a + // CT, a hung S3 connection could freeze the whole + // drain indefinitely. + using var putCt = new CancellationTokenSource(TimeSpan.FromMinutes(4)); + await storageScoped.PutAsync(bucket, key, row.Bytes, contentType, putCt.Token); + + // SAFETY GATE: confirm S3 actually persisted the + // object before dropping the DB copy. PutAsync + // returning success isn't enough — some S3 impls + // can ack a write that disappears (eventual + // consistency, replication lag, write buffer + // dropped on shutdown). HEAD costs nothing. + using var headCt = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + if (!await storageScoped.ExistsAsync(bucket, key, headCt.Token)) + throw new InvalidOperationException( + $"S3 PUT {bucket}/{key} returned success but ExistsAsync says no — refusing to clear DB bytes"); + + await rowDb.RoomDataBlobs + .Where(b => b.Id == row.Id) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.Bytes, (byte[]?)null)); + Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName }); + } + catch (Exception ex) + { + log.LogWarning(ex, "[backfill] failed {Blob} → {Bucket}/{Key} — DB bytes left intact", + row.BlobName, bucket, key); + Bump(s => s with { Failed = s.Failed + 1, LastBlobName = row.BlobName }); + } + } + + var elapsed = (int)(DateTime.UtcNow - startedAt).TotalMilliseconds; + lock (_gate) _status = _status with + { + Running = false, + FinishedAt = DateTime.UtcNow, + ElapsedMs = elapsed, + }; + log.LogInformation( + "[backfill] finished — total={Total} uploaded={Up} alreadyMirrored={Already} skipped={Skip} failed={Fail} dryRun={Dry} elapsed={Ms}ms", + _status.Total, _status.Uploaded, _status.AlreadyMirrored, _status.Skipped, _status.Failed, dryRun, elapsed); + } + catch (Exception ex) + { + log.LogError(ex, "[backfill] crashed"); + lock (_gate) _status = _status with { Running = false, FinishedAt = DateTime.UtcNow, Error = ex.Message }; + } + } + + private static void Bump(Func mutate) + { + lock (_gate) _status = mutate(_status); + } + + private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".mp4" => "video/mp4", + _ => "application/octet-stream", + }; + + public sealed record BackfillStatus( + bool Running, + bool DryRun, + DateTime? StartedAt, + DateTime? FinishedAt, + long Total, + long Uploaded, + long AlreadyMirrored, + long Skipped, + long Failed, + string? LastBlobName, + int? ElapsedMs, + string? Error) + { + public static BackfillStatus Idle => new(false, false, null, null, 0, 0, 0, 0, 0, null, null, null); + public static BackfillStatus Starting(bool dryRun) => + new(true, dryRun, DateTime.UtcNow, null, 0, 0, 0, 0, 0, null, null, null); + } +} diff --git a/DorkNet.Server/Controllers/Auth/AuthAccountController.cs b/DorkNet.Server/Controllers/Auth/AuthAccountController.cs new file mode 100644 index 0000000..767be6c --- /dev/null +++ b/DorkNet.Server/Controllers/Auth/AuthAccountController.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Controllers.Auth; + +/// +/// auth.rec.net/account/me/* — password and account-recovery endpoints +/// the client invokes from the in-game settings screen. Real persistence: +/// the password is stored as a BCrypt hash on +/// ; +/// HasPassword reflects whether the column is non-null; ChangePassword +/// requires the old password to match (or for the column to be null — +/// first-time set); ResetPassword is admin-gated for now. +/// +[ApiController] +public class AuthAccountController(DorkNetDbContext db) : ControllerBase +{ + /// GET /account/me/haspassword — returns whether the + /// caller has a password set. Client uses + /// ExpectPrimitiveResponse<bool> so the body must be + /// the literal JSON true or false. + [HttpGet("/account/me/haspassword")] + [HttpGet("/api/platformlogin/haspassword")] + [Authorize] + public async Task HasPassword() + { + var pid = this.RequireCurrentPlayerId(); + var hasPwd = await db.Players + .Where(p => p.Id == pid) + .Select(p => p.PasswordHash != null) + .FirstOrDefaultAsync(); + return Content(hasPwd ? "true" : "false", "application/json"); + } + + /// POST /account/me/changepassword (and aliases). When the + /// account already has a password the caller must supply the old + /// one; first-time creation skips that check. Form-encoded body. + [HttpPost("/account/me/changepassword")] + [HttpPost("/account/me/createpassword")] + [HttpPost("/api/platformlogin/password")] + [HttpPut("/api/platformlogin/password")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + [Authorize] + public async Task ChangePassword( + [FromForm] string? oldPassword, + [FromForm] string? newPassword) + { + if (string.IsNullOrEmpty(newPassword) || newPassword.Length < 6) + return Ok(new { success = false, error = "password_too_short" }); + + var pid = this.RequireCurrentPlayerId(); + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid); + if (player is null) return Unauthorized(); + + // First-time create: PasswordHash is null and oldPassword is + // ignored. Subsequent changes require the existing password to + // verify so a stolen JWT can't silently rotate the password. + if (!string.IsNullOrEmpty(player.PasswordHash)) + { + if (string.IsNullOrEmpty(oldPassword) || + !BCrypt.Net.BCrypt.Verify(oldPassword, player.PasswordHash)) + return Ok(new { success = false, error = "old_password_mismatch" }); + } + + player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword, workFactor: 11); + await db.SaveChangesAsync(); + return Ok(new { success = true, error = "" }); + } + + /// POST /account/me/resetpassword — sends a reset link. + /// Without an SMTP integration we have nothing to send to, so this + /// is admin-only: an admin can clear the password column on any + /// account from the admin tab and the user re-creates it via + /// changepassword on next login. Anonymous calls 401. + [HttpPost("/account/me/resetpassword")] + [HttpPost("/api/platformlogin/resetpassword")] + [Authorize] + [AdminOnly] + public async Task AdminResetPassword( + [FromForm] long? targetPlayerId) + { + if (targetPlayerId is null) + return Ok(new { success = false, error = "missing_target" }); + var player = await db.Players.FirstOrDefaultAsync(p => p.Id == targetPlayerId); + if (player is null) return Ok(new { success = false, error = "unknown_player" }); + player.PasswordHash = null; + await db.SaveChangesAsync(); + return Ok(new { success = true, error = "" }); + } +} diff --git a/DorkNet.Server/Controllers/Auth/AuthController.cs b/DorkNet.Server/Controllers/Auth/AuthController.cs new file mode 100644 index 0000000..a6c7e81 --- /dev/null +++ b/DorkNet.Server/Controllers/Auth/AuthController.cs @@ -0,0 +1,307 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Auth; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Auth; + +/// +/// auth.rec.net — OAuth-style token endpoint used by 2019+ builds alongside PlatformLogin. +/// +[ApiController] +public class AuthController( + PlayerService playerService, + AuthService authService, + LevelService level, + OrphanAccountTracker orphans, + ServerSettingsService settings, + DorkNet.Server.Data.DorkNetDbContext db, + DomainConfig domain, + ILogger logger) : ControllerBase +{ + /// Award the per-day first-login XP if the calling + /// player hasn't been seen yet today (UTC). + private async Task TryAwardLoginXpAsync(long playerId) + { + var lastSeen = await db.Players + .Where(p => p.Id == playerId) + .Select(p => p.LastSeenAt) + .FirstOrDefaultAsync(); + if (lastSeen.Date < DateTime.UtcNow.Date) + await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today"); + } + // OAuth token endpoint. The 2020 client uses three grant types here: + // - grant_type=cached_login → user clicked a remembered account on the + // account-selection screen. Body includes `account_id` (and platform + // creds). MUST issue a token for THAT specific account; otherwise the + // client receives a JWT for a different account and prompts the user + // to create a new one (because the JWT identity doesn't match what + // they picked). + // - grant_type=refresh_token → re-issue access token from the existing + // refresh JWT. + // - grant_type=password → first-time sign-in, resolve via deviceId + // (preferred) or fall back to a stable synthetic id derived from + // username so legacy callers don't share accounts. + [HttpPost("/connect/token")] + [Consumes("application/x-www-form-urlencoded")] + public async Task Token( + [FromForm] string? grant_type, + [FromForm] string? username, + [FromForm] string? password, + [FromForm] string? refresh_token, + [FromForm] string? deviceId, + [FromForm(Name = "device_id")] string? device_id_snake, + [FromForm] int platform = 0, + [FromForm] string? platformId = null, + [FromForm(Name = "platform_id")] string? platform_id_snake = null, + [FromForm] long? account_id = null) + { + // The client mixes camelCase and snake_case parameter naming + // depending on grant type — accept both and merge. + var effDeviceId = !string.IsNullOrWhiteSpace(deviceId) ? deviceId : device_id_snake; + var effPlatformId = !string.IsNullOrWhiteSpace(platformId) ? platformId : platform_id_snake; + + if (string.Equals(grant_type, "cached_login", StringComparison.OrdinalIgnoreCase)) + { + if (account_id is not long pickedId) + return BadRequest(new { error = "invalid_request", error_description = "missing account_id" }); + var picked = await playerService.GetByIdAsync(pickedId); + if (picked is null) + return Unauthorized(new { error = "invalid_grant", error_description = "unknown account_id" }); + + // Orphan cleanup: if the watch just created a brand-new + // account on account/create as part of its boot probe, but + // the user actually picked a different cached account here, + // the just-created row is unused. Delete it so the DB + // doesn't accumulate one orphan per game launch. When the + // user is genuinely creating a new account, the picked id + // matches the just-created id and we leave it. + var pendingId = orphans.PeekPending(effDeviceId, effPlatformId); + if (pendingId is long orphanId && orphanId != pickedId) + { + await playerService.DeleteOrphanAsync(orphanId); + logger.LogInformation( + "[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}", + pickedId, orphanId); + } + orphans.Clear(effDeviceId, effPlatformId); + + await TryAwardLoginXpAsync(picked.Id); + await playerService.TagPlatformAsync(picked.Id, platform, effPlatformId); + var (acc1, refr1) = authService.GenerateTokenPair(picked.Id); + return Ok(new + { + access_token = acc1, + token_type = "Bearer", + expires_in = 43200, + refresh_token = refr1, + scope = "openid profile", + }); + } + + if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(refresh_token)) + return BadRequest(new { error = "invalid_request" }); + var resolved = authService.ValidateToken(refresh_token); + if (resolved is not long refreshedId) + return Unauthorized(new { error = "invalid_grant" }); + var existing = await playerService.GetByIdAsync(refreshedId); + if (existing is null) + return Unauthorized(new { error = "invalid_grant" }); + var (acc, refr) = authService.GenerateTokenPair(existing.Id); + return Ok(new + { + access_token = acc, + token_type = "Bearer", + expires_in = 43200, + refresh_token = refr, + scope = "openid profile", + }); + } + + // password grant: two paths. + // • Username + password supplied: real password verify against + // PlayerEntity.PasswordHash. Wrong password → 401. + // • No password: legacy device-id synth path for the 2020 + // client's first-launch flow where the player hasn't set a + // password yet (HasPassword returns false in the watch UI + // and prompts them to create one). Resolves the existing + // account (or creates a fresh one) by deviceId. + if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username)) + { + var byName = await playerService.GetByUsernameAsync(username); + if (byName is null || byName.PasswordHash is null) + return Unauthorized(new + { + error = "invalid_grant", + error_description = "unknown_user_or_no_password", + }); + if (!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash)) + return Unauthorized(new + { + error = "invalid_grant", + error_description = "password_mismatch", + }); + // Tag the platform so the next boot's cached-login lookup + // finds this account — without this the watch's "remembered + // accounts" prompt would stay empty after a manual sign-in. + await playerService.TagPlatformAsync(byName.Id, platform, effPlatformId); + await TryAwardLoginXpAsync(byName.Id); + var pair = authService.GenerateTokenPair(byName.Id); + return Ok(new + { + access_token = pair.AccessToken, + token_type = "Bearer", + expires_in = 43200, + refresh_token = pair.RefreshToken, + scope = "openid profile", + }); + } + + // Device-id fallback path. Prefer the real deviceId; fall back + // to a stable legacy synthesis from username so older callers + // don't accidentally share an account. + var effectiveDeviceId = !string.IsNullOrWhiteSpace(effDeviceId) + ? effDeviceId + : $"legacy-{(username ?? "anonymous").ToLowerInvariant()}"; + + // Honour the admin "signups disabled" toggle. If the device + // hasn't logged in before, GetOrCreateByDeviceAsync would mint + // a fresh account here — same outcome as hitting + // /account/create directly, which the toggle blocks. Refuse + // BEFORE creation so the flag isn't bypassable. + if (await settings.AreSignupsDisabledAsync()) + { + var existing = await playerService.GetByDeviceAsync(effectiveDeviceId); + if (existing is null) + { + logger.LogInformation( + "[auth] /connect/token device fallback refused — signups disabled (device={Device})", + effectiveDeviceId); + return Unauthorized(new + { + error = "signups_disabled", + error_description = "Account creation is currently disabled by the server admin.", + }); + } + } + + var player = await playerService.GetOrCreateByDeviceAsync( + deviceId: effectiveDeviceId, + platform: platform, + platformId: effPlatformId, + displayName: username); + await TryAwardLoginXpAsync(player.Id); + var (access, refresh) = authService.GenerateTokenPair(player.Id); + + return Ok(new + { + access_token = access, + token_type = "Bearer", + expires_in = 43200, + refresh_token = refresh, + scope = "openid profile", + }); + } + + [HttpGet("/connect/userinfo")] + public async Task UserInfo() + { + var auth = Request.Headers.Authorization.ToString(); + if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return Unauthorized(); + var token = auth["Bearer ".Length..].Trim(); + var id = authService.ValidateToken(token); + if (id is not long playerId) return Unauthorized(); + var player = await playerService.GetByIdAsync(playerId); + if (player is null) return Unauthorized(); + return Ok(new { sub = player.Id.ToString(), name = player.Username }); + } + + // Discovery document — issuer + endpoints derived from + // DomainConfig so a single DORKNET_DOMAIN env var keeps the + // OpenID document pointed at the live deployment. + [HttpGet("/.well-known/openid-configuration")] + public IActionResult OpenIdConfig() + { + var base_ = $"https://{domain.Sub("auth")}"; + return Ok(new + { + issuer = base_, + token_endpoint = $"{base_}/connect/token", + userinfo_endpoint = $"{base_}/connect/userinfo", + }); + } + + // Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager. + // URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId} + // Returns List. Empty array = no remembered accounts. + // + // Real lookup: any account whose last platformlogin matched + // (platform, platformId) shows up here, sorted most-recent-first. On a + // single-Steam-emu setup that's one row; on a multi-account dev box + // it's the full list the user has cycled through. + [HttpGet("/cachedlogin/forplatformid/{platform:int}/{platformId}")] + public async Task CachedLoginsForPlatform(int platform, string platformId) + { + var players = await playerService.GetCachedLoginsAsync(platform, platformId); + return Ok(players.Select(p => ToCachedLogin(p, platform, platformId)).ToList()); + } + + /// POST cachedlogin/forplatformids — bulk variant + /// the watch uses when hydrating multiple platform-id mappings + /// at once (e.g. populating a friend list from Steam ids). Body + /// is form-urlencoded platform=&platformIds=a,b,c or + /// JSON with the same shape. Returns the flattened + /// List<CachedLogin> across every matched id. + [HttpPost("/cachedlogin/forplatformids")] + [HttpGet("/cachedlogin/forplatformids")] + public async Task CachedLoginsForPlatformIds() + { + int platform = 0; + string? rawIds = null; + try + { + if (Request.HasFormContentType) + { + int.TryParse(Request.Form["platform"].ToString(), out platform); + rawIds = Request.Form["platformIds"].ToString(); + if (string.IsNullOrWhiteSpace(rawIds)) + rawIds = Request.Form["PlatformIds"].ToString(); + } + } + catch { /* not form */ } + if (string.IsNullOrWhiteSpace(rawIds)) + { + int.TryParse(Request.Query["platform"], out platform); + rawIds = Request.Query["platformIds"].ToString(); + if (string.IsNullOrWhiteSpace(rawIds)) + rawIds = Request.Query["PlatformIds"].ToString(); + } + if (string.IsNullOrWhiteSpace(rawIds)) return Ok(Array.Empty()); + + var ids = rawIds + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + var results = new List(); + foreach (var pid in ids) + { + var players = await playerService.GetCachedLoginsAsync(platform, pid); + foreach (var p in players) + results.Add(ToCachedLogin(p, platform, pid)); + } + return Ok(results); + } + + private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new() + { + Platform = platform, + PlatformId = platformId, + AccountId = (int)p.Id, + LastLoginTime = p.LastSeenAt, + RequirePassword = false, + }; + +} diff --git a/DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs b/DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs new file mode 100644 index 0000000..bebfde6 --- /dev/null +++ b/DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs @@ -0,0 +1,178 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Auth; + +/// +/// auth.localhost/photon/customauth — endpoint Photon Cloud calls during a +/// client connect when "Custom Authentication" is configured on the +/// Photon AppId. +/// +/// Photon Cloud sends a GET (or POST) with the parameters the client +/// attached via AuthenticationValues.AddAuthParameter. The +/// DorkNet BepInEx plugin (DorkNet.ClientPatch) injects two: +/// +/// userid — the player's DorkNet account id (long). +/// LoginLock — the per-session GUID stored in +/// at /player/login time. +/// +/// +/// Response shape (per +/// https://doc.photonengine.com/en-us/realtime/current/reference/custom-authentication): +/// +/// ResultCode = 1 → success, allow client +/// ResultCode = 2 → parameter invalid, reject +/// ResultCode = 3 → auth failed, reject +/// +/// +/// MODE: strict (2026-05-11). Rejects connects whose userid+LoginLock +/// don't match the most-recent /player/login. The "Reject if Auth +/// Failed" toggle in the Photon dashboard SHOULD also be on so a +/// rejected ResultCode actually closes the Photon socket — without +/// the toggle the rejection is informational only. +/// +/// Fail-open on Redis errors: +/// returns true if Redis throws, so a transient infra blip doesn't +/// mass-kick every player off Photon. +/// +[ApiController] +public class PhotonCustomAuthController( + PlayerPresenceService presence, + IConfiguration config, + ILogger log) : ControllerBase +{ + [HttpGet("/photon/customauth")] + [HttpPost("/photon/customauth")] + public async Task Authenticate() + { + // Photon forwards AuthGetParameters as querystring and + // AuthPostData as form body. Read both so it works whichever + // mode the dashboard picks. + // + // Multi-value handling: Photon's dashboard URL template can + // contain literal placeholders like ?userid={userid}, + // and the watch ALSO ships AuthValues.AuthGetParameters + // (which Photon appends to the URL). The two collide in the + // querystring as userid={userid}&userid=230343 — both + // values arrive on the same key. ASP.NET Core surfaces this + // as a multi-value StringValues; we keep the array so + // below can pick the first value + // that's actually a usable string (skipping unsubstituted + // {…} placeholders). + var qs = Request.Query + .ToDictionary(kv => kv.Key, kv => kv.Value.ToArray(), + StringComparer.OrdinalIgnoreCase); + Dictionary form = new(StringComparer.OrdinalIgnoreCase); + if (Request.HasFormContentType) + { + try + { + var f = await Request.ReadFormAsync(); + form = f.ToDictionary( + kv => kv.Key, + kv => kv.Value.ToArray()!, + StringComparer.OrdinalIgnoreCase); + } + catch { /* best effort */ } + } + + // Pick the first non-empty, non-placeholder value across + // every alias for a parameter. A "placeholder" looks like + // {userid} — Photon's dashboard URL template syntax + // that Photon Cloud forgets to substitute when the same key + // is also sent as an AuthGetParameter. Falling back to the + // SECOND query value (the AuthGetParameter) gives us the + // real id. + static bool IsUsable(string? v) + => !string.IsNullOrWhiteSpace(v) + && !(v!.StartsWith('{') && v.EndsWith('}')); + + string? PickClean(params string[] keys) + { + foreach (var k in keys) + { + if (qs.TryGetValue(k, out var arr)) + foreach (var v in arr) if (IsUsable(v)) return v; + if (form.TryGetValue(k, out var farr)) + foreach (var v in farr) if (IsUsable(v)) return v; + } + return null; + } + + var userIdRaw = PickClean("userid", "userId", "UserId"); + var loginLock = PickClean("LoginLock", "loginlock", "loginLock"); + + if (!config.GetValue("Photon:RequireCustomAuth", false)) + { + log.LogInformation( + "[photon-auth] fail-open allow RequireCustomAuth=false userid={UserId} LoginLockPresent={Lock}", + userIdRaw ?? "", + !string.IsNullOrWhiteSpace(loginLock)); + return Ok(new + { + ResultCode = 1, + Message = "ok (custom auth disabled)", + UserId = userIdRaw ?? string.Empty, + }); + } + + // Anonymous connect: pre-login Photon region pings + the watch's + // initial NameServer probe both fire CustomAuth WITHOUT + // AuthValues attached (the BepInEx plugin only injects AuthValues + // once /player/login has run and Core.LocalAccountId is set). + // Returning ResultCode 1 here lets those bootstrap connects + // succeed; the actual gameplay session has populated AuthValues + // and goes through the strict path below. + if (string.IsNullOrWhiteSpace(userIdRaw) && string.IsNullOrWhiteSpace(loginLock)) + { + log.LogDebug("[photon-auth] anonymous bootstrap connect — allow"); + return Ok(new { ResultCode = 1, Message = "ok (anonymous)" }); + } + + // Half-set is suspicious — either someone's spoofing AuthValues + // by hand, or the plugin's injector partially fired. Reject so + // we don't grant gameplay credentials to an unverifiable session. + if (string.IsNullOrWhiteSpace(userIdRaw) || string.IsNullOrWhiteSpace(loginLock)) + { + log.LogWarning( + "[photon-auth] partial auth — userid={UserId} LoginLockPresent={Lock} " + + "query={Query} form={Form}", + userIdRaw ?? "", + !string.IsNullOrWhiteSpace(loginLock), + System.Text.Json.JsonSerializer.Serialize(qs), + System.Text.Json.JsonSerializer.Serialize(form)); + return Ok(new + { + ResultCode = 2, + Message = "userid and LoginLock must both be sent (or both absent for anonymous bootstrap)", + }); + } + + if (!long.TryParse(userIdRaw, out var playerId)) + { + log.LogWarning("[photon-auth] non-numeric userid={UserId}", userIdRaw); + return Ok(new { ResultCode = 2, Message = $"userid '{userIdRaw}' is not a valid account id" }); + } + + if (!presence.ValidateLock(playerId, loginLock)) + { + log.LogWarning( + "[photon-auth] lock mismatch for player={PlayerId} — stale session, " + + "different machine, or never logged in", + playerId); + return Ok(new + { + ResultCode = 3, + Message = "LoginLock mismatch — sign in via /player/login first", + }); + } + + log.LogDebug("[photon-auth] OK player={PlayerId}", playerId); + return Ok(new + { + ResultCode = 1, + Message = "ok", + UserId = playerId.ToString(), + }); + } +} diff --git a/DorkNet.Server/Controllers/Cdn/CdnController.cs b/DorkNet.Server/Controllers/Cdn/CdnController.cs new file mode 100644 index 0000000..9168245 --- /dev/null +++ b/DorkNet.Server/Controllers/Cdn/CdnController.cs @@ -0,0 +1,446 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text; +using System.Text.Json; +using DorkNet.Server.Data; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Cdn; + +/// +/// Catch-all binary blob server. Owns every GET /{*path} request +/// that hits the API host pool — cdn.* / storage.* / data.* / img.* / +/// the bare apex all funnel into this one controller and we branch on +/// the subdomain prefix inside : +/// +/// • img.{apex} → image-transform pipeline. Reads from S3 +/// (BlobRouter routes by filename suffix), supports on-the-fly +/// ?cropSquare=1 / ?width=N via +/// , falls back to disk +/// (data/images/) and finally a 1×1 transparent PNG so the +/// 2020 watch's image cache absorbs misses without 404 retry storms. +/// • everything else (cdn.* / storage.* / data.* / unknown sub) → +/// CDN serve path. S3 first, default RoomDataBlob fallback, image +/// misses still fall back to the transparent PNG (same shape the +/// old ImgController surfaced) so the watch's room-thumbnail +/// pipeline behaves identically post-merge. +/// +/// Pre-refactor the two paths lived in separate controllers +/// (ImgController + CdnController) each with their own +/// [Host(...)] filter; HostFilteringMiddleware now controls +/// which hosts the app accepts at all (via DomainConfig) and routing +/// is purely path-based, so the two handlers had to merge to avoid +/// AmbiguousMatchException on GET /{*path}. +/// +/// Every response is signed via +/// before being returned — the 2020 watch's image-verify path rejects +/// any image lacking the Content-Signature header. +/// +[ApiController] +public class CdnController( + RoomDataBlobService roomDataBlob, + IObjectStorage storage, + ImageSignatureService signatures, + DorkNetDbContext db, + ILogger logger) : ControllerBase +{ + /// 1x1 transparent PNG used for missing image files. + /// The 2020 watch treats image HTTP failures harshly (retry-storms + /// on missing room thumbnails), so image paths fail soft — return + /// a valid PNG body with a short cache TTL instead of 404. + private static readonly byte[] TransparentPng = new byte[] + { + 0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D, + 0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01, + 0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4,0x89,0x00,0x00,0x00, + 0x0D,0x49,0x44,0x41,0x54,0x78,0x9C,0x63,0x00,0x01,0x00,0x00, + 0x05,0x00,0x01,0x0D,0x0A,0x2D,0xB4,0x00,0x00,0x00,0x00,0x49, + 0x45,0x4E,0x44,0xAE,0x42,0x60,0x82, + }; + + private static readonly string ImageDir = + Path.Combine(AppContext.BaseDirectory, "data", "images"); + + // Specific route prefixes only — NO global catch-all wildcard. + // A previous version had [Route("/{*path:minlength(1)}")] which + // matched every GET on every host, then used an in-handler host + // gate to NotFound for non-cdn/img subdomains. That broke admin + // API endpoints on admin.localhost: ASP.NET routing matched the + // wildcard endpoint at UseRouting time, StaticFileMiddleware then + // refused to serve admin SPA assets (its ValidateNoEndpoint guard + // bails when an endpoint is already matched), and MapControllers + // ran CdnController.Serve which 404'd — same dynamic broke every + // admin/site API route and asset request. + // + // The route templates below cover the watch's actual CDN URL + // shapes (confirmed via Coolify access logs): + // • cdn.{apex}/room/.dat — RoomDataBlob downloads + // • cdn.{apex}/data/.htr — room thumbnails / .htr files + // • cdn.{apex}/config/ — LoadingScreenTipData and similar + // • img.{apex}/.{png,jpg,jpeg,webp,gif} — image fetches + // • img.{apex}/img/ — legacy path-prefixed images + // + // The in-handler host gate stays as defence-in-depth so that, if + // a stray request somehow reaches one of these routes on the wrong + // host (admin.*, api.*, etc.), we still 404 cleanly instead of + // serving binary CDN bytes. + [Route("/room/{*path:minlength(1)}")] + [Route("/data/{*path:minlength(1)}")] + [Route("/config/{*path:minlength(1)}")] + [Route("/img/{*path:minlength(1)}")] + // `cdn.{apex}/video/` — the watch's CommunityBoard + // builds this URL via `String.Concat("/video/", blobName)` at + // RecNet/CommunityBoard.txt:1068, so the prefix path needs an + // explicit route. Without this, the catch-all regex below only + // matches single-segment names and `/video/cb_video_*.mp4` 404s. + [Route("/video/{*path:minlength(1)}")] + // ASP.NET route templates treat `[` / `]` as token markers + // (e.g. `[controller]`). To use them as literals inside a regex + // constraint they must be doubled — so `[^/]` becomes `[[^/]]`. + // + // Extension list: image formats + room/.htr/.inv blobs that the + // watch fetches directly, plus video formats (mp4/webm/mov/m4v) so + // community-board video blobs uploaded via /admin/v1/communityboard/ + // video/upload (which writes `cb_video_.mp4` etc.) resolve + // through the same bare-filename catch-all. + [Route("/{path:regex(^[[^/]]+\\.(png|jpg|jpeg|webp|gif|dat|bin|holotar|inv|room|htr|mp4|webm|mov|m4v)$)}")] + [AcceptVerbs("GET", "HEAD")] + public async Task Serve(string? path) + { + // Defence-in-depth host check. Without the prior global wildcard + // these routes are narrow enough that stray hits on admin/api/etc. + // should be rare, but if they do land here we 404 cleanly instead + // of serving binary CDN bytes that would confuse the caller. + var host = Request.Host.Host; + var isImg = DomainConfig.MatchesSubdomain(host, "img"); + var isCdn = DomainConfig.MatchesSubdomain(host, "cdn") + || DomainConfig.MatchesSubdomain(host, "storage") + || DomainConfig.MatchesSubdomain(host, "data"); + if (!isImg && !isCdn) return NotFound(); + + // Use the full request path (e.g. "config/LoadingScreenTipData", + // "room/foo.dat") rather than the route-captured suffix so the + // downstream switch logic — which still keys on the literal + // "config/LoadingScreenTipData" — keeps working regardless of + // which of the multiple [Route] templates above matched. + var fullPath = (Request.Path.Value ?? string.Empty).TrimStart('/'); + + if (isImg) return await ServeImage(fullPath); + return await ServeCdn(fullPath); + } + + // ── Image pipeline (img.{apex}) ──────────────────────────────────── + private async Task ServeImage(string? path) + { + var safe = Path.GetFileName(path ?? string.Empty); + // Strip the leading '$' sigil that ImageData.image_name carries + // for hash-addressed assets. The watch URL-escapes it (%24) and + // the route binder decodes it back, so what arrives here is + // literally "$.jpg" — but the bytes are stored at + // ".jpg" (the bare BlobName the importer writes). Without + // this strip, every placed in-world polaroid 404s into the + // transparent-PNG fallback and renders as "?" in-game. + if (safe.StartsWith('$')) safe = safe[1..]; + if (string.IsNullOrWhiteSpace(safe)) + { + signatures.AddContentSignature(Response, TransparentPng); + return File(TransparentPng, "image/png"); + } + + var ext = Path.GetExtension(safe).ToLowerInvariant(); + var contentType = ext switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".webp" => "image/webp", + ".gif" => "image/gif", + _ => "image/png", // default to PNG for unknown — better than octet-stream for browser tags + }; + + // Three-tier resolution: S3 first (canonical post-migration), + // then on-disk legacy folder (room thumbnails shipped with the + // server), then RoomDataBlobs.Bytes column (uploads that + // pre-date the S3 migration). Once the migrator has uploaded + // everything to S3 and PR 4 drops the Bytes column, the disk + // and DB fallbacks become rare cases. + byte[]? s3Bytes = null; + var (s3Bucket, s3Key) = BlobRouter.Route(safe); + try + { + using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + s3Bytes = await storage.GetAsync(s3Bucket, s3Key, s3Timeout.Token); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "[img] S3 lookup threw host={Host} path='{Path}' file='{Safe}' bucket={Bucket} key={Key}; falling back to disk / DB / placeholder", + Request.Host.Host, path, safe, s3Bucket, s3Key); + } + + // Extension-less hash fallback. The 2020 watch references + // some persistence-view image slots as a bare 32-char hex hash + // (no .jpg, no .png) — distinct from ImageData.image_name which + // carries "$.jpg". The importer stores everything from + // SubRooms//Image/ with an extension, so a bare-hash + // request misses on first lookup. Retry with the two common + // image extensions before giving up. + if ((s3Bytes is null || s3Bytes.Length == 0) && string.IsNullOrEmpty(ext) && LooksLikeHash(safe)) + { + foreach (var altExt in new[] { ".jpg", ".png" }) + { + var altName = safe + altExt; + var (altBucket, altKey) = BlobRouter.Route(altName); + try + { + using var altTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var altBytes = await storage.GetAsync(altBucket, altKey, altTimeout.Token); + if (altBytes is { Length: > 0 }) + { + logger.LogInformation( + "[img] s3 hit (ext-fallback) host={Host} path={Path} resolvedAs={Alt} bytes={Bytes}", + Request.Host.Host, safe, altName, altBytes.Length); + s3Bytes = altBytes; + contentType = altExt == ".png" ? "image/png" : "image/jpeg"; + break; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, + "[img] ext-fallback S3 lookup threw for {Name} bucket={Bucket} key={Key}", + altName, altBucket, altKey); + } + } + } + + if (s3Bytes is { Length: > 0 }) + { + logger.LogInformation("[img] s3 hit host={Host} path={Path} bytes={Bytes}", + Request.Host.Host, safe, s3Bytes.Length); + Response.Headers.CacheControl = "public, max-age=300"; + return RespondWithTransforms(s3Bytes, contentType); + } + + var full = Path.Combine(ImageDir, safe); + if (System.IO.File.Exists(full)) + { + logger.LogInformation("[img] disk hit host={Host} path={Path} → {Full}", + Request.Host.Host, path, full); + // Short TTL on the public CF edge so swapping a thumbnail + // (e.g. switching from full-res to a resized cached PNG) + // propagates within minutes instead of being pinned for + // hours by CF's default image-cache TTL. The watch keeps + // its own per-session local cache regardless. + var diskBytes = await System.IO.File.ReadAllBytesAsync(full); + Response.Headers.CacheControl = "public, max-age=300"; + return RespondWithTransforms(diskBytes, contentType); + } + + // S3 is the only canonical store — DB carries text-only + // metadata, never bytes. If S3 misses, the bytes don't exist + // on this server; fall through to the transparent-PNG miss. + + // Stop the 404 storm — the watch retries hard on missing room + // thumbnails. Return a 1x1 transparent PNG so it caches a + // valid (empty) image and stops asking. Log loudly so the + // user can see what filename the watch tried but disk lookup + // failed at: tells us at a glance whether the client is even + // hitting this controller (no log = wrong host / DNS issue), + // and if so, what filename it expects vs what's on disk. + logger.LogWarning( + "[img] MISS host={Host} path='{Path}' file='{Safe}' lookedAt='{Full}' query='{Query}'", + Request.Host.Host, path, safe, full, Request.QueryString); + // Don't let CF pin the empty-fallback for hours — once the real + // image lands on disk the next request should pick it up. + Response.Headers.CacheControl = "public, max-age=60"; + signatures.AddContentSignature(Response, TransparentPng); + return File(TransparentPng, "image/png"); + } + + // ── CDN serve path (cdn.*, storage.*, data.*, apex) ──────────────── + private async Task ServeCdn(string? path) + { + if (string.Equals(path, "config/LoadingScreenTipData", StringComparison.OrdinalIgnoreCase)) + { + // Tips live in the LoadingScreenTips DB table; admins + // edit them via the SPA. Wire shape per + // LoadingScreenTip.cs: List of {Title, Message, + // ImageName, Context, PlatformMask, HasImage, RoomNames}. + // HasImage MUST be set explicitly — without it the watch + // waits forever on the image promise and never renders. + var rows = await db.LoadingScreenTips + .Where(t => t.IsActive) + .OrderBy(t => t.SortOrder).ThenBy(t => t.Id) + .AsNoTracking() + .ToListAsync(); + var tips = rows.Select(t => new + { + Title = t.Title, + Message = t.Message, + ImageName = t.ImageName, + Context = t.Context, + PlatformMask = t.PlatformMask, + HasImage = !string.IsNullOrEmpty(t.ImageName), + RoomNames = string.IsNullOrEmpty(t.RoomNamesCsv) + ? Array.Empty() + : t.RoomNamesCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + }).ToArray(); + var payload = JsonSerializer.SerializeToUtf8Bytes(tips); + signatures.AddContentSignature(Response, payload); + return new FileContentResult(payload, "application/json"); + } + + var fileName = string.IsNullOrEmpty(path) + ? string.Empty + : path.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? string.Empty; + // Strip the leading '$' sigil that ImageData.image_name uses + // for hash-addressed assets. The img subdomain branch above + // already handles this; defense-in-depth for any client that + // fetches the same name via cdn.* / storage.* / data.* hosts. + if (!string.IsNullOrEmpty(fileName) && fileName[0] == '$') fileName = fileName[1..]; + + if (string.IsNullOrEmpty(fileName)) + { + logger.LogWarning("[cdn] empty filename host={Host} path={Path} — default blob", + Request.Host.Host, path); + var defaultBlob = roomDataBlob.GetDefaultBlob(); + signatures.AddContentSignature(Response, defaultBlob); + return new FileContentResult(defaultBlob, "application/octet-stream"); + } + + // Two-tier read: S3 first (the canonical store post-migration), + // then RoomDataBlobs.Bytes as a backstop for any blob that was + // uploaded before the storage migration ran. Once the migrator + // has uploaded all existing bytes and the Bytes column is + // dropped, this fallback becomes a no-op. + // + // Defensive: ObjectStorageService.GetAsync only catches 404; an + // S3 misconfig (wrong endpoint, dead creds, bucket missing) or + // a network blip throws → we'd otherwise return 500 and the + // watch's RecNet.Rooms.GetRoomData logs "Failed to download + // room data" + Photon disconnects the player. Catching here + // and falling through to the DB / default-blob path keeps the + // dorm load resilient to S3 outages — players boot into the + // stock dorm scene instead of getting kicked. + byte[]? s3Bytes = null; + var (s3Bucket, s3Key) = BlobRouter.Route(fileName); + try + { + // 15 seconds for room/data blobs (vs 2 seconds on the image + // path further up): the watch's room-load flow downloads + // PersistedRoomData and DESERIALISES it as the player enters + // the room — a default-empty blob means the saved dorm + // appears completely reset. Better to wait for the real + // bytes than hand back zeros and silently lose the player's + // build. Garage on the same host typically responds in + // 50-300ms; the larger budget only burns on cold-start / + // network blips. + using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + s3Bytes = await storage.GetAsync(s3Bucket, s3Key, s3Timeout.Token); + } + catch (Exception ex) + { + logger.LogWarning(ex, "[cdn] S3 GetAsync threw for {Bucket}/{Key}; serving default blob", + s3Bucket, s3Key); + } + if (s3Bytes is { Length: > 0 }) + { + logger.LogInformation("[cdn] s3 hit host={Host} file={File} bytes={Bytes}", + Request.Host.Host, fileName, s3Bytes.Length); + // Allow CF edge to cache for an hour. RoomDataBlobs are + // content-addressed by hash so the same BlobName never + // serves different bytes — safe to cache aggressively. + Response.Headers.CacheControl = "public, max-age=3600"; + signatures.AddContentSignature(Response, s3Bytes); + return new FileContentResult(s3Bytes, MimeFromName(fileName)); + } + + // S3 is the only canonical store — DB carries text-only + // metadata (BlobName, owner, timestamps) and never holds + // bytes. If S3 misses, the bytes don't exist on this server. + + if (IsImageName(fileName)) + { + logger.LogWarning("[cdn] image MISS host={Host} file={File} query='{Query}' -> transparent png", + Request.Host.Host, fileName, Request.QueryString); + Response.Headers.CacheControl = "public, max-age=60"; + signatures.AddContentSignature(Response, TransparentPng); + return new FileContentResult(TransparentPng, "image/png"); + } + + logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob", + Request.Host.Host, fileName); + var fallbackBlob = roomDataBlob.GetDefaultBlob(); + signatures.AddContentSignature(Response, fallbackBlob); + return new FileContentResult(fallbackBlob, "application/octet-stream"); + } + + /// + /// Apply the watch's ?cropSquare=1 + ?width=N transforms + /// per-request on the way out and sign the *resulting* bytes — the + /// originals stay untouched in S3/disk/DB so a later + /// ?width=512 request can re-render from full resolution. If + /// the transform pipeline can't decode the bytes (corrupt file, GIF + /// that Skia doesn't support, etc.) we fall back to serving the raw + /// bytes rather than 5xxing the whole image request. + /// + private IActionResult RespondWithTransforms(byte[] sourceBytes, string sourceContentType) + { + var cropSquare = ParseFlag(Request.Query["cropSquare"]); + var width = ParseInt(Request.Query["width"]); + var transformed = (cropSquare || width is not null) + ? ImageTransformService.TryTransform(sourceBytes, sourceContentType, cropSquare, width) + : null; + + if (transformed is { } t) + { + signatures.AddContentSignature(Response, t.Bytes); + return File(t.Bytes, t.ContentType); + } + + signatures.AddContentSignature(Response, sourceBytes); + return File(sourceBytes, sourceContentType); + } + + private static bool ParseFlag(Microsoft.Extensions.Primitives.StringValues v) + { + var s = v.ToString(); + return !string.IsNullOrEmpty(s) && s != "0" && !s.Equals("false", StringComparison.OrdinalIgnoreCase); + } + + private static int? ParseInt(Microsoft.Extensions.Primitives.StringValues v) + => int.TryParse(v.ToString(), out var n) && n > 0 ? n : null; + + private static bool LooksLikeHash(string s) + { + if (s.Length is not (32 or 40)) return false; + foreach (var c in s) + { + var isHex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!isHex) return false; + } + return true; + } + + private static string MimeFromName(string name) + { + var ext = Path.GetExtension(name).ToLowerInvariant(); + return ext switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".mp4" or ".m4v" => "video/mp4", + ".webm" => "video/webm", + ".mov" => "video/quicktime", + _ => "application/octet-stream", + }; + } + + private static bool IsImageName(string name) + { + var ext = Path.GetExtension(name).ToLowerInvariant(); + return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp"; + } +} diff --git a/DorkNet.Server/Controllers/Chat/ChatController.cs b/DorkNet.Server/Controllers/Chat/ChatController.cs new file mode 100644 index 0000000..a426f35 --- /dev/null +++ b/DorkNet.Server/Controllers/Chat/ChatController.cs @@ -0,0 +1,823 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.Notification; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; +using System.Text.Json; + +namespace DorkNet.Server.Controllers.Chat; + +/// +/// chat.rec.net — DM / group-chat endpoints. Three entities back this: +/// +/// — append-only message log +/// keyed by thread. +/// — optional per-thread metadata +/// (custom name, creator) for group chats and renamed DMs. +/// — per-(thread, player) +/// row carrying snooze timestamp and last-read message pointer. +/// For unmodified 2-person DMs we create rows lazily on first +/// mutation; the message table is the source of truth for +/// participants. +/// +/// +[ApiController] +[Authorize] +public class ChatController( + DorkNetDbContext db, + NotificationService notifications) : ControllerBase +{ + private const int ChatSuccess = 0; + private const int ChatInvalidArguments = 1; + private const int ChatMembershipNotFound = 3; + private const int ChatPlayerAlreadyOnThread = 4; + + private long Me => this.RequireCurrentPlayerId(); + + /// List the caller's chat threads, most-recently-active + /// first. Each thread carries the most recent message preview so + /// the watch can render the inbox without per-thread fetches. + [HttpGet("/thread")] + public async Task GetThreads([FromQuery] int? maxCount) + { + var take = Math.Clamp(maxCount ?? 50, 1, 100); + + // Two sources of "threads I see in my inbox": + // 1. Explicit membership rows (group chats — leaving deletes + // the row, so its presence means "still in"). + // 2. DM threads where I sent or received at least one message. + // We union both, then filter out group keys I'm NOT a member + // of (in case I sent a message to a group then left). + var memberKeys = (await db.ChatThreadMembers + .Where(m => m.PlayerId == Me) + .Select(m => m.ThreadKey) + .ToListAsync()).ToHashSet(); + + var sentOrReceived = await db.ChatMessages + .Where(c => c.SenderPlayerId == Me || + c.ThreadKey.StartsWith($"dm:{Me}:") || + c.ThreadKey.EndsWith($":{Me}")) + .Select(c => c.ThreadKey) + .Distinct() + .ToListAsync(); + + var keys = sentOrReceived + .Concat(memberKeys) + .Distinct() + .Where(k => !k.StartsWith("group:") || memberKeys.Contains(k)) + .ToList(); + if (keys.Count == 0) return Ok(Array.Empty()); + + var latest = await db.ChatMessages + .Where(c => keys.Contains(c.ThreadKey)) + .GroupBy(c => c.ThreadKey) + .Select(g => g.OrderByDescending(c => c.SentAt).First()) + .Take(take) + .ToListAsync(); + + var threadMeta = await EnsureThreadRowsAsync(keys); + var myMembership = await db.ChatThreadMembers + .Where(m => m.PlayerId == Me && keys.Contains(m.ThreadKey)) + .ToDictionaryAsync(m => m.ThreadKey, m => m); + var participants = await LoadParticipantsAsync(keys); + + return Ok(latest + .OrderByDescending(c => c.SentAt) + .Select(c => + { + var meta = threadMeta[c.ThreadKey]; + myMembership.TryGetValue(c.ThreadKey, out var member); + participants.TryGetValue(c.ThreadKey, out var playerIds); + return ToWireThread( + meta, + member, + playerIds ?? new List(), + latestMessage: c); + }) + .ToList()); + } + + /// Return the single thread by its key, with the most + /// recent N messages. + [HttpGet("/thread/{chatThreadId}")] + public async Task GetThreadById(string chatThreadId, + [FromQuery] int maxCount = 50) + { + var take = Math.Clamp(maxCount, 1, 200); + var key = await ResolveThreadKeyAsync(chatThreadId); + var rows = await db.ChatMessages + .Where(c => c.ThreadKey == key) + .OrderByDescending(c => c.SentAt) + .Take(take) + .ToListAsync(); + if (rows.Count == 0) return NotFound(); + var meta = (await EnsureThreadRowsAsync(new[] { key }))[key]; + var member = await db.ChatThreadMembers + .FirstOrDefaultAsync(m => m.ThreadKey == key && m.PlayerId == Me); + var participants = await LoadParticipantsAsync(new[] { key }); + participants.TryGetValue(key, out var playerIds); + return Ok(ToWireThread( + meta, + member, + playerIds ?? new List(), + messages: rows.OrderBy(c => c.SentAt).ToList())); + } + + /// Recent messages for a thread, paginated by + /// . + [HttpGet("/thread/{chatThreadId}/message")] + public async Task GetMessages(string chatThreadId, + [FromQuery] int maxCount = 50, + [FromQuery] int? messageCount = null, + [FromQuery] long? beforeMessageId = null, + [FromQuery] long? referenceMessageId = null) + { + var take = Math.Clamp(messageCount ?? maxCount, 1, 200); + var key = await ResolveThreadKeyAsync(chatThreadId); + var thread = await db.ChatThreads.FirstOrDefaultAsync(t => t.ThreadKey == key); + if (thread is null) return Ok(Array.Empty()); + var q = db.ChatMessages.Where(c => c.ThreadKey == key); + var before = beforeMessageId ?? referenceMessageId; + if (before is long b) + q = q.Where(c => c.Id < b); + var rows = await q + .OrderByDescending(c => c.SentAt) + .Take(take) + .ToListAsync(); + return Ok(rows.OrderBy(c => c.SentAt).Select(c => ToWireMessage(c, thread.Id)).ToList()); + } + + /// Unread-thread count from per-thread last-read pointers. + /// A thread counts as unread when there's at least one message + /// after the caller's LastReadMessageId (or any message at + /// all when the caller has never read). + [HttpGet("/thread/unreadcount")] + public async Task UnreadCount() + { + // Threads the caller participates in via membership row. + var memberKeys = await db.ChatThreadMembers + .Where(m => m.PlayerId == Me) + .ToListAsync(); + var memberKeyToLastRead = memberKeys.ToDictionary(m => m.ThreadKey, m => m.LastReadMessageId); + + // DM threads also count even without an explicit member row. + var dmKeys = await db.ChatMessages + .Where(c => c.ThreadKey.StartsWith($"dm:{Me}:") || c.ThreadKey.EndsWith($":{Me}")) + .Select(c => c.ThreadKey) + .Distinct() + .ToListAsync(); + + var keys = memberKeyToLastRead.Keys.Concat(dmKeys).Distinct().ToList(); + if (keys.Count == 0) return Ok(0); + + var perThreadMax = await db.ChatMessages + .Where(c => keys.Contains(c.ThreadKey) && c.SenderPlayerId != Me) + .GroupBy(c => c.ThreadKey) + .Select(g => new { Key = g.Key, MaxId = g.Max(c => c.Id) }) + .ToListAsync(); + + var unread = perThreadMax.Count(t => + { + memberKeyToLastRead.TryGetValue(t.Key, out var lastRead); + return lastRead is null || t.MaxId > lastRead.Value; + }); + return Ok(unread); + } + + public sealed record SendChatRequest(string? ChatThreadId, long? RecipientId, string Body); + + /// Append a message to a thread. The thread is either + /// referenced by id (existing thread) or by RecipientId (new DM + /// to that player; we synthesise the canonical dm key). JSON shape + /// used by the admin SPA / API callers; the 2020 watch posts the + /// form-urlencoded variant in with multi- + /// valued ids + a messageContents JSON blob. + [HttpPost("/thread")] + [HttpPost("/thread/{chatThreadId}/message")] + [Consumes("application/json")] + public async Task Send(string? chatThreadId, + [FromBody] SendChatRequest body) + { + if (string.IsNullOrWhiteSpace(body.Body)) return BadRequest("empty"); + var trimmed = body.Body.Trim(); + if (trimmed.Length > 2000) trimmed = trimmed[..2000]; + + var key = chatThreadId is null ? null : await ResolveThreadKeyAsync(chatThreadId); + key ??= body.ChatThreadId is null ? null : await ResolveThreadKeyAsync(body.ChatThreadId); + key ??= body.RecipientId is long otherId + ? ChatMessageEntity.DmKey(Me, otherId) + : null; + if (key is null) return BadRequest("missing_thread"); + var thread = await EnsureThreadRowAsync(key); + + var entry = new ChatMessageEntity + { + ThreadKey = key, + SenderPlayerId = Me, + Body = trimmed, + }; + db.ChatMessages.Add(entry); + await db.SaveChangesAsync(); + + // Notify every participant — INCLUDING the sender. The 2020 + // watch's chat send path doesn't update its local thread cache + // off the HTTP 200 response, so without a self-targeted push + // the sender's chat UI doesn't show its own outgoing message + // until the next thread refresh. DM keys encode the two + // participants directly; group chat threads fan out across the + // explicit membership table. + var recipients = new List(); + if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b)) + { + var other = a == Me ? b : a; + recipients.Add(other); + } + else if (key.StartsWith("group:")) + { + recipients = await db.ChatThreadMembers + .Where(m => m.ThreadKey == key && m.PlayerId != Me) + .Select(m => m.PlayerId) + .ToListAsync(); + } + recipients.Add(Me); + foreach (var rid in recipients) + { + await notifications.NotifyAsync(rid, + PushNotificationId.ChatMessageReceived, + ToWireMessage(entry, thread.Id)); + } + + return Ok(ToWireSendMessageResponse(entry, thread.Id)); + } + + /// Set the caller's last-read pointer on the thread to + /// the most recent message id. Drives the inbox's unread badge. + [HttpPut("/thread/{chatThreadId}/read")] + [HttpPost("/thread/{chatThreadId}/read")] + public async Task MarkRead(string chatThreadId) => + await UpdateLastRead(await ResolveThreadKeyAsync(chatThreadId), latestOnly: true, explicitMessageId: null); + + /// PUT /thread/{id}/message/{messageId}/read — set + /// the caller's last-read pointer to a specific message (not just + /// "latest"). The 2020 watch uses this when the inbox view scrolls + /// to a particular message rather than the bottom. URL verified + /// at stringliteral.json: "thread/{0}/message/{1}/read". + [HttpPut("/thread/{chatThreadId}/message/{messageId:long}/read")] + [HttpPost("/thread/{chatThreadId}/message/{messageId:long}/read")] + public async Task MarkMessageRead(string chatThreadId, long messageId) => + await UpdateLastRead(await ResolveThreadKeyAsync(chatThreadId), latestOnly: false, explicitMessageId: messageId); + + private async Task UpdateLastRead(string chatThreadId, bool latestOnly, long? explicitMessageId) + { + long? latestId = explicitMessageId; + if (latestId is null) + { + latestId = await db.ChatMessages + .Where(c => c.ThreadKey == chatThreadId) + .OrderByDescending(c => c.Id) + .Select(c => (long?)c.Id) + .FirstOrDefaultAsync(); + } + if (latestId is null) return Ok(ChatSuccess); + _ = latestOnly; + + var row = await db.ChatThreadMembers + .FirstOrDefaultAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me); + if (row is null) + { + row = new ChatThreadMemberEntity + { + ThreadKey = chatThreadId, + PlayerId = Me, + }; + db.ChatThreadMembers.Add(row); + } + row.LastReadMessageId = latestId; + await db.SaveChangesAsync(); + return Ok(ChatSuccess); + } + + public sealed record SnoozeRequest(DateTime? Until); + + /// Mute notifications for the thread until + /// Until (UTC). Null/missing payload clears the snooze. + [HttpPost("/thread/{chatThreadId}/snooze")] + public async Task Snooze(string chatThreadId, [FromBody] SnoozeRequest? body) + { + chatThreadId = await ResolveThreadKeyAsync(chatThreadId); + var row = await db.ChatThreadMembers + .FirstOrDefaultAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me); + if (row is null) + { + row = new ChatThreadMemberEntity + { + ThreadKey = chatThreadId, + PlayerId = Me, + }; + db.ChatThreadMembers.Add(row); + } + row.SnoozeUntil = body?.Until; + await db.SaveChangesAsync(); + var thread = await EnsureThreadRowAsync(chatThreadId); + return Ok(new Dictionary + { + ["chatResult"] = ChatSuccess, + ["snoozedUntil"] = row.SnoozeUntil, + }); + } + + /// Remove the caller from the thread's membership. They + /// stop receiving new messages and the thread drops off their + /// inbox. DMs can't be left — there are only two participants and + /// removing one effectively ends the conversation; the watch + /// surfaces a "delete chat" option for DMs instead. + [HttpDelete("/thread/{chatThreadId}/leave")] + public async Task Leave(string chatThreadId) + { + chatThreadId = await ResolveThreadKeyAsync(chatThreadId); + if (chatThreadId.StartsWith("dm:")) + return Ok(ChatInvalidArguments); + var removed = await db.ChatThreadMembers + .Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me) + .ExecuteDeleteAsync(); + return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound); + } + + public sealed record RenameRequest(string Name); + + /// Set the thread's display name. Permission: any member + /// of the thread can rename. For DMs the watch typically nudges + /// users to pick a nickname after first send; group-chat creators + /// usually set the name at creation time. + [HttpPut("/thread/{chatThreadId}/rename")] + public async Task Rename(string chatThreadId, [FromBody] RenameRequest body) + { + chatThreadId = await ResolveThreadKeyAsync(chatThreadId); + var name = (body.Name ?? string.Empty).Trim(); + if (name.Length > 128) name = name[..128]; + + var iAmMember = chatThreadId.StartsWith("dm:") + ? IsDmParticipant(chatThreadId, Me) + : await db.ChatThreadMembers + .AnyAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me); + if (!iAmMember) return Ok(ChatMembershipNotFound); + + var meta = await db.ChatThreads.FirstOrDefaultAsync(t => t.ThreadKey == chatThreadId); + if (meta is null) + { + meta = new ChatThreadEntity + { + ThreadKey = chatThreadId, + CreatorPlayerId = Me, + }; + db.ChatThreads.Add(meta); + } + meta.Name = name; + meta.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(ChatSuccess); + } + + public sealed record CreateGroupChatRequest(List MemberIds, string? Name); + + /// Create a group chat. Generates a fresh + /// group:<guid> key, inserts membership rows for the + /// creator + each invited player, and returns the new thread id. + /// Empty or duplicate member lists are tolerated (the caller is + /// always added as a member regardless). JSON variant — the 2020 + /// watch hits the form-urlencoded form in . + /// + [HttpPost("/thread/withmembers")] + [Consumes("application/json")] + public async Task CreateGroup([FromBody] CreateGroupChatRequest body) + { + var key = $"group:{Guid.NewGuid():N}"; + var members = (body?.MemberIds ?? new()) + .Where(id => id > 0 && id != Me) + .Distinct() + .ToList(); + members.Add(Me); + + db.ChatThreads.Add(new ChatThreadEntity + { + ThreadKey = key, + Name = (body?.Name ?? string.Empty).Trim(), + CreatorPlayerId = Me, + }); + foreach (var pid in members) + { + db.ChatThreadMembers.Add(new ChatThreadMemberEntity + { + ThreadKey = key, + PlayerId = pid, + }); + } + await db.SaveChangesAsync(); + + var thread = await db.ChatThreads.FirstAsync(t => t.ThreadKey == key); + var playerIds = members.Select(x => (int)x).ToList(); + return Ok(new Dictionary + { + ["chatThread"] = ToWireThread(thread, null, playerIds), + ["chatResult"] = ChatSuccess, + }); + } + + /// Form-urlencoded variant of + /// used by the 2020 watch. Wire shape verified from the live + /// request: ids=A&ids=B&messageCount=N. The watch + /// calls this to "open a chat with these people" — semantics are + /// fetch-or-create-then-return-recent-messages, NOT just create. + /// We resolve ids to a canonical thread key (DM if two + /// participants total including the caller, otherwise group), pull + /// the latest messageCount messages, and return a shape the + /// watch's inbox can render directly. + [HttpPost("/thread/withmembers")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task WithMembersForm() + { + var (ids, _) = ParseIdsAndContents(Request.Form); + if (ids.Count == 0) return BadRequest(new { error = "missing_ids" }); + + var count = Math.Clamp( + int.TryParse(Request.Form["messageCount"], out var mc) ? mc : 50, + 1, 200); + var key = await ResolveOrCreateThreadAsync(ids); + var rows = await db.ChatMessages + .Where(c => c.ThreadKey == key) + .OrderByDescending(c => c.SentAt) + .Take(count) + .ToListAsync(); + var meta = await EnsureThreadRowAsync(key); + var participants = ids.Concat(new[] { Me }).Distinct().Select(x => (int)x).ToList(); + return Ok(ToWireThread(meta, null, participants, messages: rows.OrderBy(c => c.SentAt).ToList())); + } + + /// Form-urlencoded variant of used by + /// the 2020 watch. Wire shape: ids=A&ids=B& + /// messageContents={JSON} where messageContents is the + /// JSON-encoded RecNet.ChatMessage.Contents blob + /// ({Type,Version,Data}) — Data is the actual text. The + /// watch sends this to BOTH create-or-find-thread AND post-message + /// in one call. + [HttpPost("/thread")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task SendForm() + { + var (ids, contents) = ParseIdsAndContents(Request.Form); + if (ids.Count == 0) return BadRequest(new { error = "missing_ids" }); + + var text = ExtractChatDataField(contents); + if (string.IsNullOrWhiteSpace(text)) return BadRequest(new { error = "empty_body" }); + if (text.Length > 2000) text = text[..2000]; + + var key = await ResolveOrCreateThreadAsync(ids); + var thread = await EnsureThreadRowAsync(key); + var entry = new ChatMessageEntity + { + ThreadKey = key, + SenderPlayerId = Me, + Body = text, + }; + db.ChatMessages.Add(entry); + await db.SaveChangesAsync(); + + // Notify every participant, sender INCLUDED — see SendMessageJson + // above for the rationale; the 2020 watch's chat send path needs + // the self-push to refresh its own UI. + var recipients = new List(); + if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b)) + { + var other = a == Me ? b : a; + if (other != Me) recipients.Add(other); + } + else if (key.StartsWith("group:")) + { + recipients = await db.ChatThreadMembers + .Where(m => m.ThreadKey == key && m.PlayerId != Me) + .Select(m => m.PlayerId) + .ToListAsync(); + } + recipients.Add(Me); + foreach (var rid in recipients) + { + await notifications.NotifyAsync(rid, + PushNotificationId.ChatMessageReceived, + ToWireMessage(entry, thread.Id)); + } + var participants = await LoadParticipantsAsync(new[] { key }); + participants.TryGetValue(key, out var playerIds); + return Ok(new Dictionary + { + ["chatThread"] = ToWireThread(thread, null, playerIds ?? new List(), latestMessage: entry), + ["chatResult"] = ChatSuccess, + }); + } + + /// Form-urlencoded send used by the 2020 watch: + /// POST /thread/{chatId} with messageContents. + /// The response is a SendMessageResponse wrapper, not a raw message. + [HttpPost("/thread/{chatThreadId}")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task SendMessageForm(string chatThreadId) + { + var key = await ResolveThreadKeyAsync(chatThreadId); + var text = ExtractChatDataField(Request.Form["messageContents"].ToString()); + if (string.IsNullOrWhiteSpace(text)) return Ok(new Dictionary + { + ["chatResult"] = ChatInvalidArguments, + }); + if (text.Length > 2000) text = text[..2000]; + + var thread = await EnsureThreadRowAsync(key); + var entry = new ChatMessageEntity + { + ThreadKey = key, + SenderPlayerId = Me, + Body = text, + }; + db.ChatMessages.Add(entry); + await db.SaveChangesAsync(); + + var recipients = new List(); + if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b)) + { + var other = a == Me ? b : a; + if (other != Me) recipients.Add(other); + } + else if (key.StartsWith("group:")) + { + recipients = await db.ChatThreadMembers + .Where(m => m.ThreadKey == key && m.PlayerId != Me) + .Select(m => m.PlayerId) + .ToListAsync(); + } + // Include the sender so the form-POST send path also refreshes + // its own chat thread — same reason as the JSON send path above. + recipients.Add(Me); + foreach (var rid in recipients) + { + await notifications.NotifyAsync(rid, + PushNotificationId.ChatMessageReceived, + ToWireMessage(entry, thread.Id)); + } + + return Ok(ToWireSendMessageResponse(entry, thread.Id)); + } + + /// Pull ids (multi-valued) + messageContents + /// out of the watch's form post. ids excludes the caller + /// (the caller is implied by their JWT). + private (List ids, string? contents) ParseIdsAndContents( + Microsoft.AspNetCore.Http.IFormCollection form) + { + var ids = new List(); + foreach (var v in form["ids"]) + { + if (long.TryParse(v, out var id) && id > 0 && id != Me) + ids.Add(id); + } + ids = ids.Distinct().ToList(); + var contents = form["messageContents"].ToString(); + return (ids, string.IsNullOrEmpty(contents) ? null : contents); + } + + /// Resolve + caller to a + /// canonical thread key. 1 other → DM key. 2+ others → existing + /// group with the exact same membership, or a fresh + /// group:<guid> if none. Membership rows are created + /// lazily so the inbox listing path can find the thread. + private async Task ResolveOrCreateThreadAsync(List otherIds) + { + if (otherIds.Count == 1) + { + var key = ChatMessageEntity.DmKey(Me, otherIds[0]); + // Ensure a ChatThreadEntity row exists for DMs the watch + // is mutating, so /thread inbox listings carry consistent + // metadata. The membership rows aren't needed for DM + // routing (the key carries the pair) but the inbox queries + // are simpler when every active thread has a row. + if (!await db.ChatThreads.AnyAsync(t => t.ThreadKey == key)) + { + db.ChatThreads.Add(new ChatThreadEntity + { + ThreadKey = key, + CreatorPlayerId = Me, + }); + await db.SaveChangesAsync(); + } + return key; + } + + var full = otherIds.Concat(new[] { Me }).Distinct().OrderBy(x => x).ToList(); + // Look for an existing group whose membership matches exactly. + // Counts the rows per thread that intersect `full`, then takes + // the threads where total membership == |full| AND intersection + // size == |full|. + var candidateGroups = await db.ChatThreadMembers + .Where(m => full.Contains(m.PlayerId) && m.ThreadKey.StartsWith("group:")) + .GroupBy(m => m.ThreadKey) + .Where(g => g.Count() == full.Count) + .Select(g => g.Key) + .ToListAsync(); + foreach (var k in candidateGroups) + { + var rows = await db.ChatThreadMembers + .Where(m => m.ThreadKey == k) + .Select(m => m.PlayerId) + .ToListAsync(); + if (rows.OrderBy(x => x).SequenceEqual(full)) return k; + } + + // No matching group → create one. + var newKey = $"group:{Guid.NewGuid():N}"; + db.ChatThreads.Add(new ChatThreadEntity + { + ThreadKey = newKey, + CreatorPlayerId = Me, + }); + foreach (var pid in full) + { + db.ChatThreadMembers.Add(new ChatThreadMemberEntity + { + ThreadKey = newKey, + PlayerId = pid, + }); + } + await db.SaveChangesAsync(); + return newKey; + } + + /// Pull the Data field out of the watch's + /// messageContents JSON: {"Type":0,"Version":1,"Data":"hi"}. + /// Returns null/empty when the body isn't well-formed. + private static string ExtractChatDataField(string? messageContents) + { + if (string.IsNullOrWhiteSpace(messageContents)) return string.Empty; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(messageContents); + if (doc.RootElement.TryGetProperty("Data", out var data) + && data.ValueKind == System.Text.Json.JsonValueKind.String) + return data.GetString() ?? string.Empty; + } + catch { } + return string.Empty; + } + + /// Add a player to an existing group chat. Idempotent — + /// re-adding an existing member is a no-op. + [HttpPost("/thread/{chatThreadId}/member/{playerId:long}")] + public async Task AddMember(string chatThreadId, long playerId) + { + chatThreadId = await ResolveThreadKeyAsync(chatThreadId); + if (!chatThreadId.StartsWith("group:")) + return Ok(ChatInvalidArguments); + var iAmMember = await db.ChatThreadMembers + .AnyAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me); + if (!iAmMember) return Ok(ChatMembershipNotFound); + var exists = await db.ChatThreadMembers + .AnyAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == playerId); + if (exists) return Ok(ChatPlayerAlreadyOnThread); + if (!exists) + { + db.ChatThreadMembers.Add(new ChatThreadMemberEntity + { + ThreadKey = chatThreadId, + PlayerId = playerId, + }); + await db.SaveChangesAsync(); + } + return Ok(ChatSuccess); + } + + private async Task ResolveThreadKeyAsync(string idOrKey) + { + if (long.TryParse(idOrKey, out var id)) + { + var key = await db.ChatThreads + .Where(t => t.Id == id) + .Select(t => t.ThreadKey) + .FirstOrDefaultAsync(); + return key ?? idOrKey; + } + return idOrKey; + } + + private async Task> EnsureThreadRowsAsync(IEnumerable keys) + { + var distinct = keys.Distinct().ToList(); + var rows = await db.ChatThreads + .Where(t => distinct.Contains(t.ThreadKey)) + .ToDictionaryAsync(t => t.ThreadKey, t => t); + + foreach (var key in distinct.Where(k => !rows.ContainsKey(k))) + { + var row = new ChatThreadEntity + { + ThreadKey = key, + CreatorPlayerId = Me, + }; + db.ChatThreads.Add(row); + rows[key] = row; + } + + if (db.ChangeTracker.HasChanges()) + await db.SaveChangesAsync(); + + return rows; + } + + private async Task EnsureThreadRowAsync(string key) => + (await EnsureThreadRowsAsync(new[] { key }))[key]; + + private async Task>> LoadParticipantsAsync(IEnumerable keys) + { + var distinct = keys.Distinct().ToList(); + var result = distinct.ToDictionary(k => k, _ => new List()); + + foreach (var key in distinct) + { + if (TryParseDmKey(key, out var a, out var b)) + { + result[key] = new List { (int)a, (int)b }; + } + } + + var groupKeys = distinct.Where(k => k.StartsWith("group:")).ToList(); + if (groupKeys.Count > 0) + { + var members = await db.ChatThreadMembers + .Where(m => groupKeys.Contains(m.ThreadKey)) + .Select(m => new { m.ThreadKey, m.PlayerId }) + .ToListAsync(); + foreach (var group in members.GroupBy(m => m.ThreadKey)) + result[group.Key] = group.Select(m => (int)m.PlayerId).Distinct().ToList(); + } + + return result; + } + + private static Dictionary ToWireThread( + ChatThreadEntity thread, + ChatThreadMemberEntity? member, + List playerIds, + ChatMessageEntity? latestMessage = null, + List? messages = null) + { + var dto = new Dictionary + { + ["chatThreadId"] = thread.Id, + ["chatThreadName"] = thread.Name ?? string.Empty, + ["lastReadMessageId"] = member?.LastReadMessageId ?? 0, + ["snoozedUntil"] = member?.SnoozeUntil, + ["playerIds"] = playerIds, + }; + + if (messages is not null) + { + dto["messages"] = messages.Select(m => ToWireMessage(m, thread.Id)).ToList(); + } + else if (latestMessage is not null) + { + dto["latestMessage"] = ToWireMessage(latestMessage, thread.Id); + } + + return dto; + } + + private static Dictionary ToWireMessage(ChatMessageEntity c, long chatThreadId) => new() + { + ["chatMessageId"] = c.Id, + ["chatThreadId"] = chatThreadId, + ["senderPlayerId"] = (int)c.SenderPlayerId, + ["timeSent"] = c.SentAt, + ["contents"] = JsonSerializer.Serialize(new + { + Type = 0, + Version = 1, + Data = c.Body, + }), + }; + + private static Dictionary ToWireSendMessageResponse(ChatMessageEntity c, long chatThreadId) => new() + { + ["chatMessage"] = ToWireMessage(c, chatThreadId), + ["chatResult"] = ChatSuccess, + }; + + private static bool TryParseDmKey(string key, out long a, out long b) + { + a = b = 0; + var parts = key.Split(':'); + return parts.Length == 3 && parts[0] == "dm" && + long.TryParse(parts[1], out a) && + long.TryParse(parts[2], out b); + } + + private static bool IsDmParticipant(string key, long playerId) => + TryParseDmKey(key, out var a, out var b) && (a == playerId || b == playerId); +} diff --git a/DorkNet.Server/Controllers/Commerce/CommerceCatalogController.cs b/DorkNet.Server/Controllers/Commerce/CommerceCatalogController.cs new file mode 100644 index 0000000..d97a2f9 --- /dev/null +++ b/DorkNet.Server/Controllers/Commerce/CommerceCatalogController.cs @@ -0,0 +1,212 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Commerce; + +/// +/// commerce.rec.net/api/catalog/* — the actual catalog endpoint the +/// 2020 watch's Shop tab fetches when it loads. The previous +/// implementation routed everything on commerce.rec.net through the +/// generic StubController which always returned []; this +/// controller wins on specific routes via ASP.NET Core's +/// specific-over-wildcard precedence and returns the real +/// catalog. +/// +/// Wire shape verified against the watch's Commerce.Catalog +/// deserialiser logging "no items" when we returned an empty array +/// vs populating the Shop UI when each item carries SkuId/Name/Cost +/// keys. Each catalog DTO ships every common alias (PascalCase, +/// camelCase, snake_case) so whichever JSON field the watch's +/// strict-key reader expects, it finds. +/// +[ApiController] +public class CommerceCatalogController(StoreService store, DomainConfig domain) : ControllerBase +{ + [HttpGet("/api/catalog/v1/all")] + [HttpGet("/api/catalog/v2/all")] + [HttpGet("/api/catalog/v3/all")] + public async Task All([FromQuery] bool onlyAvailableSkus = false) + { + var items = onlyAvailableSkus + ? await store.GetAllActiveAsync() + : await store.GetAllActiveAsync(); // same set; we don't distinguish yet + return Ok(items.Select(i => ToCatalogDto(i, domain.Apex)).ToArray()); + } + + [HttpGet("/api/catalog/v1/sku/{slug}")] + [HttpGet("/api/catalog/v2/sku/{slug}")] + public async Task Sku(string slug) + { + var item = await store.GetBySlugAsync(slug); + if (item is null) return NotFound(); + return Ok(ToCatalogDto(item, domain.Apex)); + } + + /// POST /api/catalog/v1/byids — bulk lookup by id array. + /// The watch sends {Ids:[1,2,3]} as form-urlencoded; accept both + /// JSON and form bodies for safety. + [HttpPost("/api/catalog/v1/byids")] + [HttpPost("/api/catalog/v2/byids")] + public async Task ByIds() + { + string? raw = null; + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + raw = form["Ids"].ToString(); + if (string.IsNullOrWhiteSpace(raw)) raw = form["ids"].ToString(); + } + if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["Ids"].ToString(); + if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["ids"].ToString(); + if (string.IsNullOrWhiteSpace(raw)) return Ok(Array.Empty()); + + var ids = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => long.TryParse(s, out var v) ? v : 0L) + .Where(v => v > 0) + .Distinct() + .ToArray(); + if (ids.Length == 0) return Ok(Array.Empty()); + + var all = await store.GetAllActiveAsync(); + return Ok(all.Where(i => ids.Contains(i.Id)).Select(i => ToCatalogDto(i, domain.Apex)).ToArray()); + } + + /// Catalog wire shape — every common key alias present + /// (PascalCase + camelCase + snake_case) so the watch's + /// strict-key deserialiser hits whichever name it's coded + /// against without crashing on a missing field. + /// + /// Implemented as a Dictionary because System.Text.Json's + /// case-insensitive collision check rejects anonymous types whose + /// property names differ only in casing (SkuId vs + /// skuId, etc.) — same trick we use in + /// RelationshipsController.BuildRelationshipResponse. + private static Dictionary ToCatalogDto(Data.Entities.StoreItemEntity i, string apex) + { + var available = i.IsActive && (i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow); + var imageUrl = string.IsNullOrEmpty(i.ImageName) ? "" : $"https://cdn.{apex}/{i.ImageName}"; + return new Dictionary + { + // Identity / naming — every alias the watch's strict + // deserialiser might key on. + // + // CRITICAL: SkuId is typed int on + // RecNet.Commerce+SKU + // (Cpp2IL_CS/.../RecNet/Commerce.cs:80 + // private int <SkuId>k__BackingField). + // Util.GetKey<int>("SkuId") calls Convert.ChangeType, + // which throws FormatException if the value isn't + // parseable as an int. The previous shape shipped + // i.Slug (a "wardrobe-<guid>" string) under + // SkuId — that's the FormatException at + // output_log.txt:1655 which corrupts the catalog + // load and downstream destabilises the watch's player + // object (manifests as the dorm-load destroy crash). + // Ship the int row id under SkuId; keep the slug + // under Slug/Sku aliases for any string + // consumer. + ["SkuId"] = i.Id, + ["skuId"] = i.Id, + ["Sku"] = i.Slug, + ["sku"] = i.Slug, + ["ItemId"] = i.Id, + ["itemId"] = i.Id, + ["Id"] = i.Id, + ["id"] = i.Id, + ["Slug"] = i.Slug, + ["slug"] = i.Slug, + + ["Name"] = i.DisplayName, + ["name"] = i.DisplayName, + ["DisplayName"] = i.DisplayName, + ["displayName"] = i.DisplayName, + ["Title"] = i.DisplayName, + ["title"] = i.DisplayName, + + ["Description"] = i.Description, + ["description"] = i.Description, + + ["Category"] = i.Category, + ["category"] = i.Category, + ["ItemType"] = i.Category, + ["itemType"] = i.Category, + + // Cost / price — flat + nested forms. + ["Price"] = i.Price, + ["price"] = i.Price, + ["Cost"] = new Dictionary { ["Amount"] = i.Price, ["CurrencyType"] = i.CurrencyType }, + ["cost"] = new Dictionary { ["amount"] = i.Price, ["currencyType"] = i.CurrencyType }, + ["CurrencyType"] = i.CurrencyType, + ["currencyType"] = i.CurrencyType, + ["Amount"] = i.Price, + ["amount"] = i.Price, + + ["ImageName"] = i.ImageName, + ["imageName"] = i.ImageName, + ["Image"] = i.ImageName, + ["image"] = i.ImageName, + ["ImageUrl"] = imageUrl, + ["imageUrl"] = imageUrl, + + ["Available"] = available, + ["available"] = available, + ["IsAvailable"] = available, + ["isAvailable"] = available, + ["IsActive"] = i.IsActive, + ["isActive"] = i.IsActive, + ["IsLimitedTime"] = i.IsLimitedTime, + ["isLimitedTime"] = i.IsLimitedTime, + ["AvailableUntil"] = i.AvailableUntil, + ["availableUntil"] = i.AvailableUntil, + + ["Storefront"] = i.Storefront, + ["storefront"] = i.Storefront, + + ["CreatedAt"] = i.CreatedAt, + ["createdAt"] = i.CreatedAt, + + // Required RecNet.Commerce+SKU fields the watch's strict + // Util.GetKey deserialiser throws KeyNotFoundException on + // when missing. Verified at + // Cpp2IL_CS/.../RecNet/Commerce.cs:80-102 — SKU has + // OculusSkuId / AppleProductId / PSNProductLabel / + // IsSingleUse / Data / DisplayPrice / LongDescription as + // public-private (read+private-set) properties; missing + // any of them at deserialize time causes the SKU import + // to abort halfway and leaves the catalog in a half-built + // state that the StoreScreen UI binding then native-crashes + // on (output_log.txt:853 — last log line is a + // RRUI.Data.TypedDataSource resolving List.Count + // followed by silent process exit). + ["OculusSkuId"] = string.Empty, + ["oculusSkuId"] = string.Empty, + ["AppleProductId"] = string.Empty, + ["appleProductId"] = string.Empty, + ["PSNProductLabel"] = string.Empty, + ["psnProductLabel"] = string.Empty, + ["IsSingleUse"] = false, + ["isSingleUse"] = false, + ["DisplayPrice"] = i.Price.ToString(), + ["displayPrice"] = i.Price.ToString(), + ["LongDescription"] = i.Description ?? string.Empty, + ["longDescription"] = i.Description ?? string.Empty, + + // SKUData is itself an IRecNetObject with required keys — + // GiftDropIds (List), Message (string), + // SubscriptionPurchase (object). Empty list / empty + // string / null sub-purchase are valid. + ["Data"] = new Dictionary + { + ["GiftDropIds"] = Array.Empty(), + ["Message"] = string.Empty, + ["SubscriptionPurchase"] = (object?)null, + }, + ["data"] = new Dictionary + { + ["giftDropIds"] = Array.Empty(), + ["message"] = string.Empty, + ["subscriptionPurchase"] = (object?)null, + }, + }; + } +} diff --git a/DorkNet.Server/Controllers/Discovery/DiscoveryController.cs b/DorkNet.Server/Controllers/Discovery/DiscoveryController.cs new file mode 100644 index 0000000..b5ec221 --- /dev/null +++ b/DorkNet.Server/Controllers/Discovery/DiscoveryController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Discovery; + +/// +/// discovery.{rec.net,localhost} — the watch's room-discovery surface +/// (rooms tab "For You" feed). Backed by the same RoomService used +/// elsewhere so what's hot here matches the main browser. +/// +[ApiController] +public class DiscoveryController(RoomService rooms) : ControllerBase +{ + [HttpGet("/discover/v2/rooms")] + public async Task GetRooms([FromQuery] string? tag, [FromQuery] int take = 50) + => Ok((await rooms.HotAsync(tag, Math.Clamp(take, 1, 100))) + .Select(RoomService.ToWireRoom).ToList()); + + [HttpGet("/discover/v2/feed")] + public async Task GetFeed([FromQuery] int take = 50) + => Ok((await rooms.HotAsync(null, Math.Clamp(take, 1, 100))) + .Select(RoomService.ToWireRoom).ToList()); + + [HttpGet("/discover/v2/featured")] + public async Task GetFeatured([FromQuery] int take = 50) + // Featured = tagged "featured" — same set as the watch's + // primary "Featured" filter chip. + => Ok((await rooms.HotAsync("featured", Math.Clamp(take, 1, 100))) + .Select(RoomService.ToWireRoom).ToList()); +} diff --git a/DorkNet.Server/Controllers/Econ/EconController.cs b/DorkNet.Server/Controllers/Econ/EconController.cs new file mode 100644 index 0000000..3e64c0c --- /dev/null +++ b/DorkNet.Server/Controllers/Econ/EconController.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Services; +using System.Text.Json.Serialization; + +namespace DorkNet.Server.Controllers.Econ; + +/// +/// econ.{rec.net,localhost} — token balance + inventory queries used +/// by older client flows. Reads from +/// (currency type 0 = Token per CurrencyType.cs) and the +/// table. Note: the primary +/// store/inventory surface is api.*/api/storefronts/* + api/inventory/*; +/// this host is a legacy alias. +/// +[ApiController] +[Authorize] +public class EconController(DorkNetDbContext db, LevelService level) : ControllerBase +{ + [HttpGet("/econ/v1/balance/{accountId:long}")] + public async Task> GetBalance(long accountId) + { + var tokens = await level.GetBalanceAsync(accountId, 0); + return Ok(new TokenBalance + { + AccountId = accountId, + Tokens = (int)tokens, + BonusTokens = 0, + }); + } + + [HttpGet("/econ/v1/ownershipbatch")] + public async Task GetOwnershipBatch([FromQuery] string? ids) + { + var pid = this.RequireCurrentPlayerId(); + var slugs = (ids ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + var q = db.PlayerInventory.Where(p => p.PlayerId == pid); + if (slugs.Count > 0) q = q.Where(p => slugs.Contains(p.ItemSlug)); + var rows = await q.Select(p => new { p.ItemSlug, p.Quantity }).ToListAsync(); + return Ok(rows); + } + + [HttpGet("/econ/v1/inventory/{accountId:long}")] + public async Task GetInventory(long accountId) + { + var rows = await db.PlayerInventory + .Where(p => p.PlayerId == accountId) + .Select(p => new { p.ItemSlug, p.Quantity }) + .ToListAsync(); + return Ok(rows); + } + + [HttpGet("/econ/v1/products")] + public async Task GetProducts() + { + var rows = await db.StoreItems + .Where(s => s.IsActive) + .Select(s => new + { + Id = s.Id, + Slug = s.Slug, + Name = s.DisplayName, + Price = s.Price, + CurrencyType = s.CurrencyType, + }) + .ToListAsync(); + return Ok(rows); + } +} + +public class TokenBalance +{ + [JsonPropertyName("AccountId")] public long AccountId { get; set; } + [JsonPropertyName("Tokens")] public int Tokens { get; set; } + [JsonPropertyName("BonusTokens")] public int BonusTokens { get; set; } +} diff --git a/DorkNet.Server/Controllers/Geo/GeoController.cs b/DorkNet.Server/Controllers/Geo/GeoController.cs new file mode 100644 index 0000000..577597d --- /dev/null +++ b/DorkNet.Server/Controllers/Geo/GeoController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DorkNet.Server.Controllers.Geo; + +/// +/// geo.{apex}/v1/regions — region/server geography. Returns a single +/// configured region (we don't actually run multi-region; the watch's +/// region picker just needs one entry to populate the dropdown). +/// +/// The /v1/ping endpoint that previously lived here was +/// removed during the [Host]-strip refactor — both geo and ns +/// answered the same path (NsController.Ping is the canonical +/// one), and with host filters gone the two routes would have +/// ambiguous-matched on any subdomain. NsController wins because +/// its ping body carries the timestamp the keep-alive loop reads; +/// the geo version returned a bare {Ok:true} that nothing consumed. +/// +[ApiController] +public class GeoController(IConfiguration config) : ControllerBase +{ + [HttpGet("/v1/regions")] + public ActionResult> GetRegions() + { + var region = config["Photon:CloudRegion"] ?? "us"; + return Ok(new[] + { + new { Id = region, Name = region.ToUpperInvariant(), Ping = 50 }, + }); + } +} diff --git a/DorkNet.Server/Controllers/Health/HealthController.cs b/DorkNet.Server/Controllers/Health/HealthController.cs new file mode 100644 index 0000000..b538d03 --- /dev/null +++ b/DorkNet.Server/Controllers/Health/HealthController.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Controllers.Health; + +/// +/// GET /healthz — liveness/readiness probe for Coolify rolling deploys. +/// +/// Returns 503 until BOTH conditions hold: +/// 1. flag has been set by Program.cs after +/// db.Database.Migrate() returns (so a new replica that's still +/// running migrations doesn't get traffic shifted to it before its schema +/// is current). +/// 2. db.Database.CanConnectAsync() succeeds (catches transient DB +/// blips and prevents traffic during a Postgres restart). +/// +/// Coolify's rolling-deploy strategy waits for the new container's healthz +/// to return 200 before draining the old one. Combined with a brief +/// (~5–10s) `start-first` overlap, this is the closest thing to zero +/// downtime we get with single-region hosting. +/// +/// HostFilteringOptions is configured (via DomainConfig) to accept +/// localhost + 127.0.0.1 alongside the apex pool so the +/// Coolify internal healthcheck (which hits localhost:8080/healthz +/// from inside the container) reaches this handler — earlier +/// per-controller [Host(...)] filters would have 404ed it. +/// +/// Distinct from /v1/ping on the ns subdomain — that returns a +/// timestamped payload the watch's keep-alive loop reads and is part of +/// the wire contract. We deliberately don't change it. +/// +[ApiController] +public class HealthController(DorkNetDbContext db) : ControllerBase +{ + /// Set to true by Program.cs after the EF Core + /// Migrate() call returns successfully. Volatile because + /// it's read from request threads concurrently with the startup + /// thread's write. Process-local — every replica sets its own + /// flag once its own startup migrations finish. + public static volatile bool MigrationsComplete = false; + + // Order is more-negative than CdnController.Serve's wildcard + // (Order = -100). Without this the catch-all matched /healthz + // first on the localhost host (no subdomain) and returned + // 404 → Docker's HEALTHCHECK curl --fail exited non-zero → + // Coolify rolled the new container back. + [HttpGet("/healthz", Order = -1000)] + public async Task Healthz() + { + if (!MigrationsComplete) + return StatusCode(503, new { status = "starting", reason = "migrations_pending" }); + + // CanConnectAsync uses a cheap "SELECT 1"-equivalent — fast enough + // to run on every probe (~1ms for a warm pool). Cheaper than the + // alternative of failing only on actual query timeouts. + bool dbOk; + try { dbOk = await db.Database.CanConnectAsync(); } + catch { dbOk = false; } + + if (!dbOk) + return StatusCode(503, new { status = "unhealthy", reason = "db_unreachable" }); + + return Ok(new { status = "ok" }); + } +} diff --git a/DorkNet.Server/Controllers/Match/GoToController.cs b/DorkNet.Server/Controllers/Match/GoToController.cs new file mode 100644 index 0000000..6115fb9 --- /dev/null +++ b/DorkNet.Server/Controllers/Match/GoToController.cs @@ -0,0 +1,933 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json.Serialization; +using DorkNet.Server.Auth; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Match; + +/// +/// match.rec.net/goto/* — matchmaking endpoints used by +/// RecNet.Matchmaking.GoToHelper / GoToDormRoom (the latter is what +/// BootSequence.LoadDormRoom invokes during initial scene load). +/// +/// Response shape verified by disassembling +/// RecNet.Matchmaking.MatchmakingResponse.Deserialize (RVA 0x1447DD0): +/// "errorCode" - int, optional (Util.GetKeyOrDefault<int>) +/// "roomInstance" - RoomInstance object, REQUIRED. The deserializer +/// throws explicitly with the error +/// "Matchmaking response has no RoomInstance field" +/// when this key is missing or non-object. +/// +/// The nested RoomInstance shape (RVA 0x114DAB0) requires: +/// roomInstanceId, roomId, subRoomId, location, photonRegionId, +/// photonRoomId, maxCapacity, isFull, isPrivate, isInProgress +/// (all required) plus optional dataBlob, eventId, name. +/// +/// The client then JOINS the Photon room named by photonRoomId in the +/// region photonRegionId — these two values are what tie our HTTP +/// matchmaking to the actual realtime networking. +/// +[ApiController] +public class GoToController( + IConfiguration config, + PlayerPresenceService presence, + PrivateInstanceService privateInstances, + RoomService rooms, + DorkNet.Server.Data.DorkNetDbContext db, + NotificationService notifications, + ILogger logger) : ControllerBase +{ + [HttpPost("/goto/room/{roomName}")] + public async Task> GoToRoom( + string roomName, + [FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false) + { + // Unknown room name shouldn't silently drop the player into a + // synth'd dorm scene any more — that masked typo bug reports + // ("why does /goto/^Crescendoo land me in my dorm?") and let + // dead links sit unfixed. Surface the real "no such room" + // error so the watch shows its standard "room not found" toast + // and the typo is obvious. DormRoom is a special case (per-player + // lazy-create), everything else MUST exist in db.Rooms. + if (!roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase)) + { + var exists = await rooms.GetByNameAsync(roomName); + if (exists is null) + { + logger.LogWarning("[goto] unknown room name='{Name}' — returning RoomDoesNotExist", roomName); + return Ok(new MatchmakingResponseDto + { + // 4 = RoomDoesNotExist on + // RecNet.Matchmaking.CreateModifyRoomStatus enum. + // The watch reads ErrorCode != 0 and surfaces the + // standard "Room not found" toast. + ErrorCode = 4, + RoomInstance = null, + }); + } + } + var resp = await BuildResponseAsync(roomName); + if (createPrivateInstance) await ApplyPrivateInstanceAsync(resp, subRoomId: 0); + await RecordResponseAsync(resp); + return Ok(resp); + } + + /// POST /goto/room/{room}/{subRoom} — sub-room form + /// from RecNet.Matchmaking.GoToSubRoom + /// (Cpp2IL_ISIL/.../Matchmaking.txt:4682-4702): + /// String.Concat("goto/room/", urlEncode(room), "/", urlEncode(subRoom)). + /// Used by baked AG-Original rooms with multiple scenes (e.g. + /// StuntRunner/TheMainEvent, the playable gauntlet inside + /// the StuntRunner lobby). + /// + /// We resolve to the parent room's for + /// the canonical RoomId + LocationReplicationId, then expose the + /// sub-room name via the name field so the client can + /// render the right scene. If we ever build a per-sub-room scene + /// location lookup table, switch + /// to the matching scene's ReplicationId. + [HttpPost("/goto/room/{roomName}/{subRoomName}")] + public async Task> GoToSubRoom( + string roomName, string subRoomName, + [FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false) + { + // Build (don't record yet) — we need to mutate the RoomInstance + // for the sub-room before presence is recorded, otherwise the + // server presence has the lobby's RoomInstance but the client + // thinks it's in the sub-room → heartbeat sees a mismatch and + // the watch logs "presence out-of-sync" / Error -2. + var resp = await BuildResponseAsync(roomName); + + // Imported multi-scene rooms (admin Import Room) get one + // RoomSceneEntity row per chapter — prefer that when the + // (room, subRoom) pair matches. Falls back to the hardcoded + // baked-room map for AG-Originals like StuntRunner/TheMainEvent. + // + // Lookup is tolerant: in-game portals don't always pass the + // canonical scene Name. We match in this order: + // 1. exact (case-insensitive — RoomScenes.Name is NOCASE collated) + // 2. EndsWith(subRoomName) — handles portals that omit + // a "Ch0_" / "Chapter1_" prefix + // 3. Contains(subRoomName) — last-resort fuzzy match + // and fall back to the hardcoded location map only when no + // RoomScenes row matches at all. + var allScenes = await db.RoomScenes + .Where(s => s.RoomId == resp.RoomInstance.RoomId) + .Select(s => new { s.OrderIndex, s.Name, s.RoomSceneLocationId, s.DataBlobName }) + .ToListAsync(); + + var dbScene = + allScenes.FirstOrDefault(s => + string.Equals(s.Name, subRoomName, StringComparison.OrdinalIgnoreCase)) + ?? allScenes.FirstOrDefault(s => + s.Name.EndsWith(subRoomName, StringComparison.OrdinalIgnoreCase)) + ?? allScenes.FirstOrDefault(s => + s.Name.IndexOf(subRoomName, StringComparison.OrdinalIgnoreCase) >= 0); + + long subId; + string? subLocation; + string? subDataBlob; + if (dbScene is not null) + { + logger.LogInformation( + "[goto-subroom] {Room}/{Requested} → matched scene '{MatchedName}' idx={Idx} loc={Loc} blob={Blob}", + roomName, subRoomName, dbScene.Name, dbScene.OrderIndex, + dbScene.RoomSceneLocationId, dbScene.DataBlobName); + subId = dbScene.OrderIndex; + subLocation = dbScene.RoomSceneLocationId; + subDataBlob = dbScene.DataBlobName; + } + else + { + logger.LogWarning( + "[goto-subroom] {Room}/{Requested} — no RoomScenes match (room has {Count} scenes: {Names}). Falling back to hardcoded MapSubRoomToLocationId.", + roomName, subRoomName, allScenes.Count, + string.Join(", ", allScenes.Select(s => s.Name))); + subId = Math.Abs((long)subRoomName.GetHashCode()); + subLocation = MapSubRoomToLocationId($"{roomName}/{subRoomName}".ToLowerInvariant()); + subDataBlob = null; + } + + // Both PhotonRoomId AND RoomInstanceId must differ for the + // watch's transition state machine to fire. Empirical from + // comparing the two failure modes: + // Same photon + same roomInstanceId → blob downloads (because + // dataBlob field changed) but watch never transitions; the + // "Going to X" dialog hangs because nothing the state + // machine tracks signalled "I'm in a different match". + // Different photon + same roomInstanceId → watch doesn't + // even fetch the new dataBlob; it sees roomInstanceId + // unchanged so treats the response as a no-op presence + // update and skips the join flow. + // Folding subId into RoomInstanceId AND forking PhotonRoomId + // gives the watch the two signals it needs: "new instance" + // (roomInstanceId differs → reset state machine) plus "new + // photon match" (photonRoomId differs → leave/rejoin → + // SceneLoaded events that ultimately dismiss the dialog). + resp.RoomInstance.RoomInstanceId = resp.RoomInstance.RoomId * 100_000L + subId; + resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_sub{subId}"; + resp.RoomInstance.Name = subRoomName; + resp.RoomInstance.SubRoomId = subId; + if (subLocation is not null) resp.RoomInstance.Location = subLocation; + // dataBlob on the matchmaking response tells the client which + // PersistedRoomData blob to download for the entered scene. + // RoomInstance.Deserialize at RVA 0x114DAB0 reads it via the + // string-with-default reader (call 0x180EE6210), so empty is + // technically legal — but if it's empty the watch falls back + // to the parent room's RoomDataBlobName, which is the lobby's + // blob, so the sub-scene visually never changes. + if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob; + + // Private instance mutation goes AFTER sub-room mutation so the + // PhotonRoomId we register includes the _sub{N} suffix as its + // base — invitees coming in via /goto/instance/{id} land in the + // exact same sub-room photon match, not the lobby's. + if (createPrivateInstance) await ApplyPrivateInstanceAsync(resp, subRoomId: subId); + + await RecordResponseAsync(resp); + return Ok(resp); + } + + /// Per-sub-room scene location overrides for baked + /// AG-Original rooms with multiple scenes. Each entry maps a + /// "{room}/{subRoom}" key (lowercase) to the + /// RoomSceneLocationId GUID baked into the client's + /// AGRoomRuntimeConfig.Locations table. Without an entry the + /// parent room's location is reused (lobby loads, door opens + /// but transition no-ops). + /// + /// Add entries here as you confirm each sub-scene's GUID — the + /// client's AGRoomSettings.TryGetRoomSceneLocationById + /// rejects unknown GUIDs with "RecNet game session contains + /// unknown scene location ID" and refuses to load. + /// Sub-room scene GUIDs extracted from the bundled + /// resources.assets AGRoomRuntimeConfig.Rooms[].Scenes[] + /// using tools/extract-rooms-binary.py. Each entry maps + /// "{room}/{subRoom}" (lowercase) to the target + /// RoomSceneLocationId GUID. Missing sub-rooms reuse the + /// parent room's lobby location (door opens but transition no-ops). + private static string? MapSubRoomToLocationId(string subKey) => subKey switch + { + // ── StuntRunner ── + // Lobby is the implicit Scene[0]; TheMainEvent is the + // playable gauntlet behind the door. + "stuntrunner/themainevent" => "3a636bd2-f896-424c-9225-c184522c0d87", + + // ── Paintball (River map + variants) ── + // The Paintball Room has nested sub-rooms for each map; the + // lobby acts as a hub and players pick a map from a board. + "paintball/river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + "paintball/homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7", + "paintball/quarry" => "ff4c6427-7079-4f59-b22a-69b089420827", + "paintball/clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161", + "paintball/spillway" => "58763055-2dfb-4814-80b8-16fac5c85709", + "paintballvr/river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + "paintballvr/homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7", + "paintballvr/quarry" => "ff4c6427-7079-4f59-b22a-69b089420827", + "paintballvr/clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161", + "paintballvr/spillway" => "58763055-2dfb-4814-80b8-16fac5c85709", + + // ── LaserTag ── + "lasertag/cyberjunkcity" => "9d6456ce-6264-48b4-808d-2d96b3d91038", + + _ => null, + }; + + [HttpPost("/goto/event/{eventId:long}")] + public async Task> GoToEvent(long eventId) + { + var resp = await BuildResponseAsync("DormRoom"); + await RecordResponseAsync(resp); + return Ok(resp); + } + + /// POST /goto/instance/{roomInstanceId} — invite- + /// accept route. The watch posts here when joining a private + /// match via a Message-attached invite. We resolve the instance + /// id to the registered + /// and return a RoomInstance pinned to the inviter's PhotonRoomId + /// so both players land in the same Photon match. Falls back to + /// dorm if the instance was forgotten (server restart, owner + /// already left and nobody re-created it). + [HttpPost("/goto/instance/{roomInstanceId:long}")] + public async Task> GoToInstance(long roomInstanceId) + { + var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId); + await RecordResponseAsync(resp); + return Ok(resp); + } + + [HttpPost("/goto/invite/{inviteId:long}")] + public async Task> GoToInvite(long inviteId) + { + var callerId = this.CurrentPlayerId(); + if (callerId is null) + { + var unauth = await BuildResponseAsync("DormRoom"); + await RecordResponseAsync(unauth); + return Ok(unauth); + } + + var invite = await db.Messages.FirstOrDefaultAsync(m => + m.Id == inviteId + && m.RecipientPlayerId == callerId.Value + && (m.Type == 6 || m.Type == 7)); + + long? roomInstanceId = null; + if (invite is not null && TryParseRoomInstanceId(invite.Body, out var parsed)) + roomInstanceId = parsed; + + // The 2020 watch's accept-invite flow fires + // POST /api/messages/v3/delete AND POST /goto/invite/{id} in + // parallel — verified in the live server log: + // 09:53:47.161Z DELETE messageIds=[60] + // 09:53:47.245Z /goto/invite/60 → [goto-invite] invite 60 not found + // DELETE usually wins the race, so the message row is gone + // by the time we run FirstOrDefaultAsync. The + // (instance, player) → LatestInviteMessageId mapping that + // SendInvite writes onto the PrivateInstanceInviteeEntity + // survives the message deletion and lets us recover + // roomInstanceId here. + if (roomInstanceId is null) + { + var inviteeRoomInstance = await db.PrivateInstanceInvitees + .Where(i => i.PlayerId == callerId.Value && i.LatestInviteMessageId == inviteId) + .Select(i => (long?)i.PrivateInstanceId) + .FirstOrDefaultAsync(); + if (inviteeRoomInstance is long inst) + { + roomInstanceId = inst; + logger.LogInformation( + "[goto-invite] message {InviteId} missing (likely raced with DELETE); resolved roomInstance={Instance} via invitee fallback for caller {Caller}", + inviteId, inst, callerId); + } + } + + if (roomInstanceId is null) + { + logger.LogWarning( + "[goto-invite] invite {InviteId} not found and no invitee fallback for caller {Caller}", + inviteId, callerId); + return Ok(new MatchmakingResponseDto + { + ErrorCode = 40, // RoomInviteExpired + RoomInstance = new RoomInstanceDto(), + }); + } + + // Mark the message read. The watch's parallel DELETE may have + // wiped the row between our initial read and this save — + // swallow the resulting DbUpdateConcurrencyException since the + // accept has already succeeded at this point. + if (invite is not null && invite.ReadAt is null) + { + invite.ReadAt = DateTime.UtcNow; + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + db.Entry(invite).State = EntityState.Detached; + } + } + + var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId.Value); + await RecordResponseAsync(resp); + return Ok(resp); + } + + private async Task BuildPrivateInstanceJoinResponseAsync(long roomInstanceId) + { + var inst = await privateInstances.GetAsync(roomInstanceId); + if (inst is null) + { + logger.LogWarning( + "[goto-instance] {Id} → no registered private instance; routing to dorm", + roomInstanceId); + return await BuildResponseAsync("DormRoom"); + } + + // Enforce invite-list membership. Owner is always allowed (so + // they can rejoin their own match after a disconnect). Anyone + // else needs to have been invited via POST /invite. We refuse + // with errorCode != 0 so the watch surfaces a "join failed" + // toast instead of silently routing to the wrong photon room. + if (this.CurrentPlayerId() is long callerId + && !await privateInstances.CanJoinAsync(roomInstanceId, callerId)) + { + logger.LogWarning( + "[goto-instance] {Id} → caller {Caller} is not on the invite list (owner={Owner}); rejecting", + roomInstanceId, callerId, inst.OwnerPlayerId); + // MatchmakingErrorCode 26 = RoomInstanceIsPrivate — the + // dedicated "you're not invited" error from the + // RecNet.Matchmaking+MatchmakingErrorCode enum (verified in + // Cpp2IL_CS/.../Matchmaking.cs line 166). Surfaces a clearer + // "this is a private match" message on the watch than + // 9 = RoomNotJoinable does. + return new MatchmakingResponseDto + { + ErrorCode = 26, + RoomInstance = new RoomInstanceDto(), + }; + } + + return new MatchmakingResponseDto + { + ErrorCode = 0, + RoomInstance = new RoomInstanceDto + { + RoomInstanceId = inst.InstanceId, + RoomId = inst.RoomId, + SubRoomId = inst.SubRoomId, + Location = inst.Location, + PhotonRegionId = inst.PhotonRegion, + PhotonRoomId = inst.PhotonRoomId, + Name = inst.Name, + MaxCapacity = inst.MaxCapacity, + IsPrivate = true, + IsInProgress = true, + DataBlob = inst.DataBlob, + }, + }; + } + + // Invite-message Body can be any of three shapes depending on how / + // when it was written: + // 1. Modern (current): JSON {"inviteId":N,"name":"…","roomInstanceId":N} + // — extract roomInstanceId from the JSON property. + // 2. Legacy (pre-V2 fix): bare integer = roomInstanceId. + // 3. Ancient (pre-Type-column): text "Invite to private match + // (instance N)" — parse the trailing integer out of the literal. + // Honour all three so old rows in the DB still resolve. + private static bool TryParseRoomInstanceId(string body, out long instanceId) + { + instanceId = 0; + if (string.IsNullOrWhiteSpace(body)) return false; + if (body.TrimStart().StartsWith('{')) + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("roomInstanceId", out var v)) + { + // String form first — the SendInvite serializer now + // emits roomInstanceId as a quoted string because + // LitJson on the watch fails to parse a JSON object + // that contains a 64-bit numeric literal larger than + // int32 (returns null from the dict cast → NRE in + // Util.GetKey downstream). Number form stays + // supported for any old rows that pre-dated the + // stringify fix. + if (v.ValueKind == System.Text.Json.JsonValueKind.String + && long.TryParse(v.GetString(), System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var strInst)) + { instanceId = strInst; return true; } + if (v.ValueKind == System.Text.Json.JsonValueKind.Number + && v.TryGetInt64(out var numInst)) + { instanceId = numInst; return true; } + } + } + catch { /* fall through to legacy formats */ } + } + if (long.TryParse(body, out instanceId)) return true; + var open = body.IndexOf("instance ", StringComparison.Ordinal); + if (open < 0) return false; + var tail = body[(open + "instance ".Length)..].TrimEnd(')', '.', ' '); + return long.TryParse(tail, out instanceId); + } + + /// POST /goto/player/{playerId} — join the room + /// the target player is currently in. + /// + /// For PUBLIC rooms: mirror the target's room name and rebuild a + /// fresh RoomInstance (the caller may end up in a different + /// matchmade instance, which is fine for public rooms). + /// + /// For PRIVATE rooms: this route must NOT bypass the invite list. + /// Earlier we mirrored the target's RoomInstance verbatim under + /// the assumption "Go to player only shows up for friends" — + /// that's wrong: friend ≠ invited, and the owner picked Private + /// specifically to control who joins. Caller must be the owner + /// or on the invite list per ; + /// otherwise we refuse with errorCode=9 (RoomNotJoinable). + [HttpPost("/goto/player/{playerId:long}")] + public async Task> GoToPlayer(long playerId) + { + var theirRoom = presence.GetRoom(playerId); + MatchmakingResponseDto resp; + if (theirRoom is null) + { + resp = await BuildResponseAsync("DormRoom"); + } + else if (theirRoom.IsPrivate) + { + // RoomInstanceId IS the registry key for private matches — + // generated by PrivateInstanceService.Register. Caller must + // be invited (or owner) to land in the same Photon room. + var callerId = this.CurrentPlayerId(); + if (callerId is null + || !await privateInstances.CanJoinAsync(theirRoom.RoomInstanceId, callerId.Value)) + { + logger.LogWarning( + "[goto-player] caller {Caller} tried to follow {Target} into private instance {Id} but isn't invited; rejecting", + callerId, playerId, theirRoom.RoomInstanceId); + // Notify the OWNER that someone tried to follow them in. + // Helps surface the rejection so the owner knows there's + // an interested party — they can choose to send an invite. + await notifications.PrivateInstanceJoinRejected( + ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0); + return Ok(new MatchmakingResponseDto + { + ErrorCode = 26, // RoomInstanceIsPrivate + RoomInstance = new RoomInstanceDto(), + }); + } + // Allowed — mirror the target's RoomInstance so we land + // in the same Photon match. + resp = new MatchmakingResponseDto { ErrorCode = 0, RoomInstance = theirRoom }; + } + else + { + var roomName = await db.Rooms + .Where(r => r.Id == theirRoom.RoomId) + .Select(r => r.Name) + .FirstOrDefaultAsync(); + resp = await BuildResponseAsync(roomName ?? "DormRoom"); + } + await RecordResponseAsync(resp); + return Ok(resp); + } + + /// Record the just-built response against the bearer-token + /// player so the next heartbeat reflects the room they joined. + /// Anonymous /goto calls (no JWT) skip recording — the heartbeat is + /// authenticated, so a match only happens when the same JWT shows + /// up there. Without this, heartbeats return roomInstance:null and + /// the watch trips its "presence out-of-sync" detector → "Error: -2". + /// Caller is responsible for any sub-room or private-instance + /// mutations BEFORE handing the response in here. + private async Task RecordResponseAsync(MatchmakingResponseDto response) + { + if (this.CurrentPlayerId() is not long playerId) return; + + presence.SetRoom(playerId, response.RoomInstance); + + // Real visit tracking: upsert the per-(room, player) row so + // we get distinct VisitorCount (unique players ever) AND the + // gross VisitCount (every join, including rejoins). Skip the + // canonical dorm (RoomId=1) so per-day boots don't skew it. + if (response.RoomInstance.RoomId != 1) + await RecordVisitAsync(playerId, response.RoomInstance.RoomId); + + // Push a presence-update notification to every accepted-friend + // so their watch's "friends" list shows the new room. Cheap + // query — Relationships is small, and we only fan out to the + // actually-connected friends. + await NotifyFriendsOfMoveAsync(playerId, response.RoomInstance); + + // NOTE: we INTENTIONALLY do NOT self-push SubscriptionUpdateRoom + // from /goto. The watch's Rooms.OnSubscriptionUpdateRoom + // (Cpp2IL_ISIL/.../RecNet/Rooms.txt:2401-2496) is gated on + // (Rooms.cachedRoomId == roomId && Rooms.cachedSubRoomId == subRoomId): + // the FIRST push for a (room, subRoom) tuple does the fetch + + // set_LocalRoomDetails; subsequent pushes for the same tuple + // exit early at line 055, NEVER firing the + // RoomDetailsUpdated event. The save coroutine's + // RoomDetailsUpdatedCallback is registered to that event + // (RoomPersistenceManager+DisplayClass174_0:14-48) and only + // sets the roomDetailsUpdated flag when the event fires — + // which is the ONLY thing that flips the b__4 predicate true + // and dismisses the save spinner. Pre-pushing here primes the + // cached tuple so the save's own push gets deduplicated → no + // event → spinner wedges forever. Let the save's push be the + // first one for any (room, subRoom). + } + + /// Mutate the response so it represents a fresh invite-only + /// match: forks the PhotonRoomId with a per-(player, nonce) suffix + /// so randos can't matchmake into it, sets isPrivate=true, and + /// registers the instance in + /// so invitees coming in via /goto/instance/{id} can find this + /// exact Photon match. + private async Task ApplyPrivateInstanceAsync(MatchmakingResponseDto resp, long subRoomId) + { + if (this.CurrentPlayerId() is not long ownerId) + { + // Anonymous private-instance creation makes no sense — the + // owner is the keying signal for the photon room name and + // for invite resolution. Fall through with isPrivate flag + // only; the watch won't be able to invite anyone but at + // least won't mismatch the requested form value. + resp.RoomInstance.IsPrivate = true; + return; + } + + var inst = await privateInstances.RegisterAsync( + roomId: resp.RoomInstance.RoomId, + subRoomId: subRoomId, + ownerPlayerId: ownerId, + baseName: resp.RoomInstance.Name.ToLowerInvariant(), + location: resp.RoomInstance.Location, + dataBlob: resp.RoomInstance.DataBlob, + photonRegion: resp.RoomInstance.PhotonRegionId, + maxCapacity: resp.RoomInstance.MaxCapacity); + + resp.RoomInstance.RoomInstanceId = inst.InstanceId; + resp.RoomInstance.PhotonRoomId = inst.PhotonRoomId; + resp.RoomInstance.IsPrivate = true; + logger.LogInformation( + "[goto-private] room={Room} sub={Sub} owner={Owner} → instId={Id} photon={Photon}", + resp.RoomInstance.RoomId, subRoomId, ownerId, inst.InstanceId, inst.PhotonRoomId); + } + + /// Upsert the (room, player) + /// row for this join. New rows bump + /// (unique-visitor metric); + /// every join — new or repeat — bumps + /// + the per-player counter. + private async Task RecordVisitAsync(long playerId, long roomId) + { + var visit = await db.RoomVisits + .FirstOrDefaultAsync(v => v.RoomId == roomId && v.PlayerId == playerId); + var isFirst = visit is null; + if (isFirst) + { + visit = new DorkNet.Server.Data.Entities.RoomVisitEntity + { + RoomId = roomId, + PlayerId = playerId, + FirstVisitAt = DateTime.UtcNow, + }; + db.RoomVisits.Add(visit); + } + visit!.LastVisitAt = DateTime.UtcNow; + visit.VisitCount += 1; + + // Counter bumps go through ExecuteUpdate to avoid re-reading + // the room — atomic increment under concurrent /goto calls. + // Also bumps HotScore so the "Hot" tab self-reorders as + // players actually use rooms. Weight: each first-time visit + // adds 2.0, each repeat adds 0.25 (revisits matter less than + // new players). Cheers also feed in via PlayerStateController + // / MissingEndpointsController by the same factor. + if (isFirst) + { + await db.Rooms + .Where(r => r.Id == roomId) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.VisitCount, r => r.VisitCount + 1) + .SetProperty(r => r.VisitorCount, r => r.VisitorCount + 1) + .SetProperty(r => r.HotScore, r => r.HotScore + 2.0)); + } + else + { + await db.Rooms + .Where(r => r.Id == roomId) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.VisitCount, r => r.VisitCount + 1) + .SetProperty(r => r.HotScore, r => r.HotScore + 0.25)); + } + await db.SaveChangesAsync(); + } + + private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room) + { + var friendIds = await db.Relationships + .Where(r => r.Status == DorkNet.Server.Data.Entities.RelationshipStatus.Friend && + (r.RequesterId == playerId || r.TargetId == playerId)) + .Select(r => r.RequesterId == playerId ? r.TargetId : r.RequesterId) + .ToListAsync(); + 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); + } + + private async Task BuildResponseAsync(string roomName) + { + // Try to resolve from the Rooms DB first — that gives us the real + // RoomId (so subsequent /api/rooms/v4/details/{id} hits the same + // entity) and the canonical LocationReplicationId. Fall back to the + // baked-in DormRoom mapping for unknown names. + long roomId; + string locationId; + if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase)) + { + // Each player has their own dorm row (created at signup, + // lazy-created here for legacy accounts that pre-date the + // per-player dorm work). Resolve to the caller's personal + // dorm so /api/rooms/v4/details/{id} hits a real Rooms.id + // and storage.rec.net/upload's room lookup succeeds. + if (this.CurrentPlayerId() is long callerPid) + { + var personal = await rooms.EnsurePersonalDormAsync(callerPid); + roomId = personal.Id; + locationId = personal.LocationReplicationId; + } + else + { + // Anonymous /goto/room/DormRoom shouldn't happen in the + // 2020 watch (the watch always authenticates first), but + // fall back to the canonical scene id for safety. + roomId = 1; + locationId = "76d98498-60a1-430c-ab76-b54a29b7a163"; + } + } + else + { + var room = await rooms.GetByNameAsync(roomName); + if (room is not null) + { + roomId = room.Id; + locationId = room.LocationReplicationId; + } + else + { + roomId = MapRoomNameToId(roomName); + locationId = MapRoomNameToReplicationId(roomName); + } + } + var photonRegion = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant(); + + // DormRoom is per-player (each account has its own dorm); Rec Center + // and other public rooms are shared by name. Including the player's + // id in the dorm room name avoids the case where rebuilding the DB + // gives a fresh accountId but Photon still considers the old + // "recroom_dormroom_1" room locked from the previous account's + // session — manifested as a successful /goto but no actual scene + // load (black screen). + var isDorm = roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase); + var dormOwnerId = this.CurrentPlayerId(); + var photonRoom = (isDorm && dormOwnerId is long ownerForName) + ? $"^dormroom_p{ownerForName}" + : $"^{roomName.ToLowerInvariant()}_{roomId}"; + var dataBlob = await ResolveInitialDataBlobAsync(roomId, isDorm, dormOwnerId); + + var resp = new MatchmakingResponseDto + { + ErrorCode = 0, + RoomInstance = new RoomInstanceDto + { + // Deterministic: same name → same instance id, every time. + RoomInstanceId = roomId * 100_000L, + RoomId = roomId, + SubRoomId = 0, + // location is a STRING replicationId, parsed via + // RoomSceneLocations.TryGetRoomSceneLocationById (RVA 0x10F57D0). + // The value is matched against AGRoomRuntimeConfig.Room.ReplicationId + // entries baked into resources.assets. Sending an int (or wrong + // string) makes RoomInstance.Deserialize log "RecNet game session + // contains unknown scene location ID" and the scene never loads. + Location = locationId, + // photonRegionId is a STRING that maps to CloudRegionCode enum + // (eu, us, asia, jp, none, au, usw, sa, cae, kr, in, ru, rue). + // Lowercase enum names — that's the wire format Photon uses too. + PhotonRegionId = photonRegion, + PhotonRoomId = photonRoom, + Name = roomName, + MaxCapacity = 8, + IsFull = false, + IsPrivate = false, + IsInProgress = true, + DataBlob = dataBlob, + }, + }; + logger.LogInformation( + "[goto] room instance room={RoomId} name={RoomName} player={PlayerId} dataBlob={DataBlob}", + roomId, roomName, dormOwnerId, dataBlob); + + // Dorms are always private — the per-player photon-room name + // (^dormroom_p{owner}) prevents matchmaking, but without + // IsPrivate=true the /goto/player path mirrors the room + // verbatim and lets uninvited friends drop straight in. Stamp + // the response with a deterministic-id PrivateInstance owned + // by the dorm's owner so CanJoinAsync gates non-owners. + if (isDorm && dormOwnerId is long ownerPid) + { + var dorm = await privateInstances.EnsureForDormAsync( + ownerPlayerId: ownerPid, + roomId: resp.RoomInstance.RoomId, + subRoomId: 0, + baseName: resp.RoomInstance.Name, + location: resp.RoomInstance.Location, + dataBlob: resp.RoomInstance.DataBlob, + photonRegion: resp.RoomInstance.PhotonRegionId, + maxCapacity: resp.RoomInstance.MaxCapacity, + photonRoomId: resp.RoomInstance.PhotonRoomId); + resp.RoomInstance.RoomInstanceId = dorm.InstanceId; + resp.RoomInstance.IsPrivate = true; + } + + return resp; + } + + private async Task ResolveInitialDataBlobAsync(long roomId, bool isDorm, long? dormOwnerId) + { + var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) return string.Empty; + + if (isDorm && dormOwnerId is long ownerId) + { + var dormBlob = await db.DormStates.AsNoTracking() + .Where(d => d.PlayerId == ownerId) + .Select(d => d.CurrentDataBlobName) + .FirstOrDefaultAsync(); + if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob; + } + + if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; + + var customisable = room.IsDormRoom || room.CreatorPlayerId != 1; + return customisable ? $"room_{room.Id}_v1.dat" : string.Empty; + } + + /// + /// Stable id-per-room-name map for well-known rooms; unknown names get + /// a hash-based id so each room name routes to its own roomId space. + /// + private static long MapRoomNameToId(string name) => name.ToLowerInvariant() switch + { + "dormroom" => 1, + "rec_center" or "reccenter" => 2, + "lobby" => 3, + _ => Math.Abs(name.GetHashCode()) % 100_000 + 1000, + }; + + /// + /// Maps a friendly room name to the RoomScene.RoomSceneLocationId GUID + /// baked into resources.assets's AGRoomRuntimeConfig.Rooms[].Scenes[0]. + /// This is the foreign key that links a Room's "Home" scene to an + /// AGRoomRuntimeConfig.Locations entry — and is what + /// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0) matches + /// against Location.ReplicationId. + /// + /// The asset stores each Room as [Name] [Room.ReplicationId] + /// [Description] [Scene.Name="Home"] [Scene.ReplicationId] + /// [Scene.RoomSceneLocationId] — so the THIRD GUID after the room name + /// is the one we want. Earlier I sent the FIRST (Room.ReplicationId) + /// which is a different namespace entirely; the deserializer logged + /// "unknown scene location ID" because no Location had that GUID. + /// + private static string MapRoomNameToReplicationId(string name) => name.ToLowerInvariant() switch + { + "dormroom" => "76d98498-60a1-430c-ab76-b54a29b7a163", + "rec_center" or "reccenter" => "cbad71af-0831-44d8-b8ef-69edafa841f6", + "3dcharades" or "charades" => "4078dfed-24bb-4db7-863f-578ba48d726b", + "discgolflake" or "lake" => "f6f7256c-e438-4299-b99e-d20bef8cf7e0", + "discgolfpropulsion" or "propulsion" => "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", + "dodgeball" => "3d474b26-26f7-45e9-9a36-9b02847d5e6f", + "paddleball" => "d89f74fa-d51e-477a-a425-025a891dd499", + "river" => "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + "homestead" => "a785267d-c579-42ea-be43-fec1992d1ca7", + "quarry" => "ff4c6427-7079-4f59-b22a-69b089420827", + "clearcut" => "380d18b5-de9c-49f3-80f7-f4a95c1de161", + "spillway" => "58763055-2dfb-4814-80b8-16fac5c85709", + "drive-in" or "drivein" => "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", + "soccer" => "7e01cfe0-820a-406f-b1b3-0a5bf575235c", + "lasertag" => "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", + "cyberjunkcity" or "lasertagcyberjunk" => "9d6456ce-6264-48b4-808d-2d96b3d91038", + "recroyalesquads" => "253fa009-6e65-4c90-91a1-7137a56a267f", + "recroyalesolos" => "85b43509-77f7-3884-3a6b-9a4e737d6d11", + _ => "76d98498-60a1-430c-ab76-b54a29b7a163", // DormRoom + }; +} + +public class MatchmakingResponseDto +{ + [JsonPropertyName("errorCode")] + public int ErrorCode { get; set; } + + [JsonPropertyName("roomInstance")] + public RoomInstanceDto? RoomInstance { get; set; } = new(); +} + +/// +/// Mirrors RecNet.RoomInstance — Deserialize at RVA 0x114DAB0. Keys +/// verified by disassembly. JSON casing is camelCase throughout. +/// +public class RoomInstanceDto +{ + [JsonPropertyName("roomInstanceId")] + public long RoomInstanceId { get; set; } + + [JsonPropertyName("roomId")] + public long RoomId { get; set; } + + [JsonPropertyName("subRoomId")] + public long SubRoomId { get; set; } + + // STRING RoomSceneLocationId GUID — foreign-keys to + // AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via + // AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default + // = DormRoom's home-scene location ID extracted from resources.assets + // at offset 0x65107ec. + [JsonPropertyName("location")] + public string Location { get; set; } = "76d98498-60a1-430c-ab76-b54a29b7a163"; + + // STRING enum-name — CloudRegionCode is parsed by name: eu, us, asia, + // jp, none, au, usw, sa, cae, kr, in, ru, rue. Lowercase. + [JsonPropertyName("photonRegionId")] + public string PhotonRegionId { get; set; } = "us"; + + [JsonPropertyName("photonRoomId")] + public string PhotonRoomId { get; set; } = string.Empty; + + [JsonPropertyName("maxCapacity")] + public int MaxCapacity { get; set; } = 8; + + [JsonPropertyName("isFull")] + public bool IsFull { get; set; } = false; + + [JsonPropertyName("isPrivate")] + public bool IsPrivate { get; set; } = false; + + [JsonPropertyName("isInProgress")] + public bool IsInProgress { get; set; } = true; + + // Optional keys per disassembly (Util.GetKeyOrDefault). + [JsonPropertyName("dataBlob")] + public string DataBlob { get; set; } = string.Empty; + + [JsonPropertyName("eventId")] + public long EventId { get; set; } = 0; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Returns a copy of with photonRoomId + /// and dataBlob blanked out — safe to send to OTHER players + /// (friend-list presence, push updates). The watch only needs + /// roomId / name / isPrivate to render the + /// friend-list row; it joins via /goto/player/{id} or + /// /goto/instance/{id}, both of which enforce + /// . + /// + /// Without this redaction, anyone could read another player's + /// presence and use the leaked photonRoomId to JoinByName + /// in Photon directly, bypassing our invite-list check. + /// + public static RoomInstanceDto? Redact(RoomInstanceDto? room) + { + if (room is null) return null; + return new RoomInstanceDto + { + RoomInstanceId = room.RoomInstanceId, + RoomId = room.RoomId, + SubRoomId = room.SubRoomId, + Location = room.Location, + PhotonRegionId = room.PhotonRegionId, + PhotonRoomId = string.Empty, + MaxCapacity = room.MaxCapacity, + IsFull = room.IsFull, + IsPrivate = room.IsPrivate, + IsInProgress = room.IsInProgress, + DataBlob = string.Empty, + EventId = room.EventId, + Name = room.Name, + }; + } +} diff --git a/DorkNet.Server/Controllers/Match/MatchController.cs b/DorkNet.Server/Controllers/Match/MatchController.cs new file mode 100644 index 0000000..ed72eb4 --- /dev/null +++ b/DorkNet.Server/Controllers/Match/MatchController.cs @@ -0,0 +1,275 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Models.GameSessions; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Match; + +/// +/// match.rec.net — dedicated matchmaking service used by later 2020 builds. +/// Delegates to the Postgres-backed GameSessionService. +/// +[ApiController] +[Authorize] +public class MatchController( + GameSessionService sessionService, + PlayerPresenceService presence, + PrivateInstanceService privateInstances, + DorkNetDbContext db, + ILogger logger) : ControllerBase +{ + /// POST match.*/roominstance/{id}/reportjoinresult — + /// the watch fires this AFTER its Photon connect+join attempt, + /// reporting how it went via RecNet.Matchmaking+GameJoinResult + /// (Matchmaking.cs:121): + /// 0 = Success, 1 = FailedToConnectToRegion, + /// 2 = FailedToConnectToRoom, 3 = FailedToSpawnPlayer. + /// Body is form-urlencoded result=N. Watch ignores the + /// response body and just needs 2xx (Matchmaking.txt:6668-6699), so + /// we mostly use this for visibility into Photon-side issues — + /// without it, all we see in the catch-all is an opaque "200, empty + /// reply" and no clue why a player crashed back to dorm. + [HttpPost("/roominstance/{instanceId:long}/reportjoinresult")] + public IActionResult ReportJoinResult(long instanceId, [FromForm(Name = "result")] int? result) + { + var code = result ?? -1; + var label = code switch + { + 0 => "Success", + 1 => "FailedToConnectToRegion", + 2 => "FailedToConnectToRoom", + 3 => "FailedToSpawnPlayer", + _ => $"Unknown({code})", + }; + var pid = this.RequireCurrentPlayerId(); + if (code == 0) + { + logger.LogInformation( + "[joinresult] player={Player} instance={Instance} OK ({Label})", + pid, instanceId, label); + } + else + { + logger.LogWarning( + "[joinresult] player={Player} instance={Instance} FAILED code={Code} ({Label}) — Photon-side error, check Photon AppId/region/auth", + pid, instanceId, code, label); + } + return Ok(); + } + + + /// PUT /roominstance/{id}/inprogress — fired by + /// RecNet.Matchmaking.SetGameInProgress(bool inProgress) + /// (Matchmaking.cs:333). Body is form-urlencoded + /// inProgress=true|false. The watch uses this to mark a + /// match as "in progress" so newcomers are sent to a fresh instance + /// instead of joining a game already underway (e.g. paintball + /// during a round). Our matchmaker doesn't gate on this yet — log + /// + 200 to take it off the catch-all. + [HttpPut("/roominstance/{instanceId:long}/inprogress")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public IActionResult SetInProgress( + long instanceId, + [FromForm(Name = "inProgress")] bool? inProgress) + { + var pid = this.RequireCurrentPlayerId(); + logger.LogInformation( + "[set-inprogress] player={Player} instance={Instance} inProgress={Flag}", + pid, instanceId, inProgress); + return Ok(); + } + + [HttpPost("/v1/find")] + public async Task> Find([FromBody] JoinRandomGameSessionRequest req) + { + var session = await sessionService.JoinOrCreateAsync(req.RoomId, req.ActivityLevelId, req.RegionId); + return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Success, GameSession = session }); + } + + [HttpPost("/v1/join/{sessionId:long}")] + public async Task> Join(long sessionId) + { + var session = await sessionService.GetByIdAsync(sessionId); + if (session is null) return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.NotFound }); + if (session.IsFull) return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Full }); + return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Success, GameSession = session }); + } + + [HttpPost("/v1/leave/{sessionId:long}")] + public async Task Leave(long sessionId) + { + await sessionService.PlayerLeftAsync(sessionId); + return Ok(); + } + + [HttpGet("/v1/session/{sessionId:long}")] + public async Task> GetSession(long sessionId) + { + var session = await sessionService.GetByIdAsync(sessionId); + if (session is null) return NotFound(); + return Ok(session); + } + + /// GET match.*/room/{id}/instances — the + /// "RoomInstanceBrowser" the watch shows when you tap a room tile. + /// Returns the currently-active instances so the player can see + /// which one their friends are in (and join it) instead of being + /// matchmade into a fresh empty room. Verified URL at + /// Matchmaking.txt:6286; response is a + /// List<SimpleRoomInstance> per + /// SimpleRoomInstance.cs: RoomInstanceId/RoomId/SubRoomId, + /// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount, + /// HasModPresent, HashedInstanceId. + /// + /// We derive instances from PlayerPresenceService's per-player + /// RoomInstanceDto: every distinct PhotonRoomId in the same RoomId + /// is one instance, with the players currently presenced there. + /// PrivateInstances are surfaced as well so the inviter and + /// invitee see the same room in the browser. + [HttpGet("/room/{roomId:long}/instances")] + [AllowAnonymous] + public async Task RoomInstances(long roomId) + { + var presenced = presence.EnumerateActiveInstances(roomId).ToList(); + // Only surface the caller's OWN private instances — we never + // leak someone else's PhotonRoomId into the browser. The Photon + // join check still requires CanJoinAsync, but redacting at the + // list level matches the existing RoomInstanceDto.Redact policy. + var pid = this.CurrentPlayerId() ?? 0; + var privates = pid == 0 + ? new List() + : await db.PrivateInstances + .Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid) + .ToListAsync(); + + // Merge: presence rows are authoritative (player currently + // there); add any owned private instance the inviter created + // but nobody's joined yet so the inviter can still see it. + var byKey = new Dictionary(StringComparer.Ordinal); + foreach (var (room, playerIds) in presenced) + { + if (string.IsNullOrEmpty(room.PhotonRoomId)) continue; + byKey[room.PhotonRoomId] = new SimpleInstance( + RoomInstanceId: room.RoomInstanceId, + RoomId: room.RoomId, + SubRoomId: room.SubRoomId, + IsFull: false, + CreatedAt: DateTime.UtcNow, + PlayerIds: playerIds.Select(p => (int)p).ToList(), + SubroomName: room.Name ?? string.Empty, + PlayerCount: playerIds.Count, + HasModPresent: false, + HashedInstanceId: room.PhotonRoomId); + } + foreach (var p in privates) + { + if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue; + byKey[p.PhotonRoomId] = new SimpleInstance( + RoomInstanceId: p.Id, + RoomId: p.RoomId, + SubRoomId: p.SubRoomId, + IsFull: false, + CreatedAt: DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc), + PlayerIds: new List(), + SubroomName: p.Name, + PlayerCount: 0, + HasModPresent: false, + HashedInstanceId: p.PhotonRoomId); + } + + // Wire shape: SimpleRoomInstance.Deserialize reads CAMELCASE + // keys — verified at Cpp2IL_ISIL/.../RecNet/SimpleRoomInstance.txt + // lines 765-808 ({roomInstanceId, roomId, subRoomId, isFull, + // createdAt, playerIds}). SubroomName / PlayerCount / + // HasModPresent / HashedInstanceId are populated locally on the + // watch (PlayerIds.Count, local hash, etc.) — not read from + // JSON, so we don't send them. Earlier PascalCase response made + // every field default to 0/null on the watch, breaking the + // browser. + return Ok(byKey.Values.Select(i => new + { + roomInstanceId = i.RoomInstanceId, + roomId = i.RoomId, + subRoomId = i.SubRoomId, + isFull = i.IsFull, + createdAt = i.CreatedAt, + playerIds = i.PlayerIds.ToArray(), + })); + } + + private sealed record SimpleInstance( + long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull, + DateTime CreatedAt, List PlayerIds, string SubroomName, + int PlayerCount, bool HasModPresent, string HashedInstanceId); + + /// DELETE match.*/invite/{id} — Matchmaking.cs + /// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method + /// enum 4 = DELETE). The id is a MessageEntity row id (V2 game and + /// party invites are stored as Type 6/7 messages). Mark it read so the inviter's + /// inbox stops nagging, and remove the invitee from the private + /// instance's allow-list so they can't change their mind without + /// a fresh invite. + [HttpDelete("/invite/{messageId:long}")] + public async Task DeclineInvite(long messageId) + { + var pid = this.RequireCurrentPlayerId(); + var msg = await db.Messages + .FirstOrDefaultAsync(m => m.Id == messageId && m.RecipientPlayerId == pid); + if (msg is null) return NotFound(); + + if (msg.ReadAt is null) + { + msg.ReadAt = DateTime.UtcNow; + } + + // Current invite messages carry the bare roomInstanceId in + // Data/Body. Older local DBs may still have the legacy + // "Invite to private match (instance N)" text, so accept both. + if (TryParseRoomInstanceId(msg.Body, out var instId)) + { + await privateInstances.RevokeInviteAsync(instId, pid); + } + + await db.SaveChangesAsync(); + logger.LogInformation("[invite] declined messageId={Id} by player={Player}", messageId, pid); + return Ok(); + } + + // Same three-shape parser as GoToController.TryParseRoomInstanceId + // (see that file for the rationale): JSON for current Type-6/7 + // invites, bare int for the pre-fix shape, "Invite to private match + // (instance N)" text for ancient rows. + private static bool TryParseRoomInstanceId(string body, out long instanceId) + { + instanceId = 0; + if (string.IsNullOrWhiteSpace(body)) return false; + if (body.TrimStart().StartsWith('{')) + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("roomInstanceId", out var v)) + { + // See GoToController.TryParseRoomInstanceId for the + // rationale on accepting both string and number forms. + if (v.ValueKind == System.Text.Json.JsonValueKind.String + && long.TryParse(v.GetString(), System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var strInst)) + { instanceId = strInst; return true; } + if (v.ValueKind == System.Text.Json.JsonValueKind.Number + && v.TryGetInt64(out var numInst)) + { instanceId = numInst; return true; } + } + } + catch { /* fall through to legacy formats */ } + } + if (long.TryParse(body, out instanceId)) return true; + var open = body.IndexOf("instance ", StringComparison.Ordinal); + if (open < 0) return false; + var tail = body[(open + "instance ".Length)..].TrimEnd(')', '.', ' '); + return long.TryParse(tail, out instanceId); + } +} diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs new file mode 100644 index 0000000..0b2e11c --- /dev/null +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -0,0 +1,374 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Services; +using System.Text.Json.Serialization; + +namespace DorkNet.Server.Controllers.Match; + +/// +/// match.rec.net/player/* — endpoints that operate on the local player's +/// matchmaking session. These were previously falling through to the +/// GlobalCatchAllController which returned the kitchen-sink "RecNetResult +/// shotgun" body. That body lacks the required keys for the actual response +/// types and the client crashes with KeyNotFoundException. +/// +/// The endpoints here use the same camelCase PlayerPresence shape as +/// PlayerPresenceController (verified at RVA 0x1458920). +/// +[ApiController] +public class MatchPlayerController( + PlayerPresenceService presence, + NotificationService notifications, + DorkNetDbContext db, + ILogger log) : ControllerBase +{ + /// + /// POST /player/login — the 2020 watch starts every session by + /// posting here with a fresh LoginLock GUID it generated client-side. + /// We persist the lock as the canonical "active session" marker for this + /// player so subsequent heartbeats can be validated against it; a NEW + /// login overwrites the lock and any older session's heartbeats start + /// failing, which is the intended kick-the-first-session behaviour. + /// + [HttpPost("/player/login")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock) + { + var playerId = TryGetCurrentPlayerId(); + if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock)) + { + var replaced = presence.SetLock(playerId, loginLock); + if (replaced) + { + log.LogWarning( + "[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push", + playerId); + // The new session hasn't opened its SignalR socket yet + // (negotiate fires AFTER /player/login on the watch's + // boot sequence — verified in the live trace), so the + // ModerationKick push hits only the OLDER session's + // existing connections. They'll show the "you've been + // kicked" dialog and gracefully disconnect via the + // watch's LocalModerationKickSelf path. + await notifications.KickStaleSession(playerId, + "Your account was signed in on another device."); + } + } + return Ok(Array.Empty()); + } + + /// + /// 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. + /// + [HttpPost("/player/logout")] + public IActionResult PlayerLogout() + { + var playerId = TryGetCurrentPlayerId(); + if (playerId != 0) presence.Clear(playerId); + return Ok(new { success = true, error = "" }); + } + + /// + /// POST /player/notifydisconnect — fired by + /// RecNet.Matchmaking.NotifyPlayerDisconnected(int otherPlayerId) + /// (Matchmaking.cs:326). Body is form-urlencoded + /// otherPlayerId=<id>. The watch uses this to tell the + /// server "I just saw this other Photon participant disappear" — + /// e.g. when someone leaves a room via the menu. Real Rec Room + /// likely used it to GC sticky presence rows; we just acknowledge + /// and log so it stops falling through to GlobalCatchAll. + /// + /// Returns 200 with no body (BestHTTP ignores the response per + /// Matchmaking.txt:6668-6699). + /// + [HttpPost("/player/notifydisconnect")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public IActionResult NotifyDisconnect([FromForm(Name = "otherPlayerId")] int? otherPlayerId) + { + var caller = TryGetCurrentPlayerId(); + log.LogInformation( + "[player-notify-disconnect] caller={Caller} reported other={Other} left", + caller, otherPlayerId); + return Ok(); + } + + /// + /// POST /player/heartbeat — every ~5–30s while connected. Client expects + /// a PlayerPresence response that mirrors its own cached state. + /// CRITICAL: must include the player's CURRENT roomInstance, not null. + /// Returning null while the client knows it's in DormRoom triggers + /// "presence heartbeat response indicates local presence is out-of-sync" + /// repeatedly → eventual "Error: -2" disconnect dialog. + /// + /// We pull the current room from PlayerPresenceService, which records + /// each successful /goto/room/* call. + /// + [HttpPost("/player/heartbeat")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task> Heartbeat([FromForm(Name = "LoginLock")] string? loginLock) + { + // We track the LoginLock per player in /player/login (still useful + // for admin diagnostics + future session-management) but + // intentionally DO NOT 401 here on stale locks. Returning 401 on + // a heartbeat causes the watch to yank the player out of the + // current Photon room mid-session — symptoms include + // "Cannot send messages when not connected" + "Head/LeftHand/ + // RightHand being destroyed" + "presence heartbeat response + // indicates local presence is out-of-sync" — which is worse than + // letting two sessions co-exist briefly. If we want true + // single-session enforcement we'll do it via SignalR push to the + // older session and a graceful client-side disconnect, not via + // an HTTP 401 mid-heartbeat. + var playerId = TryGetCurrentPlayerId(); + var room = await GetPresenceRoomForResponseAsync(playerId); + return Ok(new PresenceDto + { + PlayerId = playerId, + StatusVisibility = 1, + DeviceClass = 0, + VrMovementMode = 0, + RoomInstance = room, + }); + } + + [HttpPut("/player/statusvisibility")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value) + { + var playerId = TryGetCurrentPlayerId(); + var room = await GetPresenceRoomForResponseAsync(playerId); + return Ok(new PresenceDto + { + PlayerId = playerId, + StatusVisibility = value ?? 1, + DeviceClass = 0, + VrMovementMode = 0, + RoomInstance = room, + }); + } + + [HttpPut("/player/vrmovementmode")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public async Task> SetVrMovementMode([FromForm(Name = "vrMovementMode")] int? value) + { + var playerId = TryGetCurrentPlayerId(); + var room = await GetPresenceRoomForResponseAsync(playerId); + return Ok(new PresenceDto + { + PlayerId = playerId, + StatusVisibility = 1, + DeviceClass = 0, + VrMovementMode = value ?? 0, + RoomInstance = room, + }); + } + + /// + /// GET /player/avoidjuniors — returns a primitive boolean. + /// Verified against + /// Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:7728 (URL) and 7737 + /// (PromiseExtensions.ExpectPrimitiveResponse) — the watch + /// expects a bare JSON true/false, not an array or + /// object. Returning [] from the catch-all triggered + /// "Failed to get avoid juniors status: Malformed Response" + a + /// Convert.ChangeType InvalidCastException, which left the + /// matchmaking init in a never-completes state on some boot flows. + /// + [HttpGet("/player/avoidjuniors")] + public ActionResult GetAvoidJuniors() => Ok(false); + + /// PUT /player/avoidjuniors — body + /// avoidJuniors=true|false (Matchmaking.txt:7883). Stub for + /// now; the multi-tenant server doesn't currently track this + /// preference per-player. Returns the new value as a JSON + /// primitive so the watch's ExpectPrimitiveResponse is happy. + [HttpPut("/player/avoidjuniors")] + [HttpPost("/player/avoidjuniors")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public ActionResult SetAvoidJuniors([FromForm(Name = "avoidJuniors")] bool? value) + => Ok(value ?? false); + + /// POST /player/photonregionpings — watch reports + /// its measured Photon region ping vector so the server can pick + /// the best region for this player's matchmaking. Body is form- + /// urlencoded: region=eu&ping=42&region=us&ping=80&... + /// (one (region, ping) pair per row, repeated). Persists the + /// freshest measurement per (player, region) pair in + /// 's in-memory map — the + /// matchmaking GoTo flow reads this to bias instance creation + /// toward the player's lowest-ping region. + /// Verified URL at Matchmaking.txt:7280. + [HttpPost("/player/photonregionpings")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] + public IActionResult PhotonRegionPings() + { + var playerId = TryGetCurrentPlayerId(); + if (playerId == 0) return Ok(); + + var form = Request.Form; + var regions = form["region"].ToArray(); + var pings = form["ping"].ToArray(); + var pairs = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < Math.Min(regions.Length, pings.Length); i++) + { + var r = regions[i]?.Trim(); + if (string.IsNullOrEmpty(r)) continue; + if (!int.TryParse(pings[i], out var p)) continue; + // Sanity bound — discard wild values from misbehaving clients. + pairs[r] = Math.Clamp(p, 0, 2000); + } + if (pairs.Count > 0) + presence.SetPhotonRegionPings(playerId, pairs); + log.LogInformation("[player-region-pings] player={Player} regions={Count}", playerId, pairs.Count); + return Ok(); + } + + // Heartbeat / logout are unauthenticated endpoints (the client hits them + // before any token is acquired in some boot flows), so we use the + // optional `CurrentPlayerId()` extension and treat 0 as "anonymous". + // Cast to int because the wire shape's PlayerId field is an int — fits + // every accountId we mint (random in [100_000, 9_999_999)). + private int TryGetCurrentPlayerId() => + this.CurrentPlayerId() is long id ? (int)id : 0; + + private async Task GetPresenceRoomForResponseAsync(int playerId) + { + if (playerId == 0) return null; + + var room = presence.GetRoom(playerId); + if (room is null) + { + // Boot-time race: the watch hits /player/heartbeat before + // /goto/room/DormRoom has finished recording presence. The + // watch's OnPresenceHeartbeatResponse compares + // (cachedRoomInstance != null) XOR (serverRoomInstance != null) + // (Cpp2IL_ISIL/.../RecNet/Matchmaking.txt:8584) — if its local + // cache already has a dorm but we hand back null, it fires + // "RecNet presence heartbeat response indicates local presence + // is out-of-sync!" and tears down the local player (the + // RigidbodyEx Head/Hand "Cannot set the parent" warnings at + // [line 585] are the destruction shrapnel). Synthesise a + // default dorm RoomInstance instead so the heartbeat never + // returns null for an authenticated player. Matches what + // /goto/room/DormRoom builds — same RoomId, same + // PhotonRoomId pattern, same location GUID — so the watch's + // RoomInstancesAreDifferent finds them identical. + var fallback = await BuildDefaultDormPresenceAsync(playerId); + if (fallback is not null) + { + log.LogInformation( + "[player-heartbeat] synthesised dorm fallback player={PlayerId} room={RoomId} dataBlob={DataBlob} (presence cache miss)", + playerId, fallback.RoomId, fallback.DataBlob); + presence.SetRoom(playerId, fallback); + return fallback; + } + log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId); + return null; + } + + if (string.IsNullOrWhiteSpace(room.DataBlob)) + { + var resolved = await ResolveDataBlobForPresenceAsync(room.RoomId, playerId); + if (!string.IsNullOrWhiteSpace(resolved)) + { + log.LogWarning( + "[player-heartbeat] repairing empty presence dataBlob player={PlayerId} room={RoomId} instance={InstanceId} resolved={DataBlob}", + playerId, room.RoomId, room.RoomInstanceId, resolved); + room.DataBlob = resolved; + presence.SetRoom(playerId, room); + } + } + + log.LogInformation( + "[player-heartbeat] player={PlayerId} room={RoomId} instance={InstanceId} subRoom={SubRoomId} location={Location} photon={PhotonRoomId} dataBlob={DataBlob}", + playerId, room.RoomId, room.RoomInstanceId, room.SubRoomId, room.Location, room.PhotonRoomId, room.DataBlob); + return room; + } + + /// Build a RoomInstance that matches the shape + /// /goto/room/DormRoom would have produced for this player + /// — used as a heartbeat fallback when presence is empty (boot + /// race, replica restart, Redis TTL expiry). Same RoomInstanceId + /// math, same PhotonRoomId pattern, same location GUID; the + /// watch's RoomInstancesAreDifferent finds the fallback identical + /// to its own cached state, so no out-of-sync. Returns null only + /// if we can't find the player's dorm row in the DB. + private async Task BuildDefaultDormPresenceAsync(int playerId) + { + var dormRow = await db.Rooms.AsNoTracking() + .Where(r => r.IsDormRoom && r.CreatorPlayerId == playerId) + .Select(r => new { r.Id, r.CurrentDataBlobName, r.LocationReplicationId }) + .FirstOrDefaultAsync(); + + // Fall back to the canonical dorm template row if the player + // doesn't yet have their own dorm record — same id space + // /goto uses. + long roomId = dormRow?.Id ?? 1; + string dataBlob = dormRow?.CurrentDataBlobName ?? string.Empty; + if (string.IsNullOrWhiteSpace(dataBlob)) + dataBlob = await ResolveDataBlobForPresenceAsync(roomId, playerId); + + return new RoomInstanceDto + { + RoomInstanceId = roomId * 100_000L, + RoomId = roomId, + SubRoomId = 0, + Location = dormRow?.LocationReplicationId ?? "76d98498-60a1-430c-ab76-b54a29b7a163", + PhotonRegionId = "us", + PhotonRoomId = $"^dormroom_p{playerId}", + Name = "DormRoom", + MaxCapacity = 8, + IsFull = false, + IsPrivate = true, + IsInProgress = true, + DataBlob = dataBlob ?? string.Empty, + EventId = 0, + }; + } + + private async Task ResolveDataBlobForPresenceAsync(long roomId, int playerId) + { + var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) return string.Empty; + + if (room.IsDormRoom) + { + var dormBlob = await db.DormStates.AsNoTracking() + .Where(d => d.PlayerId == playerId) + .Select(d => d.CurrentDataBlobName) + .FirstOrDefaultAsync(); + if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob; + } + + if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; + + var customisable = room.IsDormRoom || room.CreatorPlayerId != 1; + return customisable ? $"room_{room.Id}_v1.dat" : string.Empty; + } +} + +/// +/// Wire shape of RecNet.PlayerPresence (RVA 0x1458920). Lives here as a +/// local DTO; same fields as PlayerPresenceController.PlayerPresenceDto. +/// +public class PresenceDto +{ + [JsonPropertyName("playerId")] + public int PlayerId { get; set; } + + [JsonPropertyName("statusVisibility")] + public int StatusVisibility { get; set; } + + [JsonPropertyName("deviceClass")] + public int DeviceClass { get; set; } + + [JsonPropertyName("vrMovementMode")] + public int VrMovementMode { get; set; } + + [JsonPropertyName("roomInstance")] + public object? RoomInstance { get; set; } = null; +} diff --git a/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs b/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs new file mode 100644 index 0000000..d009b06 --- /dev/null +++ b/DorkNet.Server/Controllers/Match/PlayerPresenceController.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Mvc; +using System.Text.Json.Serialization; +using DorkNet.Server.Auth; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Match; + +/// +/// match.rec.net/player — bulk player-presence query used by +/// RecNet.Matchmaking.RefreshCachedPlayerPresences. +/// +/// URL pattern verified against +/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Matchmaking.txt +/// lines 3565-3568: +/// String.Join("&", ids) → String.Concat("player?", joined). +/// So the request URL is literally player?{id1}&{id2}&{id3}... +/// — every query-string KEY is one account id, with no value. The previous +/// [FromQuery(Name="id")] int[] ids binding only matched +/// ?id=X&id=Y and never picked up any of the watch's bare-id +/// requests, which is why every other player rendered as offline: with no +/// ids parsed, the response was [] and IsOnline (which checks +/// roomInstance != null) defaulted to false. +/// +/// Response shape: List<PlayerPresence> — keys verified by +/// disassembling RecNet.PlayerPresence.Deserialize: +/// "playerId" (int, required) +/// "statusVisibility" (int, required) +/// "deviceClass" (int, required) +/// "vrMovementMode" (only read when deviceClass == 1) +/// "roomInstance" (object, optional) +/// +/// PlayerPresence.IsOnline returns true iff +/// roomInstance is non-null — so we surface the live RoomInstance +/// from rather than always sending +/// null. Players who haven't /goto'd anywhere still report offline. +/// +[ApiController] +public class PlayerPresenceController(PlayerPresenceService presence) : ControllerBase +{ + [HttpGet("/player")] + public ActionResult> GetPresences() + { + // The watch sends two URL flavours for this endpoint: + // 1. Bulk: /player?{id1}&{id2}&{id3} — bare keys, + // from RefreshCachedPlayerPresences (Matchmaking.txt:3565). + // 2. Single: /player?id={N} — observed in the live trace + // from a different code path (probably the per-player presence + // probe). Both must work; an empty response makes + // PUNNetworkManager skip Photon region setup, which then + // crashes PingPhotonRegionsInternal with + // "An item with the same key has already been added. Key: none" + // and leaves the player on a black screen post-dorm-load. + // + // We accept ANY query parameter whose value parses as a long, plus + // any bare key that does. Same dedup → same DB lookup → same + // response shape regardless. + var ids = new HashSet(); + foreach (var (key, values) in Request.Query) + { + if (long.TryParse(key, out var fromKey) && fromKey > 0) + ids.Add(fromKey); + foreach (var v in values) + { + if (long.TryParse(v, out var fromValue) && fromValue > 0) + ids.Add(fromValue); + } + } + + // Caller's own id gets the FULL RoomInstance (photonRoomId + + // dataBlob) so the watch's cached PlayerPresence for itself + // matches what /player/heartbeat returns. The watch calls + // /player/?id=&id=&... at boot via + // RefreshCachedPlayerPresences and then caches each entry; if + // self's entry is redacted (photonRoomId="" / dataBlob="") but + // heartbeat later returns the full values, RoomInstance. + // RoomInstancesAreDifferent fires → "presence heartbeat + // response indicates local presence is out-of-sync!" → + // OnPresenceHeartbeatResponse tears the player out of the + // current room and re-joins, killing any in-flight save flow + // mid-coroutine. Only redact for OTHER players where the + // privacy concern actually applies. + var callerId = this.CurrentPlayerId(); + var result = ids.Select(id => + { + var room = presence.GetRoom(id); + return new PlayerPresenceDto + { + PlayerId = (int)id, + StatusVisibility = 1, // Visible + DeviceClass = 0, // Desktop / unknown — not VR + VrMovementMode = 0, + RoomInstance = id == callerId ? room : RoomInstanceDto.Redact(room), + }; + }).ToList(); + + return Ok(result); + } +} + +public class PlayerPresenceDto +{ + [JsonPropertyName("playerId")] + public int PlayerId { get; set; } + + /// PlayerStatusVisibility enum (int): + /// 0 = Hidden / Offline-appearing, 1 = Visible, 2 = FriendsOnly. + [JsonPropertyName("statusVisibility")] + public int StatusVisibility { get; set; } + + /// RecNet.DeviceClass enum (int). + [JsonPropertyName("deviceClass")] + public int DeviceClass { get; set; } + + /// VRMovementMode enum (int) — only consulted when + /// deviceClass == 1. + [JsonPropertyName("vrMovementMode")] + public int VrMovementMode { get; set; } + + /// Read by Util.GetObjectKey<RoomInstance> — must + /// be a nested object (or null), NOT a string. Empty string "" + /// triggers + /// InvalidCastException: Unable to cast object of type 'String' + /// to type 'Dictionary'2'. null means "player not in + /// any room" → IsOnline = false. + [JsonPropertyName("roomInstance")] + public object? RoomInstance { get; set; } = null; +} diff --git a/DorkNet.Server/Controllers/Notify/NotifyController.cs b/DorkNet.Server/Controllers/Notify/NotifyController.cs new file mode 100644 index 0000000..4d6c81f --- /dev/null +++ b/DorkNet.Server/Controllers/Notify/NotifyController.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DorkNet.Server.Controllers.Notify; + +/// +/// notify.rec.net — legacy raw-WebSocket entrypoint that the 2020 client +/// briefly used before switching to the SignalR hub at +/// notify.rec.net/hub/v1. Kept as a 410 Gone so any straggling +/// boot path that hits it gets a clear "use the hub" signal instead of +/// a silent connection upgrade. +/// +[ApiController] +public class NotifyController : ControllerBase +{ + [HttpGet("/notify/v1")] + public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new + { + error = "use_signalr_hub", + hint = "connect to /hub/v1 instead", + }); +} diff --git a/DorkNet.Server/Controllers/Ns/NsController.cs b/DorkNet.Server/Controllers/Ns/NsController.cs new file mode 100644 index 0000000..2712a54 --- /dev/null +++ b/DorkNet.Server/Controllers/Ns/NsController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Ns; + +#pragma warning disable IDE0290 + +/// +/// ns.{apex} — RecNet name server. The very first endpoint the client +/// queries on boot. Returns the service URL map so the client knows +/// where every other subdomain lives. Also serves the bare apex +/// GET / (service catalog) and /v1/ping for the +/// keep-alive loop. Apex is read from +/// (driven by DORKNET_DOMAIN) so every URL the client sees +/// flows through one source of truth. +/// +[ApiController] +public class NsController : ControllerBase +{ + private readonly PlayerService _playerService; + private readonly DomainConfig _domain; + public NsController(PlayerService playerService, DomainConfig domain) + { + _playerService = playerService; + _domain = domain; + } + + // Root: service URL discovery (primary boot call from RecNet.Core.ConnectToRecNet) + // The game's TrySetServiceUri parses this as a flat { "ServiceName": "https://..." } dictionary. + // Wrapping it breaks lookup — all URIs come back null which crashes EnsureEndsWith. + // + // Order is more-negative than CdnController.Serve's wildcard route + // (Order = -100) because the catch-all {*path} matches the + // empty path "/" too — without this, GET ns.{apex}/ falls into the + // CDN serve handler and the client gets binary back instead of the + // service-map JSON, triggering "Failed to connect to RecNet (error + // code: locker)" in BootSequence. + [HttpGet("/", Order = -1000)] + public ActionResult> Root() => + Ok(ConfigService.BuildServiceUrlMap(_domain.Apex)); + + // Some builds fetch /v1 or /v1/services directly + [HttpGet("/v1")] + [HttpGet("/v1/services")] + public ActionResult> Services() => + Ok(ConfigService.BuildServiceUrlMap(_domain.Apex)); + + // Ping / health used by the client keep-alive loop + [HttpGet("/v1/ping")] + [HttpGet("/ping")] + public ActionResult Ping() + => Ok(new { Status = "OK", Time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); + + // Player name → accountId lookup (used for friend invites etc.) + [HttpGet("/v1/player/{name}")] + public async Task PlayerLookup(string name) + { + var p = await _playerService.GetByUsernameAsync(name); + return Ok(p is null + ? new { Username = name, AccountId = 0L, Found = false } + : new { Username = p.Username, AccountId = p.Id, Found = true }); + } +} diff --git a/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs b/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs new file mode 100644 index 0000000..7af4c3a --- /dev/null +++ b/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using System.Text.Json.Serialization; + +namespace DorkNet.Server.Controllers.PlayerSettings; + +/// +/// playersettings.rec.net — dedicated player settings service. +/// +[ApiController] +[Authorize] +public class PlayerSettingsController(DorkNetDbContext db) : ControllerBase +{ + private long CurrentPlayerId => this.RequireCurrentPlayerId(); + + [HttpGet("/settings/v1/{accountId:long}")] + public async Task>> Get(long accountId) + { + // Settings are private — only the owner can read them. Returning + // 403 instead of leaking the dict. + if (accountId != CurrentPlayerId) return Forbid(); + + var settings = await db.PlayerSettings + .Where(s => s.PlayerId == accountId) + .ToDictionaryAsync(s => s.Key, s => s.Value); + return Ok(settings); + } + + [HttpPut("/settings/v1/{accountId:long}")] + public async Task Set(long accountId, [FromBody] Dictionary updates) + { + if (accountId != CurrentPlayerId) return Forbid(); + + foreach (var (key, value) in updates) + { + var existing = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == key); + if (existing is null) + db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = accountId, Key = key, Value = value }); + else + existing.Value = value; + } + + await db.SaveChangesAsync(); + return Ok(); + } + + [HttpDelete("/settings/v1/{accountId:long}/{key}")] + public async Task Delete(long accountId, string key) + { + if (accountId != CurrentPlayerId) return Forbid(); + + var setting = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == key); + if (setting is null) return NotFound(); + + db.PlayerSettings.Remove(setting); + await db.SaveChangesAsync(); + return Ok(); + } +} diff --git a/DorkNet.Server/Controllers/Rooms/RoomsController.cs b/DorkNet.Server/Controllers/Rooms/RoomsController.cs new file mode 100644 index 0000000..190ec32 --- /dev/null +++ b/DorkNet.Server/Controllers/Rooms/RoomsController.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Rooms; + +/// +/// rooms.{rec.net,localhost} — secondary room-listing host (the +/// primary surface is api.*/api/rooms/v2/* via +/// Controllers/API/Rooms/V2/RoomsController). Some older +/// 2020 client paths still hit this host for the room-tile lookup +/// during share-link resolution and friend-room-join probes. +/// Both controllers share so the data is +/// identical regardless of which host the watch chose. +/// +[ApiController] +public class RoomsController(RoomService rooms) : ControllerBase +{ + [HttpGet("/rooms/v3/{roomId:long}")] + public async Task GetById(long roomId) + { + var r = await rooms.GetByIdAsync(roomId); + return r is null ? NotFound() : Ok(RoomService.ToWireRoom(r)); + } + + [HttpGet("/rooms/v3/search")] + public async Task Search([FromQuery] string? query, [FromQuery] int take = 20) + { + var rows = await rooms.SearchAsync(query ?? string.Empty, Math.Clamp(take, 1, 100)); + return Ok(rows.Select(RoomService.ToWireRoom).ToList()); + } + + [HttpGet("/rooms/v3/hot")] + public async Task Hot([FromQuery] string? tag, [FromQuery] int take = 50) + => Ok((await rooms.HotAsync(tag, Math.Clamp(take, 1, 100))).Select(RoomService.ToWireRoom).ToList()); + + [HttpGet("/rooms/v3/new")] + public async Task New([FromQuery] int take = 50) + { + // No dedicated "new rooms" path on RoomService — order by + // CreatedAt is acceptable as a fallback since RoomEntity has + // both UpdatedAt + CreatedAt. + var rows = await rooms.HotAsync(null, Math.Clamp(take, 1, 100)); + return Ok(rows.Select(RoomService.ToWireRoom).ToList()); + } + + [HttpGet("/rooms/v3/{roomId:long}/stats")] + public async Task GetStats(long roomId) + { + var r = await rooms.GetByIdAsync(roomId); + if (r is null) return NotFound(); + return Ok(new + { + RoomId = r.Id, + VisitCount = r.VisitCount, + CheerCount = r.CheerCount, + FavoriteCount = r.FavoriteCount, + VisitorCount = r.VisitorCount, + }); + } +} diff --git a/DorkNet.Server/Controllers/RootController.cs b/DorkNet.Server/Controllers/RootController.cs new file mode 100644 index 0000000..22a4204 --- /dev/null +++ b/DorkNet.Server/Controllers/RootController.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers; + +[ApiController] +public class RootController(ConfigService configService) : ControllerBase +{ + // Legacy MOTD for pre-Dorm Room builds + [HttpGet("motd")] + public ActionResult GetMotd() + { + var cfg = configService.GetConfig($"{Request.Scheme}://{Request.Host}"); + return Ok(cfg.MessageOfTheDay); + } + + // Serve images from data/images/ + [HttpGet("img/{name}")] + public ActionResult GetImage(string name) + { + var safeFileName = Path.GetFileName(name); // prevent path traversal + var path = Path.Combine(AppContext.BaseDirectory, "data", "images", safeFileName); + + if (!System.IO.File.Exists(path)) + return NotFound(); + + var ext = Path.GetExtension(safeFileName).ToLowerInvariant(); + var contentType = ext == ".png" ? "image/png" : "image/jpeg"; + return PhysicalFile(path, contentType); + } +} diff --git a/DorkNet.Server/Controllers/Site/PublicSiteController.cs b/DorkNet.Server/Controllers/Site/PublicSiteController.cs new file mode 100644 index 0000000..03dfa3d --- /dev/null +++ b/DorkNet.Server/Controllers/Site/PublicSiteController.cs @@ -0,0 +1,246 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Site; + +/// +/// Apex-host API for the public-facing site at localhost / +/// rec.net. Everything here is anonymous-safe — the site reads +/// the photo feed, looks up players, and browses public rooms without +/// any login. Mirrors the wire shape the SPA expects (camelCase JSON +/// thanks to the default ASP.NET serializer; admin / game endpoints +/// override with PascalCase, but the public site has no legacy clients +/// to placate so we ship modern conventions). +/// +/// Lives at the apex so the SPA can same-origin fetch /api/site/v1 +/// from the bare domain without CORS gymnastics. Routing is path-only +/// (post [Host]-strip refactor) — every subdomain reaches every +/// controller and the right handler is picked by URL alone. +/// +[ApiController] +[AllowAnonymous] +[Route("api/site/v1")] +public class PublicSiteController( + DorkNetDbContext db, + PlayerService players, + DomainConfig domain) : ControllerBase +{ + // ── Players ────────────────────────────────────────────────────── + + /// GET /api/site/v1/players/search?q=…&take=20 — typeahead + /// account search by display name OR username. + [HttpGet("players/search")] + public async Task SearchPlayers([FromQuery] string? q, [FromQuery] int take = 20) + { + if (string.IsNullOrWhiteSpace(q)) return Ok(Array.Empty()); + var rows = await players.SearchAsync(q.Trim(), Math.Clamp(take, 1, 50)); + return Ok(rows.Select(PlayerCard).ToList()); + } + + /// GET /api/site/v1/players/{id} — public profile card. + [HttpGet("players/{id:long}")] + public async Task GetPlayer(long id) + { + var p = await db.Players + .Where(x => x.Id == id) + .Select(x => new + { + x.Id, x.Username, x.DisplayName, x.Bio, x.Level, x.XP, + x.IsAdmin, x.IsDeveloper, x.IsVerified, x.IsJunior, + x.CreatedAt, x.ProfileImageName, + }) + .FirstOrDefaultAsync(); + if (p is null) return NotFound(); + + var photoCount = await db.Photos + .CountAsync(ph => ph.UploaderPlayerId == id && ph.IsPublic && ph.DeletedAt == null); + + return Ok(new + { + id = p.Id, + username = p.Username, + displayName = p.DisplayName, + bio = p.Bio ?? string.Empty, + level = p.Level, + xp = p.XP, + isAdmin = p.IsAdmin, + isDeveloper = p.IsDeveloper, + isVerified = p.IsVerified, + isJunior = p.IsJunior, + createdAt = p.CreatedAt, + profileImageName = p.ProfileImageName, + photoCount, + }); + } + + /// GET /api/site/v1/players/{id}/photos — photos uploaded + /// by the player. Excludes private + soft-deleted. + [HttpGet("players/{id:long}/photos")] + public async Task GetPlayerPhotos(long id, + [FromQuery] int take = 24, [FromQuery] int skip = 0) + { + take = Math.Clamp(take, 1, 100); + skip = Math.Max(0, skip); + var rows = await db.Photos + .Where(p => p.UploaderPlayerId == id && p.IsPublic && p.DeletedAt == null) + .OrderByDescending(p => p.CreatedAt) + .Skip(skip).Take(take) + .ToListAsync(); + return Ok(await EnrichPhotosAsync(rows)); + } + + // ── Photos ─────────────────────────────────────────────────────── + + /// GET /api/site/v1/feed — global public feed, newest first. + [HttpGet("feed")] + public async Task Feed([FromQuery] int take = 24, [FromQuery] int skip = 0) + { + take = Math.Clamp(take, 1, 100); + skip = Math.Max(0, skip); + var rows = await db.Photos + .Where(p => p.IsPublic && p.DeletedAt == null) + .OrderByDescending(p => p.CreatedAt) + .Skip(skip).Take(take) + .ToListAsync(); + return Ok(await EnrichPhotosAsync(rows)); + } + + /// GET /api/site/v1/photos/{id} — single photo + metadata. + /// Bumps the ViewCount as a side effect (denormalised trending sort). + [HttpGet("photos/{id:long}")] + public async Task GetPhoto(long id) + { + var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id); + if (photo is null || !photo.IsPublic || photo.DeletedAt is not null) return NotFound(); + + photo.ViewCount += 1; + await db.SaveChangesAsync(); + + var enriched = await EnrichPhotosAsync(new[] { photo }); + return Ok(enriched.First()); + } + + // ── Rooms ──────────────────────────────────────────────────────── + + /// GET /api/site/v1/rooms/search?q=… — rooms matching name. + /// Mostly used by the public site's room directory. + [HttpGet("rooms/search")] + public async Task SearchRooms([FromQuery] string? q, [FromQuery] int take = 24) + { + var clamped = Math.Clamp(take, 1, 50); + var qq = (q ?? string.Empty).Trim(); + // Public rooms list: hide dorms (per-player private space), and + // hide rooms that have only ever been visited by their creator + // (VisitorCount < 2). This keeps "^dwadwad" style throwaway + // builds off the public-facing site without an explicit + // "publish" toggle. Admins can still see them via the Rooms + // admin page and delete them from there if they want. + var rows = await db.Rooms + .Where(r => !r.IsDormRoom && r.VisitorCount >= 2 && + (qq == "" || r.Name.Contains(qq) || r.Description.Contains(qq))) + .OrderByDescending(r => r.HotScore) + .Take(clamped) + .Select(r => new + { + id = r.Id, + name = r.Name, + description = r.Description, + creatorPlayerId = r.CreatorPlayerId, + isDormRoom = r.IsDormRoom, + isAGRoom = r.IsAGRoom, + visitCount = r.VisitCount, + visitorCount = r.VisitorCount, + cheerCount = r.CheerCount, + imageName = r.ImageName, + }) + .ToListAsync(); + return Ok(rows); + } + + // ── Site stats (home-page hero) ────────────────────────────────── + + /// GET /api/site/v1/stats — coarse counters for the + /// public site's marketing hero (player count, room count, photo + /// count). Anonymous-safe. + [HttpGet("stats")] + public async Task Stats() + { + var playerCount = await db.Players.CountAsync(); + var roomCount = await db.Rooms.CountAsync(r => !r.IsDormRoom); + var photoCount = await db.Photos.CountAsync(p => p.IsPublic && p.DeletedAt == null); + var inventionCount = await db.Inventions.CountAsync(i => !i.IsDeleted); + return Ok(new { playerCount, roomCount, photoCount, inventionCount }); + } + + // ── Helpers ────────────────────────────────────────────────────── + + /// Public-facing player card. Mirrors what the site needs + /// for the search results list / friend-list row: id, name, + /// profileImageName (the SPA stitches the img.* URL). + private static object PlayerCard(PlayerEntity p) => new + { + id = p.Id, + username = p.Username, + displayName = p.DisplayName, + level = p.Level, + isAdmin = p.IsAdmin, + isDeveloper = p.IsDeveloper, + isVerified = p.IsVerified, + isJunior = p.IsJunior, + profileImageName = p.ProfileImageName, + }; + + /// Materialise photo rows into wire DTOs with display names + /// + room names + an absolute image URL. The URL apex switches + /// based on the request host so a localhost visitor gets localhost + /// CDN links and a rec.net visitor gets rec.net. + private async Task> EnrichPhotosAsync(IEnumerable photos) + { + var list = photos.ToList(); + if (list.Count == 0) return new(); + + var uploaderIds = list.Select(p => p.UploaderPlayerId).Distinct().ToList(); + var roomIds = list.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList(); + + var uploaders = await db.Players + .Where(p => uploaderIds.Contains(p.Id)) + .Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName }) + .ToListAsync(); + var rooms = await db.Rooms + .Where(r => roomIds.Contains(r.Id)) + .Select(r => new { r.Id, r.Name }) + .ToListAsync(); + + var uMap = uploaders.ToDictionary(u => u.Id); + var rMap = rooms.ToDictionary(r => r.Id); + + var cdnHost = domain.Sub("cdn"); + + return list.Select(p => + { + uMap.TryGetValue(p.UploaderPlayerId, out var u); + rMap.TryGetValue(p.RoomId, out var r); + return (object)new + { + id = p.Id, + uploaderPlayerId = p.UploaderPlayerId, + uploaderUsername = u?.Username ?? string.Empty, + uploaderDisplayName = u?.DisplayName ?? $"Player_{p.UploaderPlayerId}", + uploaderProfileImageName = u?.ProfileImageName, + blobName = p.BlobName, + imageUrl = $"https://{cdnHost}/{p.BlobName}", + caption = p.Caption, + roomId = p.RoomId, + roomName = r?.Name ?? string.Empty, + isPublic = p.IsPublic, + cheerCount = p.CheerCount, + viewCount = p.ViewCount, + createdAt = p.CreatedAt, + }; + }).ToList(); + } +} diff --git a/DorkNet.Server/Controllers/Storage/StorageController.cs b/DorkNet.Server/Controllers/Storage/StorageController.cs new file mode 100644 index 0000000..64faadc --- /dev/null +++ b/DorkNet.Server/Controllers/Storage/StorageController.cs @@ -0,0 +1,429 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.Storage; + +/// +/// storage.rec.net — file upload endpoint the 2020 client posts to when +/// the master client persists a room save. Verified against the IL2CPP +/// disassembly of RecNet.Storage.UploadFile: +/// +/// Method: POST. +/// URL: https://storage.rec.net/upload (Service=Storage, +/// requestUri="upload"; per Service enum at dump.cs:586534). +/// Body: multipart/form-data with fields +/// File (binary, content-type from caller or +/// application/octet-stream), FileType +/// (Storage.FileType: 0=Unknown, 1=RoomSave, 2=Holotar, 3=Image, +/// 4=Video, 5=Invention), and optional References +/// (comma-joined filenames). +/// Response: UploadFileResponseDTO with one key +/// Filename — the cdn URL segment the client will request via +/// cdn.rec.net/room/{Filename} the next time anyone enters +/// the room. +/// +/// +/// Permission rule: only the room's creator (or the dorm owner for a +/// dorm save) can persist a new blob. Saves from other players in the +/// same room are silently rejected with 403 — they can build with the +/// Maker Pen but their changes won't outlive the session. +/// +[ApiController] +[Authorize] +public class StorageController( + DorkNetDbContext db, + PlayerPresenceService presence, + IObjectStorage objectStorage, + ILogger logger) : ControllerBase +{ + /// + /// FileType enum values from the client's RecNet.Storage+FileType + /// (dump.cs:586846). Kept as a private enum so callers can pattern- + /// match without leaking client-side numbering elsewhere. + /// + private enum FileType + { + Unknown = 0, + RoomSave = 1, + Holotar = 2, + Image = 3, + Video = 4, + Invention = 5, + } + + [HttpPost("/upload", Order = -2000)] + [Consumes("multipart/form-data")] + public async Task Upload() + { + // Form parsing — ASP.NET Core deserialises the multipart body + // automatically. The two field names are the literal strings the + // client uses (capitalised; verified in + // RecNet.Storage.UploadFile ISIL state {028, 044}). + if (!Request.HasFormContentType) + return BadRequest(new { error = "expected multipart/form-data" }); + + var form = await Request.ReadFormAsync(); + var file = form.Files["File"]; + if (file is null || file.Length == 0) + return BadRequest(new { error = "missing or empty File part" }); + + if (!int.TryParse(form["FileType"].ToString(), out var fileTypeRaw)) + return BadRequest(new { error = "missing or non-numeric FileType" }); + var fileType = (FileType)fileTypeRaw; + + var playerId = this.RequireCurrentPlayerId(); + var references = form["References"].ToString() ?? string.Empty; + logger.LogInformation( + "[storage] upload accepted host={Host} fileType={FileType} player={PlayerId} bytes={Bytes} refs={ReferenceCount}", + Request.Host.Host, + fileType, + playerId, + file.Length, + string.IsNullOrWhiteSpace(references) + ? 0 + : references.Split(',', StringSplitOptions.RemoveEmptyEntries).Length); + + // Read the upload bytes upfront. SQLite BLOB column expects a + // byte[]; the upload is small (KB to ~1MB) so memory is fine. + byte[] bytes; + using (var ms = new MemoryStream()) + { + await file.CopyToAsync(ms); + bytes = ms.ToArray(); + } + + return fileType switch + { + FileType.RoomSave => await UploadRoomSaveAsync(playerId, bytes, references), + FileType.Invention => await UploadInventionAsync(playerId, bytes, references), + FileType.Image => await UploadImageAsync(playerId, bytes), + FileType.Holotar => await UploadGenericAsync(playerId, bytes, "holotar"), + FileType.Video => await UploadGenericAsync(playerId, bytes, "video"), + _ => Ok(new + { + filename = $"stub_{Guid.NewGuid():N}.{Extension(fileType)}", + }), + }; + } + + + private async Task UploadRoomSaveAsync( + long playerId, byte[] bytes, string references) + { + // Determine which room the upload belongs to. The client doesn't + // include the room id in the request — it just posts a save and + // expects the server to know based on session context. We use + // PlayerPresenceService, which is updated by /goto/room/* and + // is the canonical "what room is this player currently in" + // store. + var presenceRoom = presence.GetRoom(playerId); + if (presenceRoom is null) + { + logger.LogWarning( + "[storage] room save from player {PlayerId} had no active presence; saving to personal dorm", + playerId); + var dorm = await db.Rooms.FirstOrDefaultAsync(r => r.IsDormRoom && r.CreatorPlayerId == playerId); + if (dorm is not null) + return await UploadDormSaveAsync(playerId, bytes, references, dorm.Id); + + return BadRequest(new { error = "no active room session" }); + } + + var roomId = presenceRoom.RoomId; + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) + { + var roomName = string.IsNullOrWhiteSpace(presenceRoom.Name) + ? $"Room_{roomId}" + : presenceRoom.Name; + if (await db.Rooms.AnyAsync(r => r.Name == roomName)) + roomName = $"Room_{roomId}"; + + logger.LogWarning( + "[storage] room save from player {PlayerId} referenced missing room {RoomId} ({RoomName}); creating owner room row", + playerId, roomId, roomName); + room = new RoomEntity + { + Id = roomId, + Name = roomName, + Description = "Created from an active game session.", + CreatorPlayerId = playerId, + Accessibility = presenceRoom.IsPrivate ? 0 : 1, + IsAGRoom = true, + IsDormRoom = false, + LocationReplicationId = string.IsNullOrWhiteSpace(presenceRoom.Location) + ? "76d98498-60a1-430c-ab76-b54a29b7a163" + : presenceRoom.Location, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + db.Rooms.Add(room); + await db.SaveChangesAsync(); + } + + // Dorm saves go to a per-player DormStateEntity instead of the + // shared RoomEntity. Pass the real dorm Room.Id through so the + // saved blob is keyed against the player's own dorm row (every + // player has their own dorm RoomEntity now, not the legacy + // canonical sentinel id=1). + if (room.IsDormRoom) + return await UploadDormSaveAsync(playerId, bytes, references, room.Id); + + // Permission: only the room owner can save. (Future Phase 5 + // expansion: co-owners + admins. For now, strict owner-only — + // matches the 2020 official server's "RoomRolePermissions + // .CanUseMakerPen" behaviour, which rejects upload at the wire + // for non-owners.) + if (room.CreatorPlayerId != playerId) + { + logger.LogWarning( + "[storage] player {PlayerId} tried to save room {RoomId} (creator={CreatorId})", + playerId, room.Id, room.CreatorPlayerId); + return Forbid(); + } + + // Allocate the next BlobName for this room. We use a monotonic + // counter per-room: the (N+1)th save becomes "room__v.dat". + // SQLite handles the count() query fast enough for our scale; if + // this grows too big, switch to a per-room counter column on + // RoomEntity. + var versionNumber = await db.RoomDataBlobs + .Where(b => b.RoomId == roomId) + .CountAsync() + 1; + var blobName = $"room_{roomId}_v{versionNumber}.dat"; + var (bucket, key) = BlobRouter.Route(blobName); + await StoreBlobObjectAsync(bucket, key, bytes, "application/octet-stream"); + + var entry = new RoomDataBlobEntity + { + RoomId = roomId, + BlobName = blobName, + UploadedByPlayerId = playerId, + UploadedAt = DateTime.UtcNow, + ReferencedFilenamesCsv = references, + }; + db.RoomDataBlobs.Add(entry); + + // Update RoomEntity.CurrentDataBlobName so the next visitor's + // /api/rooms/v4/details/{id} response carries the new name and + // the persistence flow downloads the latest bytes. + room.CurrentDataBlobName = blobName; + room.UpdatedAt = DateTime.UtcNow; + + await db.SaveChangesAsync(); + + logger.LogInformation( + "[storage] room save: room={RoomId} player={PlayerId} blob={Blob} size={Size}B", + roomId, playerId, blobName, bytes.Length); + + return Ok(new { filename = blobName }); + } + + /// + /// Per-player dorm save. Stores bytes in + /// keyed under RoomId=1 (matchmaking is unchanged) and + /// upserts the row for this player so + /// next time they enter their dorm, RoomsController.Details serves + /// the right BlobName from their personal state row instead of the + /// shared RoomEntity row. + /// + private async Task UploadDormSaveAsync( + long playerId, byte[] bytes, string references, long dormRoomId) + { + // Migration backfill: pre-personal-dorm saves used the legacy + // canonical RoomId=1. Re-key any of those rows for THIS player + // to their actual dorm RoomId so the version count below + // doesn't collide with existing blob names. Idempotent — runs + // a no-op once everything's migrated. + var blobNamePrefix = $"dorm_p{playerId}_v"; + await db.RoomDataBlobs + .Where(b => b.UploadedByPlayerId == playerId + && b.BlobName.StartsWith(blobNamePrefix) + && b.RoomId != dormRoomId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.RoomId, dormRoomId)); + + // Per-player blob-name-based versioning. Counting by blob-name + // prefix is more robust than RoomId because it survives any + // future migrations of the dorm row identity. + var versionNumber = await db.RoomDataBlobs + .Where(b => b.UploadedByPlayerId == playerId + && b.BlobName.StartsWith(blobNamePrefix)) + .CountAsync() + 1; + var blobName = $"dorm_p{playerId}_v{versionNumber}.dat"; + var (bucket, key) = BlobRouter.Route(blobName); + await StoreBlobObjectAsync(bucket, key, bytes, "application/octet-stream"); + + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = dormRoomId, + BlobName = blobName, + UploadedByPlayerId = playerId, + UploadedAt = DateTime.UtcNow, + ReferencedFilenamesCsv = references, + }); + + // Upsert DormStateEntity for this player. + var dormState = await db.DormStates + .FirstOrDefaultAsync(d => d.PlayerId == playerId); + if (dormState is null) + { + dormState = new DormStateEntity { PlayerId = playerId }; + db.DormStates.Add(dormState); + } + dormState.CurrentDataBlobName = blobName; + dormState.UpdatedAt = DateTime.UtcNow; + + // Bump the dorm room's UpdatedAt timestamp so the watch's + // freshness check (DataModifiedAt comparison) sees this as the + // latest version and stops showing the "room is not up to + // date" warning after a save. + await db.Rooms + .Where(r => r.Id == dormRoomId) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.UpdatedAt, DateTime.UtcNow) + .SetProperty(r => r.CurrentDataBlobName, blobName)); + + await db.SaveChangesAsync(); + + logger.LogInformation( + "[storage] dorm save: player={PlayerId} blob={Blob} size={Size}B", + playerId, blobName, bytes.Length); + + return Ok(new { filename = blobName }); + } + + /// + /// Per-creation invention upload. Stores bytes in + /// with a recognisable + /// invention_p<id>_.dat blob name; the + /// metadata row in is created + /// separately by the client's POST to + /// api/inventions/v3/save referencing the returned + /// Filename. We don't enforce a per-invention id at upload time + /// (the inventon row may not exist yet) — the unique BlobName + /// guards uniqueness on its own. + /// + private async Task UploadInventionAsync( + long playerId, byte[] bytes, string references) + { + var blobName = $"invention_p{playerId}_{Guid.NewGuid():N}.dat"; + var (bucket, key) = BlobRouter.Route(blobName); + await StoreBlobObjectAsync(bucket, key, bytes, "application/octet-stream"); + + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + // RoomId=0 is the "no specific room — invention library blob" + // sentinel. The cdn lookup keys on BlobName so RoomId is + // informational only here. + RoomId = 0, + BlobName = blobName, + UploadedByPlayerId = playerId, + UploadedAt = DateTime.UtcNow, + ReferencedFilenamesCsv = references, + }); + await db.SaveChangesAsync(); + logger.LogInformation( + "[storage] invention upload: player={PlayerId} blob={Blob} size={Size}B", + playerId, blobName, bytes.Length); + return Ok(new { filename = blobName }); + } + + /// Profile image / screenshot upload. Stored as bytes on + /// the same RoomDataBlobs table (RoomId=0 is the + /// "no specific room" sentinel). The catch-all serves them via + /// cdn.rec.net/{Filename} like every other blob. The + /// caller is expected to follow up with + /// POST account/v1/profileimage to wire the returned + /// filename into . + /// + private async Task UploadImageAsync(long playerId, byte[] bytes) + { + var blobName = $"img_p{playerId}_{Guid.NewGuid():N}.png"; + var (bucket, key) = BlobRouter.Route(blobName); + await StoreBlobObjectAsync(bucket, key, bytes, "image/png"); + + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = 0, + BlobName = blobName, + UploadedByPlayerId = playerId, + UploadedAt = DateTime.UtcNow, + ReferencedFilenamesCsv = string.Empty, + }); + await db.SaveChangesAsync(); + logger.LogInformation( + "[storage] image upload: player={PlayerId} blob={Blob} size={Size}B", + playerId, blobName, bytes.Length); + return Ok(new { filename = blobName }); + } + + /// Catch-all bytes-on-disk upload used for FileTypes that + /// don't have specialised behaviour (Holotar, Video). Stored + /// identically to Image; callers reference the returned Filename + /// from wherever they need it. + private async Task UploadGenericAsync( + long playerId, byte[] bytes, string kind) + { + var ext = kind switch { "video" => "mp4", _ => "bin" }; + var blobName = $"{kind}_p{playerId}_{Guid.NewGuid():N}.{ext}"; + var contentType = kind switch + { + "video" => "video/mp4", + _ => "application/octet-stream", + }; + var (bucket, key) = BlobRouter.Route(blobName); + await StoreBlobObjectAsync(bucket, key, bytes, contentType); + + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = 0, + BlobName = blobName, + UploadedByPlayerId = playerId, + UploadedAt = DateTime.UtcNow, + ReferencedFilenamesCsv = string.Empty, + }); + await db.SaveChangesAsync(); + logger.LogInformation( + "[storage] {Kind} upload: player={PlayerId} blob={Blob} size={Size}B", + kind, playerId, blobName, bytes.Length); + return Ok(new { filename = blobName }); + } + + private async Task StoreBlobObjectAsync(string bucket, string key, byte[] bytes, string contentType) + { + try + { + using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var written = await objectStorage.PutAsync(bucket, key, bytes, contentType, s3Timeout.Token); + logger.LogInformation( + "[storage] object write: bucket={Bucket} key={Key} bytes={Bytes} mode={Mode}", + bucket, + key, + written, + objectStorage.IsS3Configured ? "s3" : "disk-fallback"); + } + catch (Exception ex) + { + logger.LogError( + ex, + "[storage] object write failed: bucket={Bucket} key={Key}; keeping DB fallback so the client request can complete", + bucket, + key); + } + } + + private static string Extension(FileType type) => type switch + { + FileType.RoomSave => "dat", + FileType.Image => "png", + FileType.Video => "mp4", + FileType.Holotar => "holotar", + FileType.Invention => "inv", + _ => "bin", + }; +} diff --git a/DorkNet.Server/Controllers/Strings/StringsController.cs b/DorkNet.Server/Controllers/Strings/StringsController.cs new file mode 100644 index 0000000..2a8e678 --- /dev/null +++ b/DorkNet.Server/Controllers/Strings/StringsController.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DorkNet.Server.Controllers.Strings; + +/// +/// strings.rec.net — game localisation strings. +/// Returns an empty table so the client falls back to its bundled defaults. +/// +[ApiController] +public class StringsController : ControllerBase +{ + [HttpGet("/strings/v1")] + public ActionResult> GetStrings() => Ok(new Dictionary()); + + [HttpGet("/strings/v1/{locale}")] + public ActionResult> GetLocale(string locale) + => Ok(new Dictionary()); +} diff --git a/DorkNet.Server/Data/DorkNetDbContext.cs b/DorkNet.Server/Data/DorkNetDbContext.cs new file mode 100644 index 0000000..e106dc2 --- /dev/null +++ b/DorkNet.Server/Data/DorkNetDbContext.cs @@ -0,0 +1,556 @@ +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data.Entities; + +namespace DorkNet.Server.Data; + +public class DorkNetDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Players => Set(); + public DbSet Avatars => Set(); + public DbSet PlayerSettings => Set(); + public DbSet Relationships => Set(); + public DbSet Rooms => Set(); + public DbSet RoomRoles => Set(); + public DbSet LeaderboardChannelMeta => Set(); + public DbSet RoomBookmarks => Set(); + public DbSet RoomDataBlobs => Set(); + public DbSet DormStates => Set(); + public DbSet AdminActions => Set(); + public DbSet Inventions => Set(); + public DbSet Reports => Set(); + public DbSet IpBans => Set(); + public DbSet Messages => Set(); + public DbSet ChatMessages => Set(); + public DbSet ChatThreads => Set(); + public DbSet ChatThreadMembers => Set(); + public DbSet Cheers => Set(); + public DbSet CurrencyBalances => Set(); + public DbSet PlayerEvents => Set(); + public DbSet PlayerEventResponses => Set(); + public DbSet ObjectiveProgress => Set(); + public DbSet Subscriptions => Set(); + public DbSet Photos => Set(); + public DbSet StoreItems => Set(); + public DbSet InventionVersions => Set(); + public DbSet RoomBans => Set(); + public DbSet PlayerInventory => Set(); + public DbSet GiftPackages => Set(); + public DbSet BugReports => Set(); + public DbSet Clubs => Set(); + public DbSet ClubMemberships => Set(); + public DbSet RoyaleMatches => Set(); + public DbSet RoyaleMatchPlayers => Set(); + public DbSet RoyalePlayerProgress => Set(); + public DbSet TestCases => Set(); + public DbSet TestPasses => Set(); + public DbSet PlayerDevices => Set(); + public DbSet NotificationPrefs => Set(); + public DbSet CohortAssignments => Set(); + public DbSet Coupons => Set(); + public DbSet CouponRedemptions => Set(); + public DbSet LeaderboardStats => Set(); + public DbSet PlayerElo => Set(); + public DbSet PushTokens => Set(); + public DbSet PlatformIgnores => Set(); + public DbSet Cards => Set(); + public DbSet RoomVisits => Set(); + public DbSet RoomScenes => Set(); + public DbSet PrivateInstances => Set(); + public DbSet PrivateInstanceInvitees => Set(); + public DbSet GameSessions => Set(); + public DbSet CommunityBoardRows => Set(); + public DbSet LoadingScreenTips => Set(); + public DbSet ServerSettings => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // SQLite-specific NOCASE collation works via UseCollation; Postgres + // doesn't have NOCASE. Provider-conditional helper applied to the + // three string columns the wire API matches case-insensitively + // (Room.Name, RoomScene.Name, Club.Name). + // + // FOLLOW-UP: on Postgres we currently fall back to default case- + // sensitive collation on these columns. In practice this is fine + // because the wire API users always type the same casing as the + // stored value, but if we want strict parity with SQLite NOCASE + // semantics we should add a functional LOWER(...) unique index + // via raw SQL in a Postgres migration, OR enable the citext + // extension and switch the column type. Tracked for post-cutover + // hardening; not a launch blocker. + var isSqlite = Database.IsSqlite(); + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + e.HasIndex(p => p.Username).IsUnique(); + e.HasOne(p => p.Avatar).WithOne(a => a.Player).HasForeignKey(a => a.PlayerId); + e.HasMany(p => p.Settings).WithOne(s => s.Player).HasForeignKey(s => s.PlayerId); + e.HasMany(p => p.Relationships).WithOne(r => r.Requester).HasForeignKey(r => r.RequesterId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(r => r.Id); + e.HasIndex(r => new { r.RequesterId, r.TargetId }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(s => s.Id); + e.HasIndex(s => new { s.PlayerId, s.Key }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(r => r.Id); + // Room names are case-insensitive in the wire API + // (api/rooms/v2/name/{name}) — store collation-insensitive so + // GetByName lookups don't depend on the user's casing. + e.HasIndex(r => r.Name).IsUnique(); + if (isSqlite) e.Property(r => r.Name).UseCollation("NOCASE"); + // Hot-feed sorts by HotScore desc — keep it indexed. + e.HasIndex(r => r.HotScore); + e.HasIndex(r => r.CreatorPlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(b => b.Id); + e.HasIndex(b => new { b.PlayerId, b.RoomId }).IsUnique(); + e.HasIndex(b => b.PlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(b => b.Id); + // BlobName is the cdn url segment the client requests; must + // be unique across the table because the catch-all matches + // by string. + e.HasIndex(b => b.BlobName).IsUnique(); + // "All versions of this room" + "find current" both index + // by RoomId. Sort by UploadedAt desc when listing history. + e.HasIndex(b => b.RoomId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(s => s.Id); + // BuildRoomDetails fetches all scenes for one room ordered + // by OrderIndex. Composite index covers both predicate and + // sort. + e.HasIndex(s => new { s.RoomId, s.OrderIndex }).IsUnique(); + // Sub-room goto resolves by (room, name). Case-insensitive + // match — same NOCASE collation pattern as RoomEntity.Name. + e.HasIndex(s => new { s.RoomId, s.Name }); + if (isSqlite) e.Property(s => s.Name).UseCollation("NOCASE"); + }); + + modelBuilder.Entity(e => + { + // PlayerId is already declared as [Key] on the entity; explicit + // here so EF doesn't expect an Id auto-pk. + e.HasKey(d => d.PlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(a => a.Id); + // The audit log is read in reverse-chronological order; + // index Timestamp desc for the common "give me recent + // admin actions" query. + e.HasIndex(a => a.Timestamp); + // Filter "all actions taken by admin X" or "all actions + // targeting player Y" both want indexes. + e.HasIndex(a => a.AdminPlayerId); + e.HasIndex(a => new { a.TargetType, a.TargetId }); + }); + + modelBuilder.Entity(e => + { + e.HasKey(i => i.Id); + e.HasIndex(i => i.CreatorPlayerId); + // Popular tab orders by CheerCount desc; index it. + e.HasIndex(i => i.CheerCount); + }); + + modelBuilder.Entity(e => + { + e.HasKey(r => r.Id); + // Admin queue reads "open reports ordered by oldest" — + // index ResolvedAt so the WHERE IS NULL filter is fast. + e.HasIndex(r => r.ResolvedAt); + e.HasIndex(r => r.TargetPlayerId); + e.HasIndex(r => r.ReporterPlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(b => b.Id); + // Middleware queries by Cidr per request — keep it + // indexed even though the table will be small. + e.HasIndex(b => b.Cidr); + }); + + modelBuilder.Entity(e => + { + e.HasKey(m => m.Id); + // Inbox query: "messages I received, ordered by SentAt + // desc". Index Recipient + SentAt for that path. + e.HasIndex(m => new { m.RecipientPlayerId, m.SentAt }); + e.HasIndex(m => new { m.SenderPlayerId, m.SentAt }); + }); + + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + // Thread fetch is "ThreadKey + SentAt desc, take 50". + e.HasIndex(c => new { c.ThreadKey, c.SentAt }); + }); + + modelBuilder.Entity(e => + { + e.HasKey(t => t.Id); + // One metadata row per thread — concurrent rename calls + // would otherwise duplicate-insert. + e.HasIndex(t => t.ThreadKey).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(m => m.Id); + // (Thread, Player) is the natural key — Snooze/MarkRead/ + // Leave race conditions would otherwise insert duplicate + // membership rows under the same player. + e.HasIndex(m => new { m.ThreadKey, m.PlayerId }).IsUnique(); + e.HasIndex(m => m.PlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + // Re-cheer is idempotent on the (from, target, type) + // tuple — unique index lets us upsert cleanly. + e.HasIndex(c => new { c.FromPlayerId, c.TargetPlayerId, c.TargetRoomId, c.TargetPhotoId, c.Type }) + .IsUnique(); + e.HasIndex(c => c.TargetPlayerId); + e.HasIndex(c => c.TargetRoomId); + e.HasIndex(c => c.TargetPhotoId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + // Feed pagination is "WHERE IsPublic AND DeletedAt IS NULL + // ORDER BY CreatedAt DESC" — index CreatedAt for that. + e.HasIndex(p => p.CreatedAt); + e.HasIndex(p => p.UploaderPlayerId); + e.HasIndex(p => p.RoomId); + // BlobName is the cdn URL the client requests; lookups by + // it are common (admin moderation, dedupe checks). + e.HasIndex(p => p.BlobName); + }); + + modelBuilder.Entity(e => + { + e.HasKey(s => s.Id); + // Slug is referenced from Avatar.InventoryJson — must be + // unique so the inventory resolver gets a deterministic + // hit when looking up "what does this slug mean?". + e.HasIndex(s => s.Slug).IsUnique(); + // Storefront tab queries filter by storefront + active + + // category in that order; index storefront for the most + // selective predicate first. + e.HasIndex(s => s.Storefront); + e.HasIndex(s => new { s.IsActive, s.Category }); + }); + + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + // One wallet row per (player, currency) — unique on the + // pair so AwardCurrency upserts cleanly. + e.HasIndex(c => new { c.PlayerId, c.CurrencyType }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(ev => ev.Id); + e.HasIndex(ev => ev.CreatorPlayerId); + e.HasIndex(ev => ev.StartsAt); + }); + + modelBuilder.Entity(e => + { + e.HasKey(r => r.Id); + // One response per (event, player); upsert via this + // unique index. + e.HasIndex(r => new { r.EventId, r.PlayerId }).IsUnique(); + e.HasIndex(r => r.PlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(o => o.Id); + e.HasIndex(o => new { o.PlayerId, o.Key }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(s => s.Id); + // One subscription per (subscriber, target) pair. + e.HasIndex(s => new { s.SubscriberPlayerId, s.TargetPlayerId }) + .IsUnique(); + // SubscriberCount aggregations index by target. + e.HasIndex(s => s.TargetPlayerId); + e.HasIndex(s => s.SubscriberPlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(v => v.Id); + // Version-list query: WHERE InventionId = X ORDER BY + // VersionNumber DESC. Index covers both predicates. + e.HasIndex(v => new { v.InventionId, v.VersionNumber }) + .IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(b => b.Id); + // Match-service join check: "is this player banned from + // this room?". Compound index makes that O(log n). + e.HasIndex(b => new { b.RoomId, b.BannedPlayerId }); + e.HasIndex(b => b.BannedPlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + // One row per (player, item) pair. + e.HasIndex(p => new { p.PlayerId, p.ItemSlug }).IsUnique(); + e.HasIndex(p => p.PlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(g => g.Id); + // Inbox query: unconsumed gifts for a player. + e.HasIndex(g => new { g.RecipientPlayerId, g.Consumed }); + }); + + modelBuilder.Entity(e => + { + e.HasKey(b => b.Id); + // Admin queue reads "unread bugs ordered by oldest" — + // ReadAt index for the WHERE IS NULL filter. + e.HasIndex(b => b.ReadAt); + e.HasIndex(b => b.ReporterPlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + e.HasIndex(c => c.Name).IsUnique(); + // GetByName lookups are case-insensitive on the wire. + if (isSqlite) e.Property(c => c.Name).UseCollation("NOCASE"); + e.HasIndex(c => c.CreatorPlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(m => m.Id); + // One membership per (club, player) pair. + e.HasIndex(m => new { m.ClubId, m.PlayerId }).IsUnique(); + e.HasIndex(m => m.PlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(m => m.Id); + e.HasIndex(m => m.CompletedAt); + }); + + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + e.HasIndex(p => new { p.MatchId, p.PlayerId }); + e.HasIndex(p => p.PlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + e.HasIndex(p => p.PlayerId).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(t => t.Pk); + // Wire Id (string) is what the client uses in URL params; + // unique so the lookup-by-string-id is fast. + e.HasIndex(t => t.Id).IsUnique(); + e.HasIndex(t => t.TestPassId); + e.HasIndex(t => t.Status); + }); + + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + // Don't auto-generate uint Id; we set it explicitly so it + // matches the upstream JIRA pass id when seeded. + e.Property(p => p.Id).ValueGeneratedNever(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(d => d.Id); + // Lookup "all accounts using this device" for ban + // enforcement. + e.HasIndex(d => d.DeviceId); + e.HasIndex(d => d.PlayerId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(n => n.Id); + // One pref row per (player, platform). + e.HasIndex(n => new { n.PlayerId, n.Platform }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + // Sticky assignment: one per (player, cohort). + e.HasIndex(c => new { c.PlayerId, c.CohortKey }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + e.HasIndex(c => c.Code).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(r => r.Id); + // Enforce one-redeem-per-player. + e.HasIndex(r => new { r.CouponId, r.PlayerId }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(s => s.Id); + // One row per (player, channel) — enforced unique so + // SetStats can upsert without race conditions. + e.HasIndex(s => new { s.PlayerId, s.StatChannel }).IsUnique(); + // Rank computation: ORDER BY Value DESC over a channel. + e.HasIndex(s => new { s.StatChannel, s.Value }); + }); + + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + e.HasIndex(p => new { p.PlayerId, p.GameMode }).IsUnique(); + // Matchmaker reads ranked players ordered by Elo DESC. + e.HasIndex(p => new { p.GameMode, p.Elo }); + }); + + modelBuilder.Entity(e => + { + e.HasKey(t => t.Id); + // One token per (player, platform) — APNS revokes the + // old one when the user reinstalls, so we upsert. + e.HasIndex(t => new { t.PlayerId, t.Platform }).IsUnique(); + e.HasIndex(t => t.Token); + }); + + modelBuilder.Entity(e => + { + e.HasKey(i => i.Id); + // One ignore-row per (player, platform, platform user id). + e.HasIndex(i => new { i.PlayerId, i.Platform, i.PlatformUserId }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + // Home-screen query: WHERE PlayerId IS NULL OR = me + // ORDER BY Priority DESC, CreatedAt DESC. + e.HasIndex(c => c.PlayerId); + e.HasIndex(c => c.ExpiresAt); + }); + + modelBuilder.Entity(e => + { + e.HasKey(v => v.Id); + // One row per (room, player) — enforced unique so the + // visit-upsert doesn't create duplicates under concurrent + // /goto calls. Lookup-by-pair is the hot path so the + // composite index doubles as the SELECT support index. + e.HasIndex(v => new { v.RoomId, v.PlayerId }).IsUnique(); + // Per-player history view (admin / dev console). + e.HasIndex(v => v.PlayerId); + // "Recent visitors of this room" view. + e.HasIndex(v => new { v.RoomId, v.LastVisitAt }); + }); + + modelBuilder.Entity(e => + { + // Id is server-allocated (computed nonce), not autoincrement. + // Mark ValueGenerated.Never so EF doesn't try to round-trip + // through a sequence on insert. + e.HasKey(p => p.Id); + e.Property(p => p.Id).ValueGeneratedNever(); + e.HasIndex(p => p.OwnerPlayerId); + // Quick lookup for "is this player in any private instance" + // queries used by NotificationService. + e.HasIndex(p => new { p.RoomId, p.SubRoomId }); + }); + + modelBuilder.Entity(e => + { + // Composite PK: one row per (instance, invitee). Naturally + // dedupes repeat-invite calls. + e.HasKey(i => new { i.PrivateInstanceId, i.PlayerId }); + // Reverse-lookup index for "what private instances am I + // invited to" queries (the watch's invite inbox). + e.HasIndex(i => i.PlayerId); + }); + + modelBuilder.Entity(e => + { + // Postgres bigserial; SQLite gets INTEGER PRIMARY KEY + // AUTOINCREMENT semantics. Both providers' sequences are + // process-shared so two replicas don't allocate id=1. + e.HasKey(s => s.Id); + // The hot lookup is "find a non-full session in this room + // and region" — composite index covers it. + e.HasIndex(s => new { s.RoomId, s.Region }); + }); + + modelBuilder.Entity(e => + { + // Single-row table, id always 1. We don't autoincrement; + // the row is upserted explicitly. + e.HasKey(c => c.Id); + e.Property(c => c.Id).ValueGeneratedNever(); + }); + + modelBuilder.Entity(e => + { + // Single-row table, id always 1 — same pattern as + // CommunityBoardRows above. + e.HasKey(s => s.Id); + e.Property(s => s.Id).ValueGeneratedNever(); + }); + + modelBuilder.Entity(e => + { + // Channel int IS the primary key — there's only one + // metadata row per stat-channel id, and the channel id + // is what the watch reports on POST /SetStats. + e.HasKey(c => c.Channel); + e.Property(c => c.Channel).ValueGeneratedNever(); + e.HasIndex(c => c.RoomId); + }); + } +} diff --git a/DorkNet.Server/Data/Entities/AdminActionEntity.cs b/DorkNet.Server/Data/Entities/AdminActionEntity.cs new file mode 100644 index 0000000..049d8dd --- /dev/null +++ b/DorkNet.Server/Data/Entities/AdminActionEntity.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Append-only audit row written every time an admin performs a +/// privileged action (ban, unban, promote, demote, room delete, +/// MOTD change, etc.). The admin watch UI's "audit" tab reads +/// from this in reverse-chronological order. +/// +/// Rows are never deleted from the application — privileged actions +/// stay on the record. Storage cost is negligible at single-server +/// scale. +/// +public class AdminActionEntity +{ + public long Id { get; set; } + + /// The admin who performed the action. + public long AdminPlayerId { get; set; } + + /// Free-form action name. Convention: lower-snake-case verb, + /// e.g. ban_player, unban_player, promote_admin, + /// delete_room, set_motd. + [MaxLength(64)] + public string Action { get; set; } = string.Empty; + + /// Targeted entity type — player, room, + /// system. Free-form so future actions can introduce new + /// kinds without a migration. + [MaxLength(32)] + public string TargetType { get; set; } = string.Empty; + + /// Numeric id of the target. 0 for system-wide + /// actions like setting the MOTD. + public long TargetId { get; set; } + + /// Optional human reason the admin supplied at action + /// time. Surfaced to the audit log UI verbatim. + [MaxLength(512)] + public string Reason { get; set; } = string.Empty; + + public DateTime Timestamp { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/AvatarEntity.cs b/DorkNet.Server/Data/Entities/AvatarEntity.cs new file mode 100644 index 0000000..e634edd --- /dev/null +++ b/DorkNet.Server/Data/Entities/AvatarEntity.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Per-player avatar state. The 2020 client persists a mix of structured +/// and free-form blobs: +/// +/// OutfitSelections — comma-separated GUIDs of equipped +/// items per slot (head, torso, legs, feet, accessories). Sent +/// verbatim to POST api/avatar/v2. +/// FaceFeatures — JSON blob with eyeId/eyePos/eyeScl/ +/// mouthId etc. produced by the in-game face editor. +/// HairColor / SkinColor — color vault GUIDs from +/// the client's baked hair/skin color tables. +/// SavedOutfitsJson — list of named outfit presets the +/// watch's Backpack tab shows under "saved". +/// EquippedItemsJson / InventoryJson — kept for the +/// v3 wire shape (list of ). +/// +/// +public class AvatarEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + // JSON-serialised list of equipped item IDs (v3 shape) + public string EquippedItemsJson { get; set; } = "[]"; + + // JSON-serialised full inventory (v3 shape) + public string InventoryJson { get; set; } = "[]"; + + /// v2 outfit selections — comma-separated GUIDs per slot. + [MaxLength(2048)] + public string OutfitSelections { get; set; } = string.Empty; + + /// v2 face features — opaque JSON written by the face + /// editor; we never parse it server-side, just round-trip. + [MaxLength(8192)] + public string FaceFeatures { get; set; } = string.Empty; + + [MaxLength(64)] + public string HairColor { get; set; } = string.Empty; + + [MaxLength(64)] + public string SkinColor { get; set; } = string.Empty; + + /// Saved outfit presets — JSON list of named slots so the + /// watch's "Saved" tab can recall them. + public string SavedOutfitsJson { get; set; } = "[]"; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public PlayerEntity? Player { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/BugReportEntity.cs b/DorkNet.Server/Data/Entities/BugReportEntity.cs new file mode 100644 index 0000000..c5d9611 --- /dev/null +++ b/DorkNet.Server/Data/Entities/BugReportEntity.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One bug report submitted via the in-game "Report a Bug" UI +/// (POST /api/bugreporting/v2/reportbug). Surfaces in the +/// admin moderation queue at GET /api/admin/v1/bugreports. +/// +public class BugReportEntity +{ + public long Id { get; set; } + public long ReporterPlayerId { get; set; } + + [MaxLength(128)] + public string Title { get; set; } = string.Empty; + + [MaxLength(4000)] + public string Body { get; set; } = string.Empty; + + public long GameSessionId { get; set; } + + [MaxLength(64)] + public string ClientVersion { get; set; } = string.Empty; + + [MaxLength(32)] + public string Platform { get; set; } = string.Empty; + + /// Optional category for routing — also reused by Phase + /// 11's POST /api/hile/v1/log (huddle telemetry) which + /// piggybacks on this table with Category=`hile`. + [MaxLength(32)] + public string Category { get; set; } = "bug"; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime? ReadAt { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/ChatMessageEntity.cs b/DorkNet.Server/Data/Entities/ChatMessageEntity.cs new file mode 100644 index 0000000..29ae890 --- /dev/null +++ b/DorkNet.Server/Data/Entities/ChatMessageEntity.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// In-game text-chat message. Sent to the chat.rec.net/thread +/// endpoint family; backed by a flat per-thread row table where the +/// thread id is just the canonical (lower, higher) pair of player ids +/// for DMs, or the room id for room-wide chats. +/// +/// We keep a separate table from because +/// the wire shape and history-fetch semantics differ — chat is +/// append-and-read-recent, messages are inbox-style with read +/// receipts. +/// +public class ChatMessageEntity +{ + public long Id { get; set; } + + /// String thread key. Format: dm:: for + /// direct messages between two players (lower id first), or + /// room: for room-wide chats. + [MaxLength(64)] + public string ThreadKey { get; set; } = string.Empty; + + public long SenderPlayerId { get; set; } + + [MaxLength(2000)] + public string Body { get; set; } = string.Empty; + + public DateTime SentAt { get; set; } = DateTime.UtcNow; + + /// Build the canonical DM thread key for two players. + /// Order-independent so both sides hit the same row. + public static string DmKey(long a, long b) => + a < b ? $"dm:{a}:{b}" : $"dm:{b}:{a}"; + + public static string RoomKey(long roomId) => $"room:{roomId}"; +} diff --git a/DorkNet.Server/Data/Entities/ChatThreadEntity.cs b/DorkNet.Server/Data/Entities/ChatThreadEntity.cs new file mode 100644 index 0000000..79bc83a --- /dev/null +++ b/DorkNet.Server/Data/Entities/ChatThreadEntity.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Per-thread metadata for chat: name (for group chats), creator, and +/// timestamps. Rows here are sparse — a two-person DM doesn't need +/// one unless the players want a custom thread name. Group chats +/// always have a row so the member set + name are persisted. +/// +public class ChatThreadEntity +{ + public long Id { get; set; } + + /// Canonical thread key (matches ChatMessageEntity.ThreadKey). + /// Format: dm:<low>:<high> for DMs, + /// group:<guid> for group chats, room:<id> for room-wide. + [MaxLength(96)] + public string ThreadKey { get; set; } = string.Empty; + + /// Optional custom name. DMs default to empty (watch renders + /// "Chat with <username>" client-side); group chats persist a + /// name set via PUT thread/{id}/rename. + [MaxLength(128)] + public string Name { get; set; } = string.Empty; + + public long CreatorPlayerId { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/ChatThreadMemberEntity.cs b/DorkNet.Server/Data/Entities/ChatThreadMemberEntity.cs new file mode 100644 index 0000000..e8082a4 --- /dev/null +++ b/DorkNet.Server/Data/Entities/ChatThreadMemberEntity.cs @@ -0,0 +1,36 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// Per-(thread, player) row: membership, per-player snooze, and the +/// last-read message id used for unread counts and the inbox's "X +/// unread" badge. One row per active participant. A "leave" deletes +/// the row so the player no longer receives new messages on that +/// thread. +/// +/// For two-person DMs without a custom name, rows are created lazily +/// on first MarkRead / Snooze / Leave — the thread itself still works +/// fine without rows (the message table is the source of truth for +/// who participated). +/// +public class ChatThreadMemberEntity +{ + public long Id { get; set; } + + /// The thread this membership applies to. Matches + /// ChatMessageEntity.ThreadKey / ChatThreadEntity.ThreadKey. + public string ThreadKey { get; set; } = string.Empty; + + public long PlayerId { get; set; } + + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; + + /// When set, the watch's inbox treats this thread as + /// muted until the UTC timestamp. Cleared by re-snoozing with a + /// null payload. + public DateTime? SnoozeUntil { get; set; } + + /// Id of the last ChatMessageEntity the player + /// has acknowledged via PUT thread/{id}/read. Drives the + /// per-thread unread counter on the watch's inbox tab. + public long? LastReadMessageId { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/CheerEntity.cs b/DorkNet.Server/Data/Entities/CheerEntity.cs new file mode 100644 index 0000000..5f8eb8b --- /dev/null +++ b/DorkNet.Server/Data/Entities/CheerEntity.cs @@ -0,0 +1,30 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// One cheer (the watch's positive-feedback button). Targets exactly +/// one of: a player (stored on ), a room +/// (), or a photo (). +/// Unused targets are 0. Aggregated reputation queries SUM rows by +/// target. +/// +/// One row per (FromPlayer, target, Type) — re-cheering is idempotent +/// (we upsert on the unique index). +/// +public class CheerEntity +{ + public long Id { get; set; } + + public long FromPlayerId { get; set; } + + public long TargetPlayerId { get; set; } + public long TargetRoomId { get; set; } + public long TargetPhotoId { get; set; } + public long TargetInventionId { get; set; } + + /// RoomieCheerType enum mirror: 0=General, 1=Helpful, + /// 2=GreatHost, 3=Sportsman, 4=Creative, 5=Credit. Stored as int + /// to leave room for future categories without a migration. + public int Type { get; set; } + + public DateTime CheeredAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/ClubEntity.cs b/DorkNet.Server/Data/Entities/ClubEntity.cs new file mode 100644 index 0000000..a905380 --- /dev/null +++ b/DorkNet.Server/Data/Entities/ClubEntity.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// A "Group" / club — wire type RecNet.Group +/// (Cpp2IL_CS/.../RecNet/Group.cs). Field names + types match +/// the client's Group.Deserialize shape so the watch round- +/// trips cleanly. Surfaces in the watch's profile tab as the player's +/// primary-group affiliation. +/// +public class ClubEntity +{ + /// Mirrors wire field GroupId. + public long Id { get; set; } + + [MaxLength(128)] + public string Name { get; set; } = string.Empty; + + [MaxLength(2000)] + public string Description { get; set; } = string.Empty; + + /// Mirrors wire CreatorId (Int32). Cast to int + /// in serialiser. + public long CreatorPlayerId { get; set; } + + [MaxLength(256)] + public string ImageName { get; set; } = string.Empty; + + /// GroupBanStatus enum: 0=GoodStanding, 1=InReview, + /// 2=TempLock, 3=Permaban. + public int BanStatus { get; set; } = 0; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +/// One row per (club, player). Permissions is the +/// GroupMembershipPermissions flags enum (Member=0, Moderator=24, +/// CoOwner=124, Owner=127, Pending=128 for join-requests on private +/// groups). +public class ClubMembershipEntity +{ + public long Id { get; set; } + public long ClubId { get; set; } + public long PlayerId { get; set; } + + /// GroupMembershipPermissions flags enum value. + public int Permissions { get; set; } = 0; + + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/CommunityBoardEntity.cs b/DorkNet.Server/Data/Entities/CommunityBoardEntity.cs new file mode 100644 index 0000000..5475856 --- /dev/null +++ b/DorkNet.Server/Data/Entities/CommunityBoardEntity.cs @@ -0,0 +1,28 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// Single-row table holding the dorm community board state. Replaces +/// data/community_board.json on disk + in-memory cache so admin +/// edits propagate across replicas without a file-watcher invalidation +/// dance. +/// +/// Only one row exists, with = 1. Reading is a single +/// SELECT (cheap; the board is one document, not paginated). Writing +/// is a single UPDATE — no synchronization needed because there's only +/// ever one row to contend on, and admin edits are rare. +/// +public class CommunityBoardEntity +{ + /// Always 1. We model it as an int PK rather than a + /// composite or singleton-table pattern because EF Core needs a key + /// to track changes; pinning to id=1 keeps the upsert pattern + /// simple. + public int Id { get; set; } = 1; + + /// JSON-serialised CommunityBoardState. Stored as + /// text rather than jsonb because we never query inside it — it's + /// loaded as a single blob and round-tripped to the client. + public string Json { get; set; } = "{}"; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/CouponEntities.cs b/DorkNet.Server/Data/Entities/CouponEntities.cs new file mode 100644 index 0000000..7d2a63a --- /dev/null +++ b/DorkNet.Server/Data/Entities/CouponEntities.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Admin-issued promo coupon. Players redeem by code via +/// POST /api/coupons/v1/redeem/{code}. Each redemption +/// inserts a row (one per +/// (coupon, player) pair so we can enforce one-redeem-per-player). +/// +public class CouponEntity +{ + public long Id { get; set; } + + [MaxLength(64)] + public string Code { get; set; } = string.Empty; + + [MaxLength(256)] + public string Description { get; set; } = string.Empty; + + /// RewardType: 0=Currency, 1=ItemSlug, 2=Subscription. + public int RewardType { get; set; } + + /// Currency type if RewardType=0; otherwise unused. + public int RewardCurrencyType { get; set; } + + public long RewardAmount { get; set; } + + /// Item slug if RewardType=1. + [MaxLength(64)] + public string RewardItemSlug { get; set; } = string.Empty; + + public bool IsActive { get; set; } = true; + public int MaxRedemptions { get; set; } = 0; + public int RedemptionCount { get; set; } = 0; + + public DateTime? ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public class CouponRedemptionEntity +{ + public long Id { get; set; } + public long CouponId { get; set; } + public long PlayerId { get; set; } + public DateTime RedeemedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/CurrencyBalanceEntity.cs b/DorkNet.Server/Data/Entities/CurrencyBalanceEntity.cs new file mode 100644 index 0000000..944636c --- /dev/null +++ b/DorkNet.Server/Data/Entities/CurrencyBalanceEntity.cs @@ -0,0 +1,19 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// Per-player wallet for a single currency. The 2020 client knows +/// about a small set of CurrencyTypes (1=Coins, 2=Tokens, …); +/// each gets its own row keyed by (PlayerId, CurrencyType). +/// +/// Updates go through +/// or admin grants; reads back through the +/// storefronts/v4/balance/{currencyType} endpoint. +/// +public class CurrencyBalanceEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + public int CurrencyType { get; set; } + public long Balance { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/DormStateEntity.cs b/DorkNet.Server/Data/Entities/DormStateEntity.cs new file mode 100644 index 0000000..9df24bd --- /dev/null +++ b/DorkNet.Server/Data/Entities/DormStateEntity.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Per-player dorm state. Decouples each player's dorm save bytes from +/// the canonical RoomEntity row at RoomId=1 (which is shared +/// across every dorm visit and would otherwise leak one player's save +/// into every other player's dorm). +/// +/// One row per account that has ever entered their dorm. Created on the +/// first /api/rooms/v4/details/1 hit and updated whenever the +/// player saves their dorm via Maker Pen. +/// +public class DormStateEntity +{ + /// The owning account id. Primary key — one dorm state row + /// per player. + [Key] + public long PlayerId { get; set; } + + /// Blob name for the player's most recent dorm save. Format + /// dorm_p<accountId>_v<n>.dat; matches the + /// stored when the upload + /// landed. Empty string before the player has saved. + [MaxLength(128)] + public string CurrentDataBlobName { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/GameSessionEntity.cs b/DorkNet.Server/Data/Entities/GameSessionEntity.cs new file mode 100644 index 0000000..2920c71 --- /dev/null +++ b/DorkNet.Server/Data/Entities/GameSessionEntity.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Active game session backing the matchmaking JoinOrCreate flow +/// in . Replaces +/// the in-process ConcurrentDictionary + atomic counter so: +/// +/// Session ids are allocated by Postgres bigserial — no +/// replica-local counter that collides with another replica's +/// after restart. +/// A "find a non-full session in this room+region" query +/// coordinates across all replicas; without it, two players +/// joining via different replicas could each create their own +/// fresh session for the same room and end up isolated. +/// +/// +/// Sessions are deleted when their PlayerCount hits 0 (handled in the +/// service, no FK cascade needed). Brief mid-rejoin flicker is fine — +/// the next JoinOrCreate just creates a new session with a new id. +/// +public class GameSessionEntity +{ + /// bigserial — allocated by Postgres on insert. Crosses + /// replicas safely because the sequence is shared. + public long Id { get; set; } + + [MaxLength(64)] public string RoomId { get; set; } = string.Empty; + [MaxLength(64)] public string ActivityLevelId { get; set; } = string.Empty; + [MaxLength(16)] public string Region { get; set; } = "us"; + [MaxLength(128)] public string PhotonRoomName { get; set; } = string.Empty; + public int MaxCapacity { get; set; } = 8; + public int PlayerCount { get; set; } = 0; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/GiftPackageEntity.cs b/DorkNet.Server/Data/Entities/GiftPackageEntity.cs new file mode 100644 index 0000000..91b06db --- /dev/null +++ b/DorkNet.Server/Data/Entities/GiftPackageEntity.cs @@ -0,0 +1,96 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Per RecNet.Avatars+GiftPackage wire shape (decompiled at +/// Cpp2IL_CS/.../RecNet/Avatars.cs:192-555) — a queued gift the +/// recipient can claim from the watch's "gifts" inbox. Used for both +/// admin-granted gifts and gift-drop / mystery-box / level-up prizes. +/// +/// Field names + types match the client's +/// GiftPackage.Deserialize(Dictionary<String, Object>) +/// directly so the watch round-trips cleanly. +/// is the bool the client reads — we additionally stamp +/// for server-side bookkeeping (not on wire). +/// +public class GiftPackageEntity +{ + public long Id { get; set; } + + /// The player whose inbox this gift sits in. + public long RecipientPlayerId { get; set; } + + /// Wire field (Nullable<Int32>) — the player who + /// sent the gift, or null for system / drop gifts. + public int? FromPlayerId { get; set; } + + [MaxLength(128)] + public string ConsumableItemDesc { get; set; } = string.Empty; + + /// Wire field AvatarItemType as Nullable<int> + /// (0 = Outfit, 1 = HairDye). Null when the gift has no avatar + /// item (currency-only, equipment-only, etc.). + public int? AvatarItemType { get; set; } + + /// Wire field name is exactly AvatarItemDescOrHairDyeDesc + /// — content is either the AvatarItem GUID (when AvatarItemType=0) + /// or the HairDye desc string (when AvatarItemType=1). + [MaxLength(128)] + public string AvatarItemDescOrHairDyeDesc { get; set; } = string.Empty; + + [MaxLength(128)] + public string EquipmentPrefabName { get; set; } = string.Empty; + + [MaxLength(64)] + public string EquipmentModificationGuid { get; set; } = string.Empty; + + /// CurrencyType enum: 0=Invalid, 1=LaserTagTickets, + /// 2=RecCenterTokens, 100=LostSkullsGold, 101=DraculaSilver, + /// 200=RecRoyale_Season1. + public int CurrencyType { get; set; } = 0; + + public int Currency { get; set; } = 0; + public int Xp { get; set; } = 0; + public int Level { get; set; } = 0; + + /// GiftContext enum (large, see Avatars.cs comments). + /// 0 = Default, 100 = LevelUp, 1000 = Holiday, etc. + public int GiftContext { get; set; } = 0; + + /// GiftRarity: -1=None, 0=Common, 10=Uncommon, 20=Rare, + /// 30=Epic, 50=Legendary. + public int GiftRarity { get; set; } = 0; + + [MaxLength(512)] + public string Message { get; set; } = string.Empty; + + /// PlatformType: -1=All, 0=Steam, 1=Oculus, 2=PlayStation, + /// 3=Microsoft, 4=HeadlessBot, 5=IOS. + public int Platform { get; set; } = -1; + + /// Asset name string for the Unity Material that the + /// gift box renders with. + [MaxLength(128)] + public string PackageMaterial { get; set; } = string.Empty; + + /// Asset name string for the GiftPackageVariant + /// ScriptableObject (e.g. "Standard", "Holiday", etc.). + [MaxLength(128)] + public string PackageVariant { get; set; } = string.Empty; + + public bool Consumed { get; set; } = false; + public bool IsValid { get; set; } = true; + + [MaxLength(512)] + public string ErrorMessage { get; set; } = string.Empty; + + public bool SupportsCurrentPlatform { get; set; } = true; + public bool IsGifted { get; set; } = false; + + /// Server-side audit timestamp; not part of wire + /// shape. + public DateTime? ConsumedAt { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/InventionEntity.cs b/DorkNet.Server/Data/Entities/InventionEntity.cs new file mode 100644 index 0000000..8f5460c --- /dev/null +++ b/DorkNet.Server/Data/Entities/InventionEntity.cs @@ -0,0 +1,97 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// A Maker-Pen creation that the player has saved as a reusable +/// "invention" — can be re-spawned in any room they enter, shared with +/// other players, or featured on the watch's Inventions tab. The +/// underlying SpawnableTemplateData protobuf bytes are uploaded via +/// storage.rec.net/upload with FileType=Invention (5). +/// +public class InventionEntity +{ + public long Id { get; set; } + + public long CreatorPlayerId { get; set; } + + /// Stable opaque GUID the client uses to deduplicate + /// instantiated inventions across rooms (matches + /// Invention.ReplicationId). Generated server-side at + /// create time. + [MaxLength(64)] + public string ReplicationId { get; set; } = string.Empty; + + [MaxLength(128)] + public string Name { get; set; } = string.Empty; + + [MaxLength(2000)] + public string Description { get; set; } = string.Empty; + + [MaxLength(256)] + public string ImageName { get; set; } = string.Empty; + + /// Legacy permission column (private=0/friends=1/public=2) + /// — kept for backward-compat with existing rows; new logic uses + /// + + /// (InventionPermission enum: Unassigned=0, LimitedOneUseOnly=10, + /// UseOnly=20, EditAndSave=40, Publish=60, Charge=80, + /// Unlimited=100). + public int Permission { get; set; } = 0; + + /// InventionPermission value the creator gets when + /// spawning their own creation (default Unlimited=100). + public int CreatorPermission { get; set; } = 100; + + /// InventionPermission value other players get when + /// spawning. Defaults Unassigned=0 (private). + public int GeneralPermission { get; set; } = 0; + + /// True once POST api/inventions/v2/publish sets + /// a non-Unassigned . Stays true + /// across re-edits — the wire field tracks "has this ever been + /// published". + public bool IsPublished { get; set; } = false; + + /// 1-based latest version index. Bumped each time + /// POST api/inventions/v3/addversion uploads new bytes. + /// Mirrors Invention.CurrentVersionNumber. + public int CurrentVersionNumber { get; set; } = 1; + + /// Set on first publish. Null until then. + public DateTime? FirstPublishedAt { get; set; } + + /// If the creator was in a room when first saving, + /// the originating room id for analytics. + public long? CreationRoomId { get; set; } + + /// How many distinct players have spawned this + /// invention in any room (rolling counter — increments on + /// download). + public int NumPlayersHaveUsedInRoom { get; set; } = 0; + + /// True for AG (community-built) inventions, false for + /// "Rec Room Original" / staff-baked ones. + public bool IsAgInvention { get; set; } = true; + + /// Most recent uploaded blob name. Format + /// invention__v.dat; the cdn catch-all serves the + /// matching bytes. + [MaxLength(128)] + public string CurrentBlobName { get; set; } = string.Empty; + + [MaxLength(1024)] + public string TagsCsv { get; set; } = string.Empty; + + public int CheerCount { get; set; } = 0; + public int SpawnCount { get; set; } = 0; + + /// Soft-delete flag. When set, the row is hidden from + /// browse / search / detail endpoints but stays in the table so + /// existing references (cheers, reports) keep resolving and the + /// blob bytes can be restored if needed. + public bool IsDeleted { get; set; } = false; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/InventionVersionEntity.cs b/DorkNet.Server/Data/Entities/InventionVersionEntity.cs new file mode 100644 index 0000000..9480aed --- /dev/null +++ b/DorkNet.Server/Data/Entities/InventionVersionEntity.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One historical version of an . Each +/// time the creator uploads a new SpawnableTemplateData blob via +/// POST api/inventions/v3/addversion, we snapshot the prior +/// blob name into a new row here so the watch's "version history" +/// tab and `GET api/inventions/v1/versions?inventionId=X` can list +/// them. Indexed on (InventionId, VersionNumber DESC) for the +/// common "show me the version list" query. +/// +public class InventionVersionEntity +{ + public long Id { get; set; } + public long InventionId { get; set; } + + /// Mirrors InventionVersion.ReplicationId — opaque + /// GUID the client uses to deduplicate spawns of this specific + /// version across rooms. + [MaxLength(64)] + public string ReplicationId { get; set; } = string.Empty; + + /// 1-based version index. Auto-assigned to + /// max(VersionNumber) + 1 on insert. + public int VersionNumber { get; set; } + + [MaxLength(128)] + public string BlobName { get; set; } = string.Empty; + + /// Tokens it costs to spawn this version of the + /// invention (decompiled wire field + /// InventionVersion.InstantiationCost). + public int InstantiationCost { get; set; } = 0; + + /// Watt budget the creation consumes + /// (InventionVersion.LightsCost). + public int LightsCost { get; set; } = 0; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/IpBanEntity.cs b/DorkNet.Server/Data/Entities/IpBanEntity.cs new file mode 100644 index 0000000..9f07d89 --- /dev/null +++ b/DorkNet.Server/Data/Entities/IpBanEntity.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// IP-level ban for blocking misbehaving traffic before the JWT-based +/// player ban can apply. Checked by IpBanCheckMiddleware ahead +/// of authentication; matching requests get a 403. +/// +/// CIDR notation lets a single row block a /24 or /32. The expiry is +/// optional — null means "permanent until manually removed". +/// +public class IpBanEntity +{ + public long Id { get; set; } + + /// CIDR or single-IP string (1.2.3.4 or 1.2.3.0/24). + /// Compared against HttpContext.Connection.RemoteIpAddress. + [MaxLength(64)] + public string Cidr { get; set; } = string.Empty; + + [MaxLength(512)] + public string Reason { get; set; } = string.Empty; + + public long BannedByAdminId { get; set; } + public DateTime BannedAt { get; set; } = DateTime.UtcNow; + + /// Null = permanent. + public DateTime? Until { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/LeaderboardChannelMetaEntity.cs b/DorkNet.Server/Data/Entities/LeaderboardChannelMetaEntity.cs new file mode 100644 index 0000000..2e57a2b --- /dev/null +++ b/DorkNet.Server/Data/Entities/LeaderboardChannelMetaEntity.cs @@ -0,0 +1,40 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// Admin-curated metadata for a leaderboard StatChannel id. The +/// 2020 watch reports raw integer channel ids via +/// POST api/Leaderboard/v1/SetStats; this table lets admins map +/// each channel to a specific room (so the room detail page can list +/// "its" leaderboards) and a human-readable name + ordering hint. Rows +/// here override the hardcoded AdminController.KnownStatChannels +/// fallback. Channels with no row stay listed as "Channel N". +/// +/// One channel id, one row. Repeated SetStats calls for the same +/// channel from any room write to that channel's value rows; the meta +/// row only changes when an admin renames or re-maps it. +/// +public class LeaderboardChannelMetaEntity +{ + /// Same int the watch reports for this channel. Primary + /// key — there's only ever one meta row per channel. + public int Channel { get; set; } + + /// Room this channel "belongs to" for the per-room detail + /// view. 0 = global / unscoped (e.g. cross-room player progression + /// counters). The same room can own many channels (e.g. Stunt + /// Runner has one channel per course). + public long RoomId { get; set; } + + public string Name { get; set; } = string.Empty; + + /// When true, a lower stored value is a better score (race + /// times, lap times, etc.). The list endpoint sorts ascending. + public bool LowerIsBetter { get; set; } + + /// UI hint: "count" (default), "time-ms" for elapsed-time + /// channels, "score" for points. The SPA formats accordingly. + public string ValueFormat { get; set; } = "count"; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/LoadingScreenTipEntity.cs b/DorkNet.Server/Data/Entities/LoadingScreenTipEntity.cs new file mode 100644 index 0000000..d2e9702 --- /dev/null +++ b/DorkNet.Server/Data/Entities/LoadingScreenTipEntity.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Tip shown on the dorm-load splash. Served via +/// cdn.{rec.net,localhost}/config/LoadingScreenTipData; edited via +/// the admin SPA. Wire shape per RecNet/LoadingScreenTip.cs +/// requires Title/Message/ImageName + Context/PlatformMask/HasImage +/// flags + an optional RoomNames list (scopes the tip to specific +/// rooms). +/// +public class LoadingScreenTipEntity +{ + public long Id { get; set; } + + [MaxLength(128)] public string Title { get; set; } = string.Empty; + [MaxLength(512)] public string Message { get; set; } = string.Empty; + + /// Optional uploaded image (RoomDataBlob filename). Empty + /// when the tip is text-only; CdnController sets + /// HasImage in the wire payload based on this. + [MaxLength(128)] public string ImageName { get; set; } = string.Empty; + + /// RecNet LoadingScreenContext enum (0 = Any). Controls + /// which loading screens the tip is eligible for. + public int Context { get; set; } = 0; + + /// PlatformMask bitfield (-1 = every platform). Used by + /// the watch to filter out tips that reference platform-specific + /// features (e.g. mobile-only). + public int PlatformMask { get; set; } = -1; + + /// CSV of room names this tip is scoped to. Empty = show + /// in any room's loading screen. Example: "DormRoom,MakerRoom". + [MaxLength(512)] public string RoomNamesCsv { get; set; } = string.Empty; + + public int SortOrder { get; set; } = 0; + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/MessageEntity.cs b/DorkNet.Server/Data/Entities/MessageEntity.cs new file mode 100644 index 0000000..3a22520 --- /dev/null +++ b/DorkNet.Server/Data/Entities/MessageEntity.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Direct message between two players. Backs the watch's +/// api/messages/v2 endpoints (inbox + send + mark-read). +/// +/// One row per message. Threading happens at read time — the inbox +/// query groups by (Sender, Recipient) pair to surface the most +/// recent message per conversation. +/// +public class MessageEntity +{ + public long Id { get; set; } + + public long SenderPlayerId { get; set; } + public long RecipientPlayerId { get; set; } + + [MaxLength(2000)] + public string Body { get; set; } = string.Empty; + + /// RecNet Message.MessageType wire value (see + /// Cpp2IL_CS/.../RecNet/Message.cs). Default 30 = + /// TextMessage. Other values used by the 2020 client: 0/6 = + /// GameInvite[V2], 3/7 = PartyActivitySwitch[V2], 10 = + /// RequestGameInvite (the "ask to join" flow), 1 = + /// GameInviteDeclined, 11 = RequestGameInviteDeclined, 20 = + /// FriendStatusOnline. + public int Type { get; set; } = 30; + + /// Optional RecNet room id carried by invite and party + /// activity messages. The client uses this to render room-aware + /// cards and passes it back when accepting V2 invites. + public long? RoomId { get; set; } + + public DateTime SentAt { get; set; } = DateTime.UtcNow; + + /// Null until the recipient hits the message in the + /// watch UI; set to the read timestamp at that point. + public DateTime? ReadAt { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/ObjectiveProgressEntity.cs b/DorkNet.Server/Data/Entities/ObjectiveProgressEntity.cs new file mode 100644 index 0000000..702414a --- /dev/null +++ b/DorkNet.Server/Data/Entities/ObjectiveProgressEntity.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Per-player objective / achievement progress. The 2020 client's +/// Objectives.MyProgress response is two arrays — Objectives +/// and ObjectiveGroups — keyed by string id. We collapse that to one +/// table here; group rows distinguish themselves with a leading +/// group: prefix on . +/// +public class ObjectiveProgressEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + /// Objective id from the client's hardcoded objective + /// table (e.g. tutorial_dorm_decorate). Group rows use + /// group:<groupId>. + [MaxLength(128)] + public string Key { get; set; } = string.Empty; + + public bool IsCompleted { get; set; } + public DateTime? ClearedAt { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/PhotoEntity.cs b/DorkNet.Server/Data/Entities/PhotoEntity.cs new file mode 100644 index 0000000..a9d07c4 --- /dev/null +++ b/DorkNet.Server/Data/Entities/PhotoEntity.cs @@ -0,0 +1,71 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One in-game camera photo, posted to the social feed. The actual +/// pixel bytes live in keyed by +/// (uploaded earlier via storage.rec.net's +/// /upload endpoint with FileType=Image). This entity wraps that blob +/// with the social metadata: who took it, where, who's tagged in it, +/// when it was posted, and how many cheers it has. +/// +/// Public photos are visible without auth on feed.rec.net; private +/// photos are only listable by the uploader (and admins). Soft-delete +/// via so moderation can take a photo down +/// without losing the audit trail. +/// +public class PhotoEntity +{ + public long Id { get; set; } + + public long UploaderPlayerId { get; set; } + + /// The cdn filename — the same string returned by + /// storage.rec.net/upload and used in the cdn.rec.net/{name} + /// download path. Indexed because feed lookups join blob bytes + /// via this key. + [MaxLength(256)] + public string BlobName { get; set; } = string.Empty; + + /// Optional caption typed on the watch's "Share" panel + /// before posting. Limited so a single photo can't grow the + /// table row unbounded. + [MaxLength(2048)] + public string Caption { get; set; } = string.Empty; + + /// The room the photo was taken in (resolved from + /// PlayerPresenceService at upload time). 0 if the player wasn't + /// in any room (rare — only happens during loading). + public long RoomId { get; set; } + + /// Comma-separated list of tagged player IDs. The watch + /// builds this from the players visible in the camera's frustum + /// when the shutter was pressed. Tagged players show up in their + /// own "Photos of me" feed. + [MaxLength(2048)] + public string TaggedPlayerIdsCsv { get; set; } = string.Empty; + + /// If false, photo only appears in the uploader's own + /// "My photos" page (and admin views); if true, it shows up on + /// the public feed.rec.net feed and any tagged player's + /// "Photos of me" tab. Defaults to true since the camera's + /// "Share" button is the natural action — making it private is + /// an explicit toggle. + public bool IsPublic { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// Soft-delete sentinel — non-null means the photo is + /// hidden from every feed but still in the table for audit. + /// Admin moderation tools set this; the bytes blob is kept until + /// a separate cleanup job decides the audit window has elapsed. + public DateTime? DeletedAt { get; set; } + + /// Denormalised cheer counter — incremented when a + /// CheerEntity row is inserted with TargetPhotoId=this.Id, so the + /// feed sort doesn't have to JOIN+aggregate every render. + public int CheerCount { get; set; } + + public int ViewCount { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/PlayerEntity.cs b/DorkNet.Server/Data/Entities/PlayerEntity.cs new file mode 100644 index 0000000..17f1c4e --- /dev/null +++ b/DorkNet.Server/Data/Entities/PlayerEntity.cs @@ -0,0 +1,123 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +public class PlayerEntity +{ + public long Id { get; set; } + + // Stable per-installation identifier the client sends as `deviceId` in + // platformlogin / account-create form bodies. We deliberately use this + // (not `platformId`) so accounts are NOT keyed to Steam IDs — anyone + // running the client gets a unique persistent account based on the + // hardware-derived device hash, regardless of which Steam emulator + // (Goldberg, none, real Steam) is in front. Indexed unique below. + [MaxLength(128)] + public string DeviceId { get; set; } = string.Empty; + + // Last-seen platform info — informational, NOT part of account identity. + public int LastPlatform { get; set; } = 0; + + [MaxLength(128)] + public string LastPlatformId { get; set; } = string.Empty; + + [MaxLength(64)] + public string Username { get; set; } = string.Empty; + + [MaxLength(64)] + public string DisplayName { get; set; } = string.Empty; + + [MaxLength(512)] + public string Bio { get; set; } = string.Empty; + + public int Level { get; set; } = 1; + public int XP { get; set; } = 0; + public int Reputation { get; set; } = 0; + public bool IsVerified { get; set; } = false; + + // New accounts default to non-developer. Admins grant the badge + // privilege via the SPA's Players → Profile flags tab on a + // per-player basis (used to default to true while we were + // bootstrapping the emulator; flipped off now that the build is + // stable and we don't want every walk-in to spawn with the dev + // console unlocked). + public bool IsDeveloper { get; set; } = false; + // Mirrors IsDeveloper for the overhead badge slider — the 2020 watch + // gates the in-settings "Developer Display Mode" slider on a single + // role check (`role/developer/{id}`), and the slider's positions + // render as "Community Team" / "Developer" above the player's head + // (verified in Cpp2IL_ISIL/.../PlayerUI.txt:9085-9099 — slider value + // 1 → "Community Team", 2 → "Developer"). Keep this field separate + // from IsDeveloper so admins can mark someone as community-team + // without granting full dev privileges; the role endpoint ORs both. + public bool IsCommunityTeam { get; set; } = false; + public bool CanReceiveInvites { get; set; } = true; + public bool IsJunior { get; set; } = false; + + [MaxLength(256)] + public string? ProfileImageName { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime LastSeenAt { get; set; } = DateTime.UtcNow; + + // ── Phase 1 account hardening ───────────────────────────────────────── + // Optional contact email. Nullable because the 2020 client never asks + // for one; reserved for future password-reset / admin-recovery flows. + [MaxLength(256)] + public string? Email { get; set; } + + // When non-null AND `> DateTime.UtcNow`, BanCheckMiddleware short-circuits + // every authenticated request from this account with 401. Setting to a + // past timestamp acts as a "previously banned" audit marker without + // blocking access. + public DateTime? BannedUntil { get; set; } + + // Privilege flag for `[AdminOnly]` endpoints (Phase 5). Defaults false; + // PlayerService bumps this to true when the very first account on a + // fresh DB is created so there's always at least one root admin. + public bool IsAdmin { get; set; } = false; + + // Last IP that authenticated against this account. Updated by the + // request-tracing pipeline so we don't write IPs from anonymous paths. + [MaxLength(64)] + public string? LastIp { get; set; } + + // The deviceId that originally created this account. Immutable after + // creation — exists for the audit trail in case the live DeviceId + // rotates (e.g., user re-installs and the hardware hash shifts; the + // account stays bound to whatever they typed in /account/create the + // first time). + [MaxLength(128)] + public string? CreatedFromDeviceId { get; set; } + + // BCP-47 locale tag (e.g. "en-US"). Optional; informs future + // localisation work and is currently set from the client's accept- + // language header at login. + [MaxLength(16)] + public string? Locale { get; set; } + + /// BCrypt hash of the account password. Null when the + /// account has never set one — the in-game settings screen calls + /// /account/me/haspassword and prompts the user to create one + /// before sensitive operations. Format: BCrypt's standard + /// `$2a$<cost>$<salt><hash>` so the verifier + /// reads cost + salt directly, no separate column needed. + [MaxLength(128)] + public string? PasswordHash { get; set; } + + /// Player's birthday (date only; no timezone). Set during + /// signup and used to derive on Junior cutoff + /// boundary crossing. Nullable for legacy accounts created before + /// the field existed. + public DateTime? Birthday { get; set; } + + /// Optional phone number — used by the watch's + /// account-recovery flow. Stored verbatim, never validated. + [MaxLength(32)] + public string? Phone { get; set; } + + // Navigation + public AvatarEntity? Avatar { get; set; } + public List Settings { get; set; } = []; + public List Relationships { get; set; } = []; +} diff --git a/DorkNet.Server/Data/Entities/PlayerEventEntity.cs b/DorkNet.Server/Data/Entities/PlayerEventEntity.cs new file mode 100644 index 0000000..8f3e172 --- /dev/null +++ b/DorkNet.Server/Data/Entities/PlayerEventEntity.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// A player-scheduled gathering ("event") in a specific room. Backs +/// the watch's Events tab — players can RSVP via +/// . +/// +public class PlayerEventEntity +{ + public long Id { get; set; } + + public long CreatorPlayerId { get; set; } + public long RoomId { get; set; } + + [MaxLength(128)] + public string Title { get; set; } = string.Empty; + + [MaxLength(2000)] + public string Description { get; set; } = string.Empty; + + public DateTime StartsAt { get; set; } + public DateTime EndsAt { get; set; } + + public int Capacity { get; set; } = 0; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +/// One row per RSVP'd player + event. +public class PlayerEventResponseEntity +{ + public long Id { get; set; } + public long EventId { get; set; } + public long PlayerId { get; set; } + /// 0=Going, 1=Maybe, 2=NotGoing — matches the watch's + /// three-state RSVP toggle. + public int Response { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/PlayerInventoryEntity.cs b/DorkNet.Server/Data/Entities/PlayerInventoryEntity.cs new file mode 100644 index 0000000..8ccf06d --- /dev/null +++ b/DorkNet.Server/Data/Entities/PlayerInventoryEntity.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One row per (player, item) pair. Backs both the +/// /api/equipment/v2/getUnlocked and +/// /api/consumables/v1/getUnlocked endpoints — the type comes +/// from the joined . +/// +/// For equipment, is always 1 and +/// tracks whether it's equipped. For +/// consumables, is the stack size and +/// tracks whether the player has it favourited / +/// quick-equipped on the watch. +/// +public class PlayerInventoryEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + /// FK by string to . + /// Stored as a string rather than an int FK because a player can + /// be granted items that aren't in the live store (admin-gifted, + /// legacy retired items, etc.). + [MaxLength(64)] + public string ItemSlug { get; set; } = string.Empty; + + public int Quantity { get; set; } = 1; + public bool IsActive { get; set; } = false; + + public DateTime AcquiredAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/PlayerSettingEntity.cs b/DorkNet.Server/Data/Entities/PlayerSettingEntity.cs new file mode 100644 index 0000000..705ae29 --- /dev/null +++ b/DorkNet.Server/Data/Entities/PlayerSettingEntity.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +public class PlayerSettingEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + [MaxLength(128)] + public string Key { get; set; } = string.Empty; + + [MaxLength(1024)] + public string Value { get; set; } = string.Empty; + + public PlayerEntity? Player { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/PlayerSystemsEntities.cs b/DorkNet.Server/Data/Entities/PlayerSystemsEntities.cs new file mode 100644 index 0000000..ee5b3b0 --- /dev/null +++ b/DorkNet.Server/Data/Entities/PlayerSystemsEntities.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One per (player, deviceId) — recorded on +/// POST /api/deviceid/v1/register. Lets admins flag +/// duplicate-account abuse: when a banned player creates a new +/// account on the same device, the new account inherits the device +/// ban automatically. +/// +public class PlayerDeviceEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + [MaxLength(128)] + public string DeviceId { get; set; } = string.Empty; + + [MaxLength(32)] + public string Platform { get; set; } = string.Empty; + + public DateTime FirstSeen { get; set; } = DateTime.UtcNow; + public DateTime LastSeen { get; set; } = DateTime.UtcNow; + + /// Set by admin tooling — if true, every account using + /// this device is auto-banned at login. + public bool IsBanned { get; set; } = false; +} + +/// Per-player notification preferences. iOS-style toggle +/// page; Android / Quest just persist the same shape. +public class NotificationPrefsEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + [MaxLength(32)] + public string Platform { get; set; } = string.Empty; + + public bool AllowAll { get; set; } = true; + public bool AllowFriendRequest { get; set; } = true; + public bool AllowMessage { get; set; } = true; + public bool AllowEventInvite { get; set; } = true; + public bool AllowAnnouncements { get; set; } = true; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +/// Sticky cohort assignment — one row per (player, cohort +/// key). Created on first hit to GET /api/cohort/v1/{key} with +/// a randomly selected variant; remains stable for the lifetime of +/// the player so A/B tests don't flicker between sessions. +public class CohortAssignmentEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + [MaxLength(64)] + public string CohortKey { get; set; } = string.Empty; + + [MaxLength(32)] + public string Variant { get; set; } = string.Empty; + + public DateTime AssignedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/PrivateInstanceEntity.cs b/DorkNet.Server/Data/Entities/PrivateInstanceEntity.cs new file mode 100644 index 0000000..9e3c3b5 --- /dev/null +++ b/DorkNet.Server/Data/Entities/PrivateInstanceEntity.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Durable record of a private (invite-only) Photon match created via +/// POST /goto/room/{name}?CreatePrivateInstance=True. Replaces +/// the in-process ConcurrentDictionary in +/// so +/// invitations land on any replica. +/// +/// Without this row in shared storage, an invite issued by Player A +/// (replica 1) wouldn't be visible to Player B's POST /goto/instance/{id} +/// when their request lands on replica 2 — they'd be routed to a fresh +/// public lobby instead of the inviter's match. +/// +/// The associated invite list lives in ; +/// we keep it as a join table rather than a JSON column so the per-row +/// "is X invited to Y?" check stays a fast indexed lookup. +/// +public class PrivateInstanceEntity +{ + /// The instance id used as the wire roomInstanceId + /// in matchmaking responses. Generated server-side at creation + /// time; stable for the lifetime of the match. NOT autoincrement — + /// the value is computed from owner/room/sub plus a nonce so the + /// id is itself unforgeable without the nonce. + public long Id { get; set; } + + public long RoomId { get; set; } + public long SubRoomId { get; set; } + public long OwnerPlayerId { get; set; } + + [MaxLength(256)] public string PhotonRoomId { get; set; } = string.Empty; + [MaxLength(64)] public string Location { get; set; } = string.Empty; + [MaxLength(256)] public string DataBlob { get; set; } = string.Empty; + [MaxLength(16)] public string PhotonRegion { get; set; } = "us"; + public int MaxCapacity { get; set; } = 8; + [MaxLength(128)] public string Name { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/PrivateInstanceInviteeEntity.cs b/DorkNet.Server/Data/Entities/PrivateInstanceInviteeEntity.cs new file mode 100644 index 0000000..bd3e2c5 --- /dev/null +++ b/DorkNet.Server/Data/Entities/PrivateInstanceInviteeEntity.cs @@ -0,0 +1,29 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// Join row marking that is on the invite list +/// for . Created when the owner sends an +/// invite via POST /invite (handled in MessagesController); +/// checked on POST /goto/instance/{id} by +/// PrivateInstanceService.CanJoin. +/// +/// Composite PK on (PrivateInstanceId, PlayerId) — naturally unique; +/// repeat-invite calls become idempotent no-ops. +/// +public class PrivateInstanceInviteeEntity +{ + public long PrivateInstanceId { get; set; } + public long PlayerId { get; set; } + public DateTime InvitedAt { get; set; } = DateTime.UtcNow; + + /// The MessageEntity.Id of the latest invite for this + /// (instance, player). Lets GoToInvite resolve the + /// roomInstanceId even when the watch's accept flow has already + /// raced through POST /api/messages/v3/delete and removed + /// the underlying message row. Without this column the server + /// returned ErrorCode 40 ("invite expired") for any successful + /// accept where DELETE landed first — which was the common case + /// because the 2020 watch fires DELETE and POST /goto/invite/{id} + /// in parallel. + public long? LatestInviteMessageId { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/PushTokenEntity.cs b/DorkNet.Server/Data/Entities/PushTokenEntity.cs new file mode 100644 index 0000000..2c2d3f0 --- /dev/null +++ b/DorkNet.Server/Data/Entities/PushTokenEntity.cs @@ -0,0 +1,86 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One push-notification token per (player, platform). Backs +/// POST api/messages/v1/IOSSaveDeviceToken + +/// IOSClearDeviceToken. Stored so an APNS / FCM proxy could +/// fan out push notifications to mobile clients; on a private +/// server we just persist for audit + future use. +/// +public class PushTokenEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + /// Platform: "ios", "android", "fcm", etc. + [MaxLength(16)] + public string Platform { get; set; } = "ios"; + + [MaxLength(256)] + public string Token { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +/// +/// One row per (player, platform user id) that the player has +/// chosen to ignore even before they appear in +/// . Backs +/// POST api/relationships/v1/bulkignoreplatformusers — when +/// a Steam friend joins later, the relationship is auto-set to +/// Ignored. Lets the import stick across friend-discovery cycles. +/// +public class PlatformIgnoreEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + /// PlatformType enum: 0=Steam, 1=Oculus, 2=PlayStation, + /// 3=Microsoft, 5=IOS. + public int Platform { get; set; } + + [MaxLength(64)] + public string PlatformUserId { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +/// +/// One row per "card" displayed on the watch's home screen / cards +/// tab. Backs GET api/cards/v1/all. Cards are short tappable +/// tiles ("Daily Login", "New Room: Foo", "Friend invited you to +/// X") seeded by the server and consumed by the client. +/// +public class CardEntity +{ + public long Id { get; set; } + + /// If null, the card is global (shown to everyone). + /// If set, only visible to that specific player. + public long? PlayerId { get; set; } + + [MaxLength(128)] + public string Title { get; set; } = string.Empty; + + [MaxLength(2000)] + public string Body { get; set; } = string.Empty; + + [MaxLength(256)] + public string ImageName { get; set; } = string.Empty; + + [MaxLength(512)] + public string ActionUrl { get; set; } = string.Empty; + + /// Card category — "announcement", "daily", "event", + /// "system". Drives the icon the watch renders. + [MaxLength(32)] + public string Category { get; set; } = "announcement"; + + public int Priority { get; set; } = 0; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ExpiresAt { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/RelationshipEntity.cs b/DorkNet.Server/Data/Entities/RelationshipEntity.cs new file mode 100644 index 0000000..1e06a69 --- /dev/null +++ b/DorkNet.Server/Data/Entities/RelationshipEntity.cs @@ -0,0 +1,58 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// Storage-side enum mirroring the int values previously hard-coded in +/// . Stays separate from the on-the-wire +/// DorkNet.Models.Relationships.RelationshipStatus so the contract +/// can evolve without touching the DB representation. Underlying int +/// values match — no migration data fixup needed. +/// +public enum RelationshipStatus +{ + /// Mutual friend (both directions accepted). + Friend = 1, + + /// Outgoing request from + /// awaiting accept/decline by . + PendingSent = 2, + + /// + /// Reserved for the receiving-side view if we later denormalise. The + /// backing column is still 1-2-4 in practice; PendingReceived is what + /// the wire DTO computes from the caller's perspective when the row's + /// PendingSent direction is "into" them. + /// + PendingReceived = 3, + + /// Block held by + /// against . Hides the target + /// from the requester's friend list and prevents new requests both ways. + Blocked = 4, +} + +public class RelationshipEntity +{ + public long Id { get; set; } + public long RequesterId { get; set; } + public long TargetId { get; set; } + public RelationshipStatus Status { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + /// True when favorited + /// . Favorited shows up first in the friend + /// list and triggers an extra notification on "online". + public bool Favorited { get; set; } + + /// True when muted text from + /// . Drops chat-message notifications but + /// keeps the friendship intact. + public bool Muted { get; set; } + + /// True when ignored + /// (a soft block — hides the player from + /// the room browser without breaking the relationship row). + public bool Ignored { get; set; } + + public PlayerEntity? Requester { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/ReportEntity.cs b/DorkNet.Server/Data/Entities/ReportEntity.cs new file mode 100644 index 0000000..d8c85ed --- /dev/null +++ b/DorkNet.Server/Data/Entities/ReportEntity.cs @@ -0,0 +1,62 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One in-game player report. Created by any authenticated player via +/// the watch's "report player" UI; surfaces in the admin moderation +/// queue at GET api/admin/v1/reports until an admin resolves it. +/// +public class ReportEntity +{ + public long Id { get; set; } + + /// Account id of the player filing the report. + public long ReporterPlayerId { get; set; } + + /// Account id of the player being reported. + public long TargetPlayerId { get; set; } + + /// Free-form category the watch sends — convention is + /// numeric matching the client's RoomieReportCategory enum + /// (Harassment=1, Inappropriate=2, Cheating=3, Spam=4, Other=5). + public int Category { get; set; } + + /// The reporter's free-text description. Capped at 1000 + /// chars by the watch UI. + [MaxLength(1000)] + public string Message { get; set; } = string.Empty; + + /// Optional game session id for context — admins can + /// correlate to room/server logs around the time of the report. + public long GameSessionId { get; set; } + + /// Room the offending behaviour happened in (0 if N/A). + public long RoomId { get; set; } + + /// If the report targets an invention, the invention id + /// (0 otherwise). + public long TargetInventionId { get; set; } + + /// If the report targets a room (rather than the player + /// in it), the room id (0 otherwise). + public long TargetRoomId { get; set; } + + /// If the report targets a player event, the event id + /// (0 otherwise). + public long TargetEventId { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// Null while the report sits in the admin queue; set + /// when an admin resolves it via + /// POST api/admin/v1/reports/{id}/resolve. Together with + /// + + /// forms the full audit trail. + public DateTime? ResolvedAt { get; set; } + + public long? ResolverAdminId { get; set; } + + [MaxLength(512)] + public string ResolutionNote { get; set; } = string.Empty; +} diff --git a/DorkNet.Server/Data/Entities/RoomBanEntity.cs b/DorkNet.Server/Data/Entities/RoomBanEntity.cs new file mode 100644 index 0000000..6fb0c31 --- /dev/null +++ b/DorkNet.Server/Data/Entities/RoomBanEntity.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One per-room ban issued by a room owner / admin via +/// POST /api/rooms/v2/banfromroom. The match service consults +/// these rows when resolving an instance join: if the joining player +/// has an active row matching the room id, the join is refused. +/// +public class RoomBanEntity +{ + public long Id { get; set; } + public long RoomId { get; set; } + public long BannedPlayerId { get; set; } + public long BannedByPlayerId { get; set; } + + /// BanType enum mirror: 0 = Soft (kick), 1 = Permanent, + /// 2 = Temporary (until ). + public int BanType { get; set; } + + public DateTime? Until { get; set; } + + [MaxLength(512)] + public string Reason { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/RoomBookmarkEntity.cs b/DorkNet.Server/Data/Entities/RoomBookmarkEntity.cs new file mode 100644 index 0000000..47ece49 --- /dev/null +++ b/DorkNet.Server/Data/Entities/RoomBookmarkEntity.cs @@ -0,0 +1,13 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// A "favorite" / bookmark — when a player taps the heart on a room in the +/// watch. Backs `api/rooms/v2/mybookmarks`. +/// +public class RoomBookmarkEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + public long RoomId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/RoomDataBlobEntity.cs b/DorkNet.Server/Data/Entities/RoomDataBlobEntity.cs new file mode 100644 index 0000000..0f5f7a5 --- /dev/null +++ b/DorkNet.Server/Data/Entities/RoomDataBlobEntity.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// Metadata for a single saved version of a room's PersistedRoomData +/// protobuf. The actual bytes live in S3 at the canonical +/// (bucket, key) returned by ; +/// this row only carries the lookup string + provenance. +/// +/// One row per save. Visitors fetching the room get the row whose +/// matches the current +/// RoomEntity.CurrentDataBlobName; the CDN controller takes +/// that name, routes it via , and +/// streams bytes out of S3. +/// +/// Was originally a fat row with a Bytes column; that column +/// was dropped once S3 became the canonical store. Only text columns +/// remain in the DB. +/// +public class RoomDataBlobEntity +{ + public long Id { get; set; } + + /// FK to RoomEntity. Indexed for "give me all versions of + /// this room" queries (history, GC). Set to 0 for shared assets + /// not bound to a specific room (HTRs, PV images, polaroids). + public long RoomId { get; set; } + + /// The string the client uses as the cdn URL segment. + /// converts this into a + /// canonical (bucket, key) deterministically. + [MaxLength(128)] + public string BlobName { get; set; } = string.Empty; + + /// The player id of the uploader. Audit trail; also used by + /// the GC to keep the room owner's saves longer than visitor saves. + public long UploadedByPlayerId { get; set; } + + public DateTime UploadedAt { get; set; } = DateTime.UtcNow; + + /// Comma-separated list of file names referenced by the + /// PersistedRoomData (e.g. invention thumbnails, audio clips). The + /// client passes these via the upload's referencedFilenames argument; + /// the server uses them later when GC'ing orphaned blobs. + [MaxLength(2048)] + public string ReferencedFilenamesCsv { get; set; } = string.Empty; + + /// + /// Legacy bytes column. The codebase no longer writes to this — S3 + /// is the canonical store — but rows imported / uploaded before the + /// S3-only cutover still hold their bytes here, and the backfill + /// migrator (POST /api/admin/v1/storage/backfill) reads them + /// to upload into S3. After backfill is verified, a follow-up EF + /// migration drops this column entirely and only metadata remains. + /// Nullable so freshly-inserted rows can omit it without breaking + /// SQLite's NOT NULL invariant. + /// + public byte[]? Bytes { get; set; } +} diff --git a/DorkNet.Server/Data/Entities/RoomEntity.cs b/DorkNet.Server/Data/Entities/RoomEntity.cs new file mode 100644 index 0000000..b6dc876 --- /dev/null +++ b/DorkNet.Server/Data/Entities/RoomEntity.cs @@ -0,0 +1,117 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// A user-creatable Rec Room "room" — has a name, a creator, a hub scene +/// (the AGRoomRuntimeConfig.Locations entry it spawns into), and metadata +/// for the watch UI's room browser. +/// +/// Naming maps: in the wire JSON the client expects mostly PascalCase +/// (Room.Deserialize at RVA 0x114E430). Property names match. +/// +/// Two-way relationship with PlayerEntity for ownership / bookmark / role +/// browsing — kept on the room side as ID columns rather than a navigation +/// to keep PlayerEntity unchanged. +/// +public class RoomEntity +{ + public long Id { get; set; } + + [MaxLength(128)] + public string Name { get; set; } = string.Empty; + + [MaxLength(2000)] + public string Description { get; set; } = string.Empty; + + /// The player who created the room (NOT the dorm owner — every + /// real Rec Room has a creator player id; for seeded "RecRoomOriginal" + /// rooms we set this to 1 / the system account). + public long CreatorPlayerId { get; set; } + + [MaxLength(256)] + public string ImageName { get; set; } = string.Empty; + + /// RoomState enum (int): 0 = Active, 1 = Archived. + public int State { get; set; } = 0; + + /// RoomAccessibility enum (int): 0 = Private, 1 = Public, + /// 2 = LimitedAccessibility / FriendsOnly. + public int Accessibility { get; set; } = 1; + + public bool SupportsLevelVoting { get; set; } = false; + + /// True for AG (user-built) rooms, false for "Rec Room + /// Original" rooms. The watch tabs `#community` (AG) and + /// `#recroomoriginal` (RR Originals) filter on this. + public bool IsAGRoom { get; set; } = true; + + public bool IsDormRoom { get; set; } = false; + public bool CloningAllowed { get; set; } = false; + public bool SupportsVRLow { get; set; } = true; + public bool SupportsMobile { get; set; } = false; + public bool SupportsScreens { get; set; } = true; + public bool SupportsWalkVR { get; set; } = true; + public bool SupportsTeleportVR { get; set; } = true; + public bool AllowsJuniors { get; set; } = true; + public int RoomWarningMask { get; set; } = 0; + + [MaxLength(512)] + public string CustomRoomWarning { get; set; } = string.Empty; + + public bool DisableMicAutoMute { get; set; } = false; + + /// RoomScene.RoomSceneLocationId GUID — foreign-keys to a + /// Locations[] entry in resources.assets. This is what + /// AGRoomSettings.TryGetRoomSceneLocationById matches. + /// Default: DormRoom location GUID extracted from the asset. + [MaxLength(64)] + public string LocationReplicationId { get; set; } = "76d98498-60a1-430c-ab76-b54a29b7a163"; + + /// Comma-separated tags. The 2020 watch supports a small set + /// (#community, #recroomoriginal, #featured, etc.) — keeping it as a + /// flat string avoids a join table for the v0 watch implementation. + [MaxLength(1024)] + public string TagsCsv { get; set; } = string.Empty; + + public int CheerCount { get; set; } = 0; + public int FavoriteCount { get; set; } = 0; + + /// Total joins across all players (every /goto + /// counts; same player rejoining bumps it again). Mirrors + /// official Rec.Net Stats.VisitCount. + public int VisitCount { get; set; } = 0; + + /// Distinct players who've ever visited (each player + /// counts once no matter how many times they rejoin). Mirrors + /// official Rec.Net Stats.VisitorCount. Bumped only when + /// inserts a new row for the + /// (room, player) pair. + public int VisitorCount { get; set; } = 0; + + /// Score the watch uses to sort the "Hot" tab. Higher = more + /// visible. Seeded rooms get bumped above empty user rooms so they show + /// up first in the browser. + public double HotScore { get; set; } = 0; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + /// True keeps the room out of room browse / search / + /// "RR Originals" feeds while leaving /goto, clone, and + /// admin-tool access intact. Used for rooms that exist in the seed + /// (MakerRoom, EventRoom, paintball sub-maps merged into the + /// Paintball lobby, etc.) but shouldn't show up to players because + /// either there's a unified room hosting their scenes elsewhere or + /// they're admin-only utility rooms. + public bool HiddenFromBrowse { get; set; } = false; + + /// Name of the latest row + /// for this room — also what we put in + /// RoomDetails.Scenes[0].DataBlobName so the client knows which + /// blob to download. Empty for AG-Original baked rooms (rec center, + /// paintball, etc.) which have no per-room save bytes; the dorm and + /// user-built / cloned rooms point at their latest save. + [MaxLength(128)] + public string CurrentDataBlobName { get; set; } = string.Empty; +} diff --git a/DorkNet.Server/Data/Entities/RoomRoleEntity.cs b/DorkNet.Server/Data/Entities/RoomRoleEntity.cs new file mode 100644 index 0000000..0530f01 --- /dev/null +++ b/DorkNet.Server/Data/Entities/RoomRoleEntity.cs @@ -0,0 +1,25 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// Per-room player role grant. Drives RoomDetails.CoOwners / +/// Moderators / Hosts arrays (and their Invited* counterparts when +/// is false). Each (RoomId, PlayerId, Role) +/// tuple is unique — granting the same role twice is a no-op. +/// +public class RoomRoleEntity +{ + public long Id { get; set; } + public long RoomId { get; set; } + public long PlayerId { get; set; } + + /// RoleKind: 0 = CoOwner, 1 = Moderator, 2 = Host. + public int Role { get; set; } + + /// False while the invite is still pending. Accepted + /// rows show up in CoOwners/Moderators/Hosts; pending rows show + /// up in InvitedCoOwners/InvitedModerators/InvitedHosts. + public bool Accepted { get; set; } = true; + + public long? GrantedByPlayerId { get; set; } + public System.DateTime GrantedAt { get; set; } = System.DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/RoomSceneEntity.cs b/DorkNet.Server/Data/Entities/RoomSceneEntity.cs new file mode 100644 index 0000000..e58cb6f --- /dev/null +++ b/DorkNet.Server/Data/Entities/RoomSceneEntity.cs @@ -0,0 +1,60 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One scene within a multi-scene custom room. Most rooms historically +/// had only Scenes[0] (synthesised in BuildRoomDetails from +/// + +/// ); rooms imported from a +/// multi-scene archive write one row per chapter here, and the wire-shape +/// builder emits the full Scenes[] array from this table when any +/// rows exist. +/// +/// The scene's on the wire is this table's +/// — the matchmaking SubRoomId the watch +/// passes to /goto/room/{name}/{subroom} matches against +/// , then resolves to for +/// RoomInstance.SubRoomId. +/// +public class RoomSceneEntity +{ + public long Id { get; set; } + + /// FK to . Indexed so the per-room + /// fetch in BuildRoomDetails is one query. + public long RoomId { get; set; } + + /// 0-based index this scene occupies in the wire + /// Scenes[] array. Doubles as the RoomSceneId the + /// matchmaking flow uses for RoomInstance.SubRoomId. + public int OrderIndex { get; set; } + + /// Display name (e.g. "Lobby", "Ch1_Headfirst"). Matched + /// case-insensitively against the sub-room segment of + /// /goto/room/{name}/{subroom}. + [MaxLength(128)] + public string Name { get; set; } = string.Empty; + + /// The RoomSceneLocationId GUID the client uses to + /// look up the bundled stage prefab via + /// AGRoomSettings.TryGetRoomSceneLocationById. Defaults to + /// the MakerRoom (Basement) scene GUID — the canonical "blank + /// canvas" stage 2020 custom rooms were built on. Extracted from + /// resources.assets via tools/extract-locations-binary.py: the + /// "Maker Room" Location entry has SceneName=Basement and + /// ReplicationId=a75f7547-79eb-47c6-8986-6767abcb4f92. + [MaxLength(64)] + public string RoomSceneLocationId { get; set; } = "a75f7547-79eb-47c6-8986-6767abcb4f92"; + + /// The the watch + /// downloads as the persisted state for this scene. + [MaxLength(128)] + public string DataBlobName { get; set; } = string.Empty; + + public int MaxPlayers { get; set; } = 8; + public bool IsSandbox { get; set; } = false; + public bool CanMatchmakeInto { get; set; } = true; + + public DateTime DataModifiedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/RoomVisitEntity.cs b/DorkNet.Server/Data/Entities/RoomVisitEntity.cs new file mode 100644 index 0000000..f37b549 --- /dev/null +++ b/DorkNet.Server/Data/Entities/RoomVisitEntity.cs @@ -0,0 +1,31 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// One row per (Room, Player) recording the player's visit history +/// to that room. Backs the proper RoomEntity.VisitorCount +/// (count of unique players who've visited) vs VisitCount +/// (total joins, including the same player multiple times) split +/// the official Rec.Net stats column distinguishes. +/// +/// Upserted by GoToController on every POST /goto/room +/// so the per-room "who's been here, when" history is queryable for +/// the Hot-rooms ranking + admin diagnostics. +/// +public class RoomVisitEntity +{ + public long Id { get; set; } + public long RoomId { get; set; } + public long PlayerId { get; set; } + + /// Set once on the player's first visit; immutable after. + /// Used to decide whether to bump RoomEntity.VisitorCount. + public DateTime FirstVisitAt { get; set; } = DateTime.UtcNow; + + /// Updated on every visit. Drives "recent visitors" + /// queries. + public DateTime LastVisitAt { get; set; } = DateTime.UtcNow; + + /// Per-player visit count (this player visited this room + /// N times). Sum across all rows = RoomEntity.VisitCount. + public int VisitCount { get; set; } = 0; +} diff --git a/DorkNet.Server/Data/Entities/RoyaleEntities.cs b/DorkNet.Server/Data/Entities/RoyaleEntities.cs new file mode 100644 index 0000000..c178c5e --- /dev/null +++ b/DorkNet.Server/Data/Entities/RoyaleEntities.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One Rec Royale match. Inserted by the server when +/// POST api/royale/v2/matchcomplete arrives so we keep an +/// audit trail (the client doesn't read this table — it only reads +/// the aggregated ). +/// +public class RoyaleMatchEntity +{ + public long Id { get; set; } + + public DateTime CompletedAt { get; set; } = DateTime.UtcNow; + + /// From MatchCompleteStats.Rank — the player's + /// finish position (1 = Victory Royale). + public int Rank { get; set; } + + public int NumEliminations { get; set; } + public int SecondsAlive { get; set; } + public bool WalkGame { get; set; } + public bool CustomGame { get; set; } + public int ChestsOpened { get; set; } + public int ShieldPotionsConsumed { get; set; } + public int HealthPotionsConsumed { get; set; } + public int SecondsInAir { get; set; } +} + +/// One per (match, player) — server-side audit only; not +/// exposed to client. Lets us aggregate per-match contributions +/// retroactively. +public class RoyaleMatchPlayerEntity +{ + public long Id { get; set; } + public long MatchId { get; set; } + public long PlayerId { get; set; } + + public int Rank { get; set; } + public int NumEliminations { get; set; } + public int SecondsAlive { get; set; } +} + +/// One per player — mirrors wire type +/// RecRoyalePlayerProgress (TotalXP/Level/RankIdx/RankName + +/// per-level XP thresholds and acorn reward). The watch reads this +/// from GET api/royale/v1/current. +public class RoyalePlayerProgressEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + public long TotalXP { get; set; } + public int Level { get; set; } + public int RankIdx { get; set; } + + [MaxLength(64)] + public string RankName { get; set; } = "Recruit"; + + public long CurrentLevelXPThreshold { get; set; } + public long NextLevelXPThreshold { get; set; } = 100; + public int NextLevelAcornReward { get; set; } = 50; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs new file mode 100644 index 0000000..544cb4b --- /dev/null +++ b/DorkNet.Server/Data/Entities/ServerSettingsEntity.cs @@ -0,0 +1,26 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// Single-row table holding server-wide runtime toggles that admins can +/// flip from the SPA without a redeploy. Modelled after +/// : is pinned to 1 +/// so EF Core has a key to track changes against, and reads/writes +/// touch one row. +/// +/// Add new boolean (or scalar) columns directly to this entity as more +/// toggles appear — keeping them strongly typed beats a generic +/// key/value table for the dozen-or-so global switches we'll ever have. +/// +public class ServerSettingsEntity +{ + public int Id { get; set; } = 1; + + /// When true, the watch's account-creation endpoints + /// (POST /account/create and POST /api/account/v1/create) + /// short-circuit with Success=false and the watch surfaces + /// the error to the player. Existing logins are unaffected; only + /// brand-new account requests are blocked. + public bool SignupsDisabled { get; set; } + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/StatsEntities.cs b/DorkNet.Server/Data/Entities/StatsEntities.cs new file mode 100644 index 0000000..050988f --- /dev/null +++ b/DorkNet.Server/Data/Entities/StatsEntities.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One leaderboard stat row per (player, statChannel). Backs +/// api/Leaderboard/v1/SetStats + api/Leaderboard/v2/getPlayerRank. +/// +/// The 2020 watch's RecNet.Leaderboards uses an int channel +/// id (e.g. 1=PaintballWins, 2=DodgeballHits, etc.) and a single +/// long value per channel. A player's "rank" is computed as +/// 1 + count(*) of rows in the same channel with a higher +/// value. +/// +public class LeaderboardStatEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + /// RecNet stat channel id (small int per game mode + + /// stat type — e.g. paintball-CTF-wins, dodgeball-hits). + public int StatChannel { get; set; } + + /// Aggregate stat value. Set semantics depend on + /// : 0=Always, 1=OnlyIfHigher, 2=OnlyIfLower, + /// 3=Increment. Most stat channels are mode 1 (high score). + public long Value { get; set; } + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +/// +/// Match-result Elo per (player, game mode). Backs +/// api/PlayerElo/v1/reportPlayerElo. The 2020 watch reports +/// an Elo delta after each ranked match (Royale, Paintball, +/// Soccer); we accumulate it per game mode for matchmaking. +/// +public class PlayerEloEntity +{ + public long Id { get; set; } + public long PlayerId { get; set; } + + /// RecNet game mode id (small int). We don't enumerate + /// these; the wire just sends an int and we pass it through. + public int GameMode { get; set; } + + /// Current Elo. Default 1000 on first row. + public int Elo { get; set; } = 1000; + + public int Wins { get; set; } + public int Losses { get; set; } + public int MatchesPlayed { get; set; } + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/StoreItemEntity.cs b/DorkNet.Server/Data/Entities/StoreItemEntity.cs new file mode 100644 index 0000000..7576ddb --- /dev/null +++ b/DorkNet.Server/Data/Entities/StoreItemEntity.cs @@ -0,0 +1,87 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// One purchasable item in the in-game store. The watch's Shop tab +/// renders these, and the purchase flow (initiatepurchase → +/// processpurchase → completepurchase) verifies the player has enough +/// of currency, deducts , +/// and appends to the buyer's +/// list so the customisation +/// menu can equip it. +/// +/// Item slots / categories track the avatar slots the watch's +/// outfit-selector groups items by — Head, Torso, Legs, Feet, +/// Accessory, Face, Hair, plus non-avatar categories (Consumable, +/// RoomTemplate, Emote) that don't go into outfit selections but +/// still show up in the player's inventory. +/// +public class StoreItemEntity +{ + public long Id { get; set; } + + /// Stable string identifier — what we put in + /// InventoryJson and OutfitSelections. Format is a GUID for + /// avatar items (so the watch's avatar resolver matches the + /// baked AvatarItemDefinition catalog), or a kebab-case slug for + /// non-avatar items (consumables / templates). Unique. + // Widened from 64→128 in 2026-05 to accommodate the + // wardrobe-colored-{guid}-{color} slugs the color-variant seeder + // generates — longest in the catalog is ~68 chars (long color + // names like "WhiteHatHacker" + 36-char GUID + prefix). + [MaxLength(128)] + public string Slug { get; set; } = string.Empty; + + [MaxLength(128)] + public string DisplayName { get; set; } = string.Empty; + + [MaxLength(1024)] + public string Description { get; set; } = string.Empty; + + /// Avatar slot or item type. One of: head, torso, legs, + /// feet, accessory, face, hair, consumable, roomtemplate, emote. + /// + [MaxLength(32)] + public string Category { get; set; } = "accessory"; + + /// cdn filename (relative to cdn.rec.net/) for the + /// store-tile thumbnail. Empty = the watch falls back to a + /// silhouette. Same format as RoomEntity.ImageName. + [MaxLength(256)] + public string ImageName { get; set; } = string.Empty; + + /// Currency the item is priced in. 2 = standard tokens + /// (the everyday currency earned from level-up + cheers); higher + /// numbers reserved for premium currencies (e.g. 1 = real-money + /// purchases — never enabled on a private server). + public int CurrencyType { get; set; } = 2; + + public long Price { get; set; } + + /// Listing flag. False = hidden from the store but kept + /// in the catalog so existing inventory references stay + /// resolvable. Admin-only mutation. + public bool IsActive { get; set; } = true; + + /// True for limited-time / event items. The watch + /// renders an "ends in" countdown alongside these and pulls them + /// from the catalog when AvailableUntil expires (we keep the row, + /// just flip IsActive=false on expiry). + public bool IsLimitedTime { get; set; } + + public DateTime? AvailableUntil { get; set; } + + /// Storefront grouping. "main" = the everyday + /// shop; "giftdrop:N" = a specific rotating gift-drop shelf + /// the 2020 client probes via /storefronts/v3/giftdropstore/{N}; + /// "rro" = every RRO/Rec Center in-room shelf; + /// "all" = every in-room shelf; "season:N" = + /// season-pass ladders. Default is main. + /// + [MaxLength(32)] + public string Storefront { get; set; } = "main"; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/SubscriptionEntity.cs b/DorkNet.Server/Data/Entities/SubscriptionEntity.cs new file mode 100644 index 0000000..f1235ad --- /dev/null +++ b/DorkNet.Server/Data/Entities/SubscriptionEntity.cs @@ -0,0 +1,15 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// Asymmetric "follow" relationship: +/// follows for activity updates. +/// Distinct from 's mutual friend +/// model — subscriptions are one-way and don't require acceptance. +/// +public class SubscriptionEntity +{ + public long Id { get; set; } + public long SubscriberPlayerId { get; set; } + public long TargetPlayerId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/TestCaseEntities.cs b/DorkNet.Server/Data/Entities/TestCaseEntities.cs new file mode 100644 index 0000000..28fe4ba --- /dev/null +++ b/DorkNet.Server/Data/Entities/TestCaseEntities.cs @@ -0,0 +1,96 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// QA test case — wire type RecNet.TestCase +/// (Cpp2IL_CS/.../RecNet/TestCase.cs). The 2020 Rec Room +/// client uses this to surface JIRA-tracked test cases in an +/// internal QA tab; users with the developer role claim a case, +/// run it in the named room, then submit a Pass/Fail status. +/// +/// Field names + types match the client's +/// TestCase.Deserialize exactly. is the +/// TestCaseStatus enum: 0=NotYetTested, 1=Claimed, 2=Failed, +/// 3=Passed. +/// +public class TestCaseEntity +{ + /// EF primary key (long autoincrement) — kept separate + /// from the wire string so we can index cleanly. + public long Pk { get; set; } + + /// Wire field Id (string) — typically the JIRA + /// issue key like RR-1234. + [MaxLength(64)] + public string Id { get; set; } = string.Empty; + + [MaxLength(64)] + public string Key { get; set; } = string.Empty; + + [MaxLength(256)] + public string Title { get; set; } = string.Empty; + + [MaxLength(2000)] + public string Description { get; set; } = string.Empty; + + [MaxLength(128)] + public string RoomName { get; set; } = string.Empty; + + /// TestCaseStatus enum: 0=NotYetTested, 1=Claimed, + /// 2=Failed, 3=Passed. + public int Status { get; set; } = 0; + + public int MinNumAssignedPlayers { get; set; } = 1; + + /// JSON list of player ids currently claimed on this + /// test case. Mirrors wire AssignedPlayerIds (List<int>). + public string AssignedPlayerIdsJson { get; set; } = "[]"; + + /// Sibling JSON list of names for display (kept in sync + /// with ). + public string AssignedPlayerNamesJson { get; set; } = "[]"; + + /// JSON list of tag strings. + public string TagsJson { get; set; } = "[]"; + + [MaxLength(512)] + public string JiraUrl { get; set; } = string.Empty; + + [MaxLength(512)] + public string JiraBugUrl { get; set; } = string.Empty; + + /// Optional FK to the this + /// case belongs to (null = orphan / standalone). + public uint? TestPassId { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +/// Wire type RecNet.TestPass — a versioned bundle of +/// test cases the QA team groups for a release pass. Listed via +/// GET api/testcasemanagement/v1/testpasssummary; one fetched +/// in detail via GET api/testcasemanagement/v1/testpass/{id}. +/// +public class TestPassEntity +{ + /// Wire Id is uint — store as long PK and cast. + public uint Id { get; set; } + + [MaxLength(128)] + public string Name { get; set; } = string.Empty; + + [MaxLength(2000)] + public string Description { get; set; } = string.Empty; + + public DateTime StartDate { get; set; } = DateTime.UtcNow; + + /// Null while the pass is still active. + public DateTime? EndDate { get; set; } + + public bool WasManuallyClosed { get; set; } = false; + + /// JSON list of tag strings. + public string TagsJson { get; set; } = "[]"; +} diff --git a/DorkNet.Server/Data/avatar_item_lookup.json b/DorkNet.Server/Data/avatar_item_lookup.json new file mode 100644 index 0000000..d45f205 --- /dev/null +++ b/DorkNet.Server/Data/avatar_item_lookup.json @@ -0,0 +1,461 @@ +{ + "items": [ + {"guid":"_OWVy3z6iU-M3-zbQgSLig","name":"(Wrist_VampireHunter)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"_qXaNbm4yEeyalo372QbvA","name":"(Eye_BikerGoggles)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"0088603e-ec3b-4478-8694-e6fb1989b3f2","name":"(Hair_AngledBob)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"01689baf-405e-4b38-9f09-b624e03865b8","name":"(Hat_Wizard)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"02077915-57d3-4e26-b4fe-8e0399cbb293","name":"(Shirt_Artist)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"022ce375-ed02-479d-b87d-9d72d126bbd4","name":"(Neck_JackFrostWings)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"02a5b48c-abba-4385-967b-ba900424829e","name":"(Eye_SafariMonocle)","outfit_type":10,"outfit_set":33,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"033e328f-d9d2-4e53-af20-44f18e3ea208","name":"(shirt_sherlock)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"03f8c394-28fa-4087-978b-8d108f0bd969","name":"(Hat_Angler)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"05ec4e87-088f-4281-a118-2e63c2585200","name":"(Neck_Tie_Gold_Sequin)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"06306723-ca20-4aa6-b7b3-917113f41ac3","name":"(Glasses_Pointy)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"07205057-0804-41de-abe4-bd4027fee1df","name":"(Hat_Conductor)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"0753d7a4-8247-4fca-a6fc-359c26086140","name":"(Hair_Fonzie)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"08d56627-4500-4724-8692-a23c96ddb3ec","name":"(Wrist_Mobster)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"09177621-9ecd-4f6a-b6a5-64490139141d","name":"(Hair_FlatTop)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"0ab5790f-a537-4306-ba66-5eb87c02ec7d","name":"(Shirt_Quiltedcoat)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"0ab6c95c-e157-48bd-a172-2642896b4ffc","name":"(shirt_royalLady)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"0bc6bd1b-8b7e-4945-a1d6-c26b9c043318","name":"(SeaCaptainTorso)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"0c537a84-9095-406f-8c87-1464f3a774ba","name":"(Hat_PB_TruckerHat)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"0d4e7ebe-efd9-4e68-9900-5645916e7596","name":"(Neck_TieSequin)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"0QOlT9c5xUuinpfZaoEe8Q","name":"(Hat_Tourist)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"0yFzgVESokC3gFd3hrS8bg","name":"(Shirt_TutorialGown_3)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"102c625b-b988-4bf8-a2aa-a31ad7029cdc","name":"(Hat_TopHat)","outfit_type":0,"outfit_set":0,"drawer_index":6,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"1041d745-c5e9-4218-a3e1-198f5c9c418c","name":"(Shirt_Safari)","outfit_type":101,"outfit_set":33,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"105a5100-cb57-410f-a26b-1ee21002f067","name":"(Shirt_JackFrost)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"11e508a7-fe7d-470e-b270-2dba33e2f7a1","name":"(Glasses_Scientist)","outfit_type":10,"outfit_set":23,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"123de433-a005-4994-8a11-d2d84ad66b11","name":"(Hat_LaserTag)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"125a3a62-1005-47dc-9fd8-ee09ea803e9f","name":"(Wrist_Aviator)","outfit_type":200,"outfit_set":34,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"13e70c80-69a4-4ad5-90e5-4dd5e03da677","name":"(Hat_Shark)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"14ef6b00-debf-4a85-9755-b4d37df496d3","name":"(Hat_Baseball)","outfit_type":0,"outfit_set":0,"drawer_index":3,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"1596c113-aa3a-4f5c-b398-e5c3575548e7","name":"(Hat_Luchador)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"17236d76-e3b6-42f6-9179-6fcf0479436f","name":"(Shirt_80sSpandex)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"1736a60b-6fb6-49bd-948a-fd3f3fc1be50","name":"(Eye_SciFiVisor_Glow)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"17aed0a0-70ed-49da-ad09-5bc4746f7718","name":"(Hat_ArtistBeret)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"18cd3470-2dd9-4a0a-98bd-eae2d5f5d522","name":"(Shirt_Barbershop)","outfit_type":101,"outfit_set":35,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"18feb3c2-049f-4f91-9f4e-6b5fcb4ab70c","name":"(Hat_FlapperHeadband)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"193a3bf9-abc0-4d78-8d63-92046908b1c5","name":"(Hair_Emo)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"1974dd48-aa44-434f-879a-eaff0c7c06e6","name":"(Wrist_WitchHunterGlove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"1a028850-64ee-40ab-b547-ebbcfa1b04b3","name":"(Hat_DrumMajor)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"1a71064b-794f-40fa-9109-8ad36602b6e1","name":"(Hair_Shagg)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"1b37d6cd-0a89-4604-a046-e5cff55b0cf6","name":"(FirefighterTorso)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"1beaf288-b5a5-475c-8149-c8f3d1146083","name":"(Wrist_HarnessGloves)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"1d27b674-f9e2-4ffc-9d8c-a58a1be06457","name":"(Hair_Afro)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"1e504fee-b593-4037-bd8f-b88025147991","name":"(Hat_Bow)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"1ebbfdfc-f72b-4d70-99d4-4d527f896aa7","name":"(Shirt_Beekeeper)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"1fcaf4fd-9012-407e-b691-fbc3ccf22421","name":"(Shirt_Scarecrow)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"1fd69ef8-0b74-4962-af5a-67f0bf0358f2","name":"(Hair_Ponytail)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"204bc384-e6d2-41b8-8ce9-0b26be5d85b7","name":"(Belt_WaitressApron)","outfit_type":102,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"20b424a9-861a-4d39-a583-22d7b7348c14","name":"(Hat_BogMonsterHat)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":true,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"21599b51-c50f-43d8-ac5f-62c30cd02ca5","name":"(Hair_Lori)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"21caa68e-c3fa-474c-af5e-af1e742b7a60","name":"(TennisSkirt)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2296ed0d-df56-4d46-b33a-aae9230a47fc","name":"(YellowBlueDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"22ef1089-d073-4fe8-b038-6f34adf0b85f","name":"(Wrist_Wizard)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"22pyI3Kz-UullhcebC6DLQ","name":"(Eye_Snorkeler_Goggles)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"241506f6-bf88-4b46-b5fe-513a225421f4","name":"(Hair_HalfUp)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"24a240f4-1574-420b-b898-a7e91f170759","name":"(Hair_ManBun)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"24b8b757-4168-4b3b-ba11-46f29d19044d","name":"(Jersey_Team_Emissive)","outfit_type":104,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":true}, + {"guid":"24c4404a-839f-46bd-98b6-6848d747a98b","name":"(Hat_Winter)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"24d94673-f394-42e6-a3c8-276f8b8aa722","name":"(Shirt_LaserTag)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"25d4c652-1730-4925-925e-31a290a2272a","name":"(Shirt_Smuggler)","outfit_type":101,"outfit_set":25,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"274cb9b2-2f59-47ea-9a8d-a5b656d148c6","name":"(Hat_Saija_Helmet)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2786fc22-3d6c-440d-afcc-15890c061577","name":"(Hat_Bee)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"27cdecd2-88f1-40a8-80a9-87abd827c3c5","name":"(Mouth_WizardBeard)","outfit_type":20,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"27e742bf-3081-4274-ba70-301f0edeb4f1","name":"(Shirt_Doctor)","outfit_type":101,"outfit_set":29,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"28374ebc-4050-4ea0-b3b6-41ebe75cedf7","name":"(Wrist_Punk)","outfit_type":200,"outfit_set":31,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"28e5dd74-7963-42cf-aa68-f8005aaf312e","name":"(Eye_Skeleton_Mask)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"293e7ad3-01c9-43af-96e3-06c34145ff12","name":"(Hat_BaseballHelmet)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2b2eb9e5-d52e-4004-bdb2-0bcf6910992d","name":"(Hat_Flowers)","outfit_type":0,"outfit_set":0,"drawer_index":2,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2b7f4dae-51da-4af0-b009-12d6005c85c6","name":"(Hair_Mullet)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"2bd53190-46f8-4956-9388-87c5a04efc4f","name":"(Hat_Rainhat)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2c3773a5-a0b4-41c5-a4df-6572dff516c9","name":"(Eye_2020Glasses)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2c679f89-c76e-4cfb-94e9-448c8fd44d55","name":"(Shirt_Saija)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2caHvVQ1vECrPv-YcdxdyA","name":"(Hat_Unicorn)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2cb4f372-3372-4583-8b57-c4e3988e3c28","name":"(Hair_PunkGirl)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"2dFMy4zc7ky_Fd5fzfgInw","name":"(Wrist_Letterman)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2e59d8d0-91a0-4449-bfdc-a5d663fd9343","name":"(CollarShirt)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2e5afc40-7e25-418c-8b1b-316b8ef479a7","name":"(KnightCape)","outfit_type":100,"outfit_set":1,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2f8518fc-c989-40de-98b4-c6f09b304166","name":"(BaseballTorso)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"2faa26b6-f7c3-4b0d-8f70-1f9454e64a54","name":"(Hat_Golfer)","outfit_type":0,"outfit_set":27,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"30380607-8b54-4b63-81d3-cb3f08e452c1","name":"(Dress_FormalDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3044adce-90c0-4f66-81f5-39c4fca86fdf","name":"(Wrist_LaserTagGlove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"34144f4c-f6ef-49e5-a286-1781e82e6782","name":"(Glasses_Paintball)","outfit_type":10,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"34654303-9760-447f-8614-1c26506db994","name":"(Hat_Electrician)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"348ac48b-986e-4d42-a0f7-1aea16b88271","name":"(Basketball_Tee)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"34d45669-b319-4c7d-bd05-078961d70d0b","name":"(FootBallShoulderPads)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"350c5c1e-318b-4233-a7e5-1f49909d8e65","name":"(Shirt_Lumberjack)","outfit_type":101,"outfit_set":3,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"35f60d27-22cf-4e17-8f39-c9e3a9fa2291","name":"(Shirt_ScoutSkirt)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3757149b-f478-4975-81e5-d808a1bed8d5","name":"(Shirt_Winter)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"37668d3d-c9f4-4bd6-81d4-a4f64ba2d61b","name":"(Neck_Bee)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"38b38e24-d636-41c6-b80e-a285ae3106dc","name":"(Neck_PirateCaptain)","outfit_type":102,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"39278302-b52a-48a8-a566-ed756a5fa1a9","name":"(Shirt_Lacrosse)","outfit_type":101,"outfit_set":15,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3a790be3-2937-44d4-be01-b5d65353bd3d","name":"(Wrist_Winter)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3af4c506-d49c-4126-9437-1d3bb1658863","name":"(Neck_Scallywag_Belt)","outfit_type":102,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3b00c71f-79c2-4a04-9fc0-a23b4c85562f","name":"(Shirt_PirateCaptain)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3bb50a88-09ae-48c7-a42c-df5879680eff","name":"(Wrist_ConstructionGlove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3c9acb9e-3353-4359-aa5a-ae9512b086b4","name":"(Wrist_Scarecrow)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3d5184e1-0201-4476-bf4b-4eb28190de62","name":"(Hat_CatEars)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3d9d52cb-6a3d-436b-90e7-e76f238329ee","name":"(Shirt_Vest)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3dc003c5-428b-47d6-80fb-6520b63119e8","name":"(Shirt_Electrician)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3dec4865-cf23-4c19-a460-dd2db80ee851","name":"(Wrist_Smuggler)","outfit_type":200,"outfit_set":25,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3df394de-9c3e-4141-aba4-12eac4742102","name":"(Hat_PirateCaptain)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3e139dd9-4757-43a8-8546-8da70dc17f0a","name":"(CopStache)","outfit_type":20,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"3e3f5ed5-d810-4054-b648-d93167b90033","name":"(Shirt_SleevelessDenimJacket)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"3R_zf8xliEiurVU8GwaItg","name":"(Shirt_IceQueenGown)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"4025e4c4-abaa-4729-a2a3-a7175313e5ed","name":"(Wrist_Beekeeper)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"40528de7-38a3-4a7c-8f93-6d3bfa5573f2","name":"(Hat_HeadBand)","outfit_type":0,"outfit_set":0,"drawer_index":1,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"40734c4d-e1fe-426c-8921-930f7463d8e8","name":"(hat_royalCrown)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"418db0e0-c7af-40c2-9cac-d461ce41e210","name":"(Shirt_WitchHunter)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"418ed21e-766f-4c69-9d57-6f866dcc7feb","name":"(Hat_Safari)","outfit_type":0,"outfit_set":33,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"42aa7888-f0b9-4584-9cc1-966585c6b36e","name":"(Shirt_Aviator)","outfit_type":101,"outfit_set":34,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"4308da04-af84-40bb-845a-7a9e1a2726ec","name":"(Shirt_Mobster)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"43354e59-4938-44bc-81e0-4fba47d0ac12","name":"(Eye_MardiGrasEyes2)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee","name":"(Shirt_50sDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"459ec62c-67db-4b72-9e29-0ad3ecd0b737","name":"(Hat_HarnessHelmet)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"45eaab67-19c2-4601-8f80-3565a4dceba4","name":"(Hair_Pompadour)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"45f5e714-8a5f-4385-a97f-675066167011","name":"(SeventiesStache)","outfit_type":20,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"473c672d-2b79-48a2-a49d-fd68160c5bde","name":"(Wrist_FlapperGlove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"474db08a-d4dc-4625-9f5a-1a6ae0b70827","name":"(Hat_BaseBall_FlatBill)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"47629ed1-5378-427c-ad61-4b44d2fe90cc","name":"(Eye_AviatorGoggles)","outfit_type":10,"outfit_set":34,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"4763436c-2dcc-482a-bf37-a6b16d7aae6e","name":"(Wrist_SpaceMarineGlove)","outfit_type":200,"outfit_set":24,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"49397e16-1027-4286-9bc4-b59eae565ff0","name":"(Hair_Jazzercise)","outfit_type":2,"outfit_set":9,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"4ce7a70f-15a0-4cad-98ee-5cbb40827338","name":"(Shirt_SpaceMarine)","outfit_type":101,"outfit_set":24,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"4d507dfa-4a99-4ac0-8537-229e9dc0eb4a","name":"(FemaleTank)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"4d533b3a-b5fa-446a-842d-92798ad5b493","name":"(Wrist_DrumMajor_Glove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"4e42e02e-33fd-4ff9-b74e-9bde26253c35","name":"(Wrist_BogMonsterWrist)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"4fcb5b84-13a9-43e6-9680-72b17874ae53","name":"(Hat_DeepSeaDiver)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"50c9c6f8-2963-4ef3-95d5-e999a898269f","name":"(Wrist_Saija_Glove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5154be09-bb8a-4917-95a0-51c91b6a4e09","name":"(Shirt_Conductor_Contest)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"515dfc36-7f7b-47bc-abee-380d68f41be6","name":"(RedFlowerDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"529015c7-3b4a-4a73-b2c3-33103339b6e3","name":"(Glasses_Smuggler)","outfit_type":10,"outfit_set":25,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"52VsI_lKlkSUAOJlfDnyrQ","name":"(Wrist_80sWindbreaker)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"57552652-d902-43b7-a68e-5c7da87582bc","name":"(Shirt_Scuba)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5800d2b5-75b8-442f-8b10-63bd3d73a1b8","name":"(Wrist_FighterPilot)","outfit_type":200,"outfit_set":26,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"587ca2bb-dd42-42bf-833f-4e2a97349866","name":"(Neck_Doctor)","outfit_type":100,"outfit_set":29,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"59d9560d-e5af-4205-b860-3afea1cc31a9","name":"(Hat_SpaceMarineHelmet)","outfit_type":0,"outfit_set":24,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5a38f864-7d1c-4989-8558-40b9668f78ef","name":"(Hat_FootBall)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5ac25e5a-8c8e-445c-82a4-7272baec1eb5","name":"(Hat_Doctor)","outfit_type":0,"outfit_set":29,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5accb7dc-90a3-46ee-bad2-8fd1861d50f1","name":"(CowboyScarf)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5b01eaa3-0cac-40c0-b72a-b3f6a868ae0c","name":"(Wrist_Archer_Glove)","outfit_type":200,"outfit_set":2,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5b3dc472-773e-4714-a9d0-7e7bbf6bc695","name":"(Neck_Scuba)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5b6535f0-86cd-417a-bcce-a1ace9a5f260","name":"(ArcherQuiver)","outfit_type":100,"outfit_set":0,"drawer_index":1,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5bde836f-9343-4934-b01a-606f707020ef","name":"(Hat_Sherlock)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5cd08cfb-c729-4c30-96d9-6a99bb934d91","name":"(Neck_MaleCheerSash)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5d1c42a0-7e36-431a-8e78-a8b74046d153","name":"(TiedScarf)","outfit_type":100,"outfit_set":0,"drawer_index":1,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5e3929d3-d291-4e91-a0ba-2ef203e407d3","name":"(Hat_FlatCap)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5e9bbda6-d681-42b1-9cc4-5758acf34bc0","name":"(Neck_NecklaceGold)","outfit_type":103,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5f1bc3c2-dfbb-4258-9b10-608535e34db2","name":"(Wrist_Zombie)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5f492f8f-ab61-484a-9587-747132b54309","name":"(Jazzercise)","outfit_type":101,"outfit_set":9,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5f600892-aa67-484a-afc7-14c219d89c52","name":"(Shirt_Flapper)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5fwjFQoC_Uu9ChvF6uNGZw","name":"(Eye_SkiBuddy)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"5PUja-WX00Sme9yBwvDq-g","name":"(Shirt_FigureSkaterDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"60067e91-18b8-43ab-ae20-a8ea74c757bf","name":"(Neck_FemaleCheerSash)","outfit_type":100,"outfit_set":0,"drawer_index":1,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"61ba3c90-c81e-4deb-bc79-50c0f1fe3e83","name":"(Beard_Lumberjack)","outfit_type":20,"outfit_set":3,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"62ce4109-8dee-4895-bf1b-bfa143db4c7e","name":"(BlueJacket)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"6427bd57-fcb2-420f-8b3e-d1da43470b84","name":"(Glasses_Sun)","outfit_type":10,"outfit_set":0,"drawer_index":1,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"64ad8789-1645-45f7-819a-a413ff7c9622","name":"(Wrist_PaintballGlove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"657efe89-620a-46a2-8b58-cafd855c7cd5","name":"(FemaleCheerTorso)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"664485e9-a159-4fcb-b32c-2b114b4a1218","name":"(Shirt_Scallywag)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"66f8f55d-cad4-4afb-93e8-b6bdf04b37b3","name":"(Hat_Green_Sequin_HeadBackBow)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"67117b3d-0669-4cf3-8a4f-066cabd4d8b1","name":"(LuchadorCape)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"6930ce13-4be4-4ab9-9817-667bd261ffc3","name":"(Shirt_Bishop)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"6a98bbb6-bbe0-4dc3-97a9-e253dc506a2b","name":"(Wrist_Skeleton_FingerlessGlove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"6b337a35-95d9-4b3a-9891-d9fbb06262aa","name":"(Mouth_CurlyMustache)","outfit_type":20,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"6b9e022c-0b68-48fd-8eca-da8573c18900","name":"(LongScarf)","outfit_type":100,"outfit_set":0,"drawer_index":1,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"6d36091e-7083-4e3d-8729-94612602c8cb","name":"(Hat_WitchHunter)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"6d48c545-22bb-46c1-a29d-0a38af387143","name":"(WristBand_Team)","outfit_type":203,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":true}, + {"guid":"6d815b35-6f68-4ed4-817d-70f141e1a571","name":"(OrangeCollarDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"6dd261d8-9806-4dac-81c4-a58f6d6a7f9a","name":"(Neck_Bowtie_Gold_Sequin)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"6QT99hx6DE6t9cKAs61i3w","name":"(Hat_SkiBuddy)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"6RhwWExXekaYpK0NGHSXGQ","name":"(Shirt_SkiBuddy)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"703f1987-2b31-4b6f-bab7-1939b1105ac0","name":"(Hat_Sun)","outfit_type":0,"outfit_set":0,"drawer_index":2,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"71014872-d879-4858-9d68-58da3c673d8b","name":"(Hat_Bike)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7134c60b-0640-463a-8d97-caa9184bef93","name":"(Hat_LacrosseHelmet)","outfit_type":0,"outfit_set":15,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"71921831-ba6f-408b-a00e-2fd97663636f","name":"(Wrist_WristTape)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"71fd5353-b439-4a23-b471-bfb99b8154d7","name":"(Hat_BunnyEars)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"739fd42a-8a8c-44a5-afa3-e0974802c6ae","name":"(Shirt_KnightTorso)","outfit_type":101,"outfit_set":1,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"73a119ab-d2c8-4da9-98b2-29edbb38e2c2","name":"(Dress_FormalDress_RedSequin)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"73c0e2ba-cba1-482a-89d5-1508997aceeb","name":"(Dress_FormalDress_SilverSequin)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"74365234-cb72-409d-8cf5-9a7209f707e1","name":"(Wrist_SafariGlove)","outfit_type":200,"outfit_set":33,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"745c5110-104e-4b37-aabf-7c690fd54811","name":"(PurpleJacket)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"74b91f2b-d4d3-4e41-8946-b1296ab7a773","name":"(Shirt_Tennis)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"74c65554-0d15-409b-b9a8-0b2620a7d7e2","name":"(Shirt_MartyRecFly)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"74e6878f-94e0-41f2-93ea-de6412238385","name":"(PartyBand)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7553d851-1986-4325-870d-e425dfb7d007","name":"(Mouth_BeardFarmer)","outfit_type":20,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"75-HXZBOAEiDBqclnOn8YQ","name":"(Belt_PoolFloatie)","outfit_type":102,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"76369aef-aeda-46d2-996a-cd00594d0543","name":"(Hat_Mobster)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"77c37481-a185-47ff-af55-65f9fdfcf9e5","name":"(FurCoat)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"77d3c585-4928-4471-a425-89036efe7299","name":"(Hair_Spiky)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"79b90274-6eec-4664-acfb-4a123334661e","name":"(Hair_PigTails)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"7a6fe0e6-f930-4fb8-9b2f-4c857a273dd9","name":"(Wrist_BikeGlove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7a730848-ab20-445d-b416-1a72cab49324","name":"(Jersey_Dev)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7a7d8a06-7982-4bcb-b7bd-4d03b48889b4","name":"(Hat_Aviator)","outfit_type":0,"outfit_set":34,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7b857a8c-92ad-4028-a2c2-b3c20cdab5f2","name":"(Shirt_Jersey)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7be2eafc-21ea-4266-ae64-8888c06798d6","name":"(Neck_Pearls)","outfit_type":103,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7c1552a6-bc36-46e6-884c-1de6b8111a92","name":"(Hat_ScoutRaccoon)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7db6b49f-3e2a-4da8-bdfa-e9ad9ebc5ba6","name":"(Eye_MirroredGlasses)","outfit_type":10,"outfit_set":26,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7dc6c916-b887-4929-93b5-dcea3022e6a9","name":"(Hat_TopHat_Contest)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7dd6f7b0-7ba0-429f-a04f-e32d3a79ee61","name":"(Hair_WavyShort)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"7e22a4bd-3e87-4d50-8843-9e2e3cf45285","name":"(Shirt_Turtleneck)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7LSm-xCYakmgcAr--Fn-JA","name":"(Shirt_Witch)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"7VQvz9sbzUWvGalxd9rISg","name":"(Shirt_BrideofFrank)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"8263a9ca-8c57-4e81-b3e3-24f2ca19a70a","name":"(Hat_Construction)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"83be5ba4-525a-4781-a5f6-839c22e4d1d3","name":"(Bowtie)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"843e3364-a743-4dfc-a990-0436e47b8c10","name":"(BikeJacket)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"84cd594c-1cd8-4b4d-8409-85c8fd5fb02a","name":"(PinkStollDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"86af3a97-18d8-4dda-a2f2-cfd3b4224254","name":"(Neck_GreenSequin_Bowtie)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"86dd9ff9-643f-4f11-8243-93ad948db4cc","name":"(Neck_NewYearsSash)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"87792b6e-1468-45e1-b28c-83f27ada7299","name":"(Eye_MirrorShades_PinkGreen) Variant","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"880a3cc0-7407-4b61-b759-f9dd890fe9e5","name":"(Hair_Bob)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"88739d79-aeb9-471a-b25b-776f46bba9f6","name":"(Wrist_AstronautGlove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"896c2491-2f96-4986-9cbd-b3b31ef5d8c5","name":"(EquestrianJacket)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"899f1d17-eee1-4b7d-a112-fbbba597e738","name":"(Hat_Squid)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"8aa79563-ace1-4ba7-ad0c-f3210a78142f","name":"(FemaleTShirt)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"8b065e2a-b106-4f11-b415-4c47ce87993e","name":"(Shirt_TruckerJacket)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"8b9f1413-e786-4a30-946c-9292f207875a","name":"(Hair_PulpFiction)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"8c51fc1a-b1ad-4002-a13c-4313abd92c7f","name":"(Hat_Visor)","outfit_type":0,"outfit_set":0,"drawer_index":2,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"8d10cc78-6b00-45f3-affb-205e9cc5b03f","name":"(Beard_Close)","outfit_type":20,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"8d17031a-c0a5-4145-92b7-1f0c2d8f0ce6","name":"(Hat_Scallywag)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"8d703940-224a-4b0c-a04d-f365550071e1","name":"(Hat_Hockey)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"8d97303c-5138-4da1-a74d-378da19d11cb","name":"(Neck_DeepSeaDiver)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"8df990d9-141a-4757-b4f2-138d5e68ab74","name":"(Hat_NewYearsHat)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"8fc7aaef-adec-4534-8f91-445e4e7095d5","name":"(Wrist_Conductor)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9073d6a0-3bc1-4c82-aeb3-d4ee195538b4","name":"(MaleCheerTorso)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9168b3f9-c7e5-40b6-bb2c-9e708596e675","name":"(Hat_Zombie)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"92302d9d-c527-418c-ac5d-1fa869727505","name":"(Hair_Part)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"94eb7ef3-528f-4f59-92a0-48b0ec287527","name":"(Wrist_Soccer)","outfit_type":200,"outfit_set":28,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"95ab7a7c-c35d-4da5-9955-0921064470b6","name":"(Hair_GordonGekko)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"967187b8-df20-4f4f-b48f-10b9ea75cedf","name":"(Shirt_Farmer)","outfit_type":101,"outfit_set":32,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"96bc2f9f-ce3a-4c4c-b92c-cfed4ed4594f","name":"(Hat_Beekeeper)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"973d84ee-5b88-475c-b579-a76c34a7c533","name":"(Wrist_TruckerJacket)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"973dc863-b9a5-40d8-8f15-4096e75bd2cb","name":"(Eye_MirrorShades_Red)Variant","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"97eae086-cfea-45ff-9b77-1d184256d493","name":"(Shirt_Wizard)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"988e4069-4341-428b-bbf5-023309891385","name":"(Neck_Bowtie_Silver_Sequin)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9a366f8b-ea5b-49ae-a98c-f6b282e9bf14","name":"(Dress_FormalDress_GoldSequin)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9b5406c4-8589-47e4-9f69-4fa4733615e7","name":"(Hat_Turkey)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9bf5d259-7774-4cbe-a90f-7f188cc0dce7","name":"(Mouth_GoateeThick)","outfit_type":20,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"9bf9d502-8a3c-4b24-9565-0a2c2d9864fd","name":"(Wrist_Knight_Gauntlet)","outfit_type":200,"outfit_set":1,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9c28e450-8e12-455c-a4c8-c0a2160d4190","name":"(Eye_Scuba)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9c34c2c1-07d7-4180-94a2-be8c9caaae84","name":"(LuchadorTorso)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9c8fc7f0-8f99-4aad-a34f-8d979f6ae352","name":"(ButtonTop)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9cdb4965-6f00-4304-afed-e301e73a2b3a","name":"(Hat_SmallBrim)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9d395a84-4342-4b1c-940c-213ec2eb56f9","name":"(Hat_QueenCrown)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9d68e9f4-6ea0-4717-bb78-f2783a14a6b6","name":"(Wrist_ScientistGlove)","outfit_type":200,"outfit_set":23,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9d9fadb6-97eb-480e-a224-4e0179082071","name":"(Hair_MeatballBuns)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"9e6397dd-b7b0-4d32-8c86-1910106a8478","name":"(Shirt_Raincoat)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9eb7f505-5afd-4b28-9886-7996d38c59c6","name":"(Wrist_CowboyGlove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"9o0gWLJjREG552gxRreT8A","name":"(Shirt_FigureSkaterCollar)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"a12f724f-4a73-4ab8-aad4-6bfc662b4dd6","name":"(Hair_Undercut)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"a16d59da-caa5-4388-af67-315bfcb16f64","name":"(Hat_Cloche)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"a302741d-313b-4682-82e5-ac76764e662c","name":"(YellowPoofyDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"a44766dc-19ad-4476-849c-5113a5faabd0","name":"(neck_royalCape)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"a4c26527-8f75-44da-b150-7aaf8ef739c1","name":"(Eye_MirrorShades)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"a5b747b8-c1a5-4bce-999f-e13dbe77bde6","name":"(Hat_DiscoBall)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"a61e86cd-f7e7-4ac7-a6e8-df269bc5cb01","name":"(Hat_Hibiscus)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"a628d62c-fa30-4405-bcbc-3e646e3a3434","name":"(Hat_Equestrian)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"a6cbfe76-534a-4655-a8a8-3fed13d001c7","name":"(Hair_BaldTop)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"a6f1825e-1647-4d06-85d7-ac2d139612c0","name":"(Shirt_DrumMajor)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"a96e168c-0c4d-4c70-9f8a-c8f07164ce99","name":"(Shirt_ScoutNeckerchief)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"a986ff33-9e16-4219-8ebb-2e7b4c772ca7","name":"(FieldHockey)","outfit_type":101,"outfit_set":17,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5","name":"(Shirt_Peacoat)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"abc25091-ed5f-4c72-9364-fffeef1bc239","name":"(Wrist_BishopGlove)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"AbYdU7O_9U-Lwg7zFVZzXQ","name":"(Hat_AntlerHeadband)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ad0bdec5-f767-42e7-99e0-221473464a85","name":"(Hat_Cowboy)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"af33bdb1-b7fa-4fe6-b37a-b1fe6a420879","name":"(Hat_Chef)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"AIpF1QUeMUWytpHYBDBAkg","name":"(Shirt_80sWindbreaker)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"akAhnN0cFkecj8q3spixkw","name":"(Hat_Headphones_Gold)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ASHnWS0h9kOJhbOUwxWKFQ","name":"(Shirt_TutorialGown_1)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"awOFE0_GSECr8GBNBo6a_w","name":"(Belt_PoolFloatie_AnimalHead)","outfit_type":102,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b02053b3-013f-42b0-8881-85f5be2b8cda","name":"(BlackCheerDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc","name":"(Shirt_TruckerOveralls)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b148cb1e-df81-442f-aea6-ab1727aad00e","name":"(Hair_ChunkyAfro)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"b1bfe0b4-1ff6-420f-acfc-2331d34246dc","name":"(Shirt_Angler)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b1c3ccc8-91d7-434e-b9cc-bd632fffc01b","name":"(Wrist_DeepSeaDiver)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b33dbeee-5bdd-443d-aa6a-761248054e08","name":"(Jersey_Team)","outfit_type":104,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":true}, + {"guid":"b537c30c-faef-4649-9a1f-be3985936f93","name":"(Hat_Lumberjack)","outfit_type":0,"outfit_set":3,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b6610b7b-3978-46fd-9af6-41b4568c9b4d","name":"(Shirt_Soccer)","outfit_type":101,"outfit_set":28,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b6rLwzD4NkKV7xKn9ZYVkA","name":"(Shirt_Hoodie)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b795c631-f075-4d32-ac87-c732b36cb932","name":"(Neck_StringLights)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b861e5f3-fc6d-43b3-9861-c1b45cb493a8","name":"(Hair_Bishop)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"b89be768-a169-4b24-8022-751e7b817865","name":"(Glasses_SciFiVisor)","outfit_type":10,"outfit_set":0,"drawer_index":1,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b904304b-cce0-466f-8569-c621eeadd4f7","name":"(Wrist_Boxing_Glove)","outfit_type":200,"outfit_set":14,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b95678aa-351a-4929-8a0e-0274a183eb18","name":"(Neck_NecklaceSilver)","outfit_type":103,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b99a89c7-38c5-4196-9862-4e1bf9cb0ce1","name":"(Hat_BoxingHelmet)","outfit_type":0,"outfit_set":14,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b9d08f9a-b23d-4d7e-978b-a0cd8adbfe9c","name":"(Neck_WinterCloak)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"b9e0f03b-2609-4911-82e9-1e324dbbe8be","name":"(Wrist_Scallywag_Bracelet)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ba84b23e-01ee-4668-a7ef-2e3e0eca2f23","name":"(CowboyTorso)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"bb54e0e9-5d90-41f3-94c0-8e82f1791af1","name":"(Hat_Firefighter)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"bb8602d4-f91f-4e35-9c88-1cdf3a04ff0a","name":"(Shirt_Bee)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"bc7299ac-be2b-404f-b8e6-402e2c0660f0","name":"(Eye_RoundGlasses)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"bf13fa1c-f991-4aaa-9402-aac109be9562","name":"(GoldMedal)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"bf9dc196-5727-4752-8126-7ad5fcd96d55","name":"(Neck_ScoutSash)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"bfd9c70b-9ad8-4f47-a840-c03231f9ad41","name":"(Hat_Bowler)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"BJ2XrGvl40iIl28IbTfbKg","name":"(Hair_FemaleCheerleader)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"BqpRbr_T1kO_YJqB1787kA","name":"(Neck_QpidQuiver)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"BR0mZKsKaE-t4R8CzEwuyg","name":"(Wrist_Thriller)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"bu9tb-K7NEiocvZY3o7ukw","name":"(Hat_80sShutterShades)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"c0e59f27-a13d-4bd9-b3b8-a38a360f649b","name":"(Shirt_Archer)","outfit_type":101,"outfit_set":2,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"c161dc73-6ce3-49c4-8d15-45e6911f84e4","name":"(SeaCaptainBeard)","outfit_type":20,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"c3745cee-d87e-4a23-b6e2-64ebf05c8b11","name":"(Shirt_DeepSeaDiver)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"c45ed7b8-99bd-4a4b-a9ff-e16edf5d7a18","name":"(Hair_ManPony)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"C4uuztTiYUe20PACMARksA","name":"(Shirt_Letterman)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"c6c08eb5-381a-4193-9722-80da95d62abe","name":"(BusinessTie)","outfit_type":100,"outfit_set":0,"drawer_index":1,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"c70005d5-6276-4a98-acb3-6a77bc19379a","name":"(Glasses_Red)","outfit_type":10,"outfit_set":0,"drawer_index":1,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"c855dcc3-96cb-470d-b159-d37a025a47d1","name":"(Hair_DutchBraid)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"c87239ca-a255-4b13-bbc5-1b38236fb4cc","name":"(Hat_Viking)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"c8a97aeb-e8dc-40ba-b6bc-a7c7be907381","name":"(Hair_Malibu)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af","name":"(Shirt_Harness)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"cbN5e-t_jEKa4hzir5JAxQ","name":"(Eye_TouristGlasses)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"cc96f8a5-bc5b-4f89-83b7-ecd53905ada7","name":"(Beard_Thick)","outfit_type":20,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"cca7ab3d-5609-4700-92fd-3280fcea9b77","name":"(CowboyStache)","outfit_type":20,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"CdeM6RJmPUumXtdmdM3RXA","name":"(Eye_SteampunkFemmeSpex)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ce250e13-8137-4f62-9e52-2b1cebe7d0ad","name":"(Hair_Punk)","outfit_type":2,"outfit_set":31,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"ce81210f-8eb8-4cd2-95d5-046d4da229c8","name":"(Wrist_Barbershop)","outfit_type":200,"outfit_set":35,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"cea5c141-32d8-4dae-80d9-6fb74834a79c","name":"(Hat_Farmer)","outfit_type":0,"outfit_set":32,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"cec9cfac-a45b-4edb-b26d-7453cc2bfaf2","name":"(Hat_Froggyheadband)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"CQuKKNpan0O7rhR1fr14-Q","name":"(Shirt_Bowling)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"CTcrvbo3OEepIV4oW8bx4w","name":"(Hair_RecedingHairline)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"d0a9262f-5504-46a7-bb10-7507503db58e","name":"(MaleTShirt)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"d2d3264b-fbf6-40b5-9f10-c85ec737d112","name":"(Hat_Archer)","outfit_type":0,"outfit_set":2,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"d3b8bd7b-ca9a-4f5d-b115-3659dc294a03","name":"(Hat_GreenVelvet_TopHat)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"d3d69bee-60bc-44a3-ad7c-65b3b69d1e59","name":"(Shirt_Dodgeball)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"d57cf97e-b5d2-4e47-8231-a226a161f6e3","name":"(Eye_MirrorShades_PinkClear)Variant","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"d6b0a1e7-e918-43e5-8191-3a8d9d01df7c","name":"(HockeyShirt)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"d766c8d3-76e8-4021-b0a4-24b78fb7eaba","name":"(TrackSuit)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"d8280c0c-d803-4513-be10-a0ba96d8821e","name":"(Hair_Rhianna)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"d84c0ff9-8fbe-4ed8-abf3-7996e81888ab","name":"(Hair_GiantAfro)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"d86e2e65-5d87-466d-b885-8c52ac2eeeb0","name":"(Shirt_SeaCaptain_Contest)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"d8f408f0-78f5-4e8a-b42a-e7b09632d1fd","name":"(PaintballVest)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"d960b47d-f8bd-4c2e-813c-a6a09876ea18","name":"(Hat_Scarecrow)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":true,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"da4e7b34-2095-4a9e-801e-4f409039e0dd","name":"(Hair_Buzz)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf","name":"(BabydollDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"dcf9012d-f9e9-4f15-872e-f130af3b2efa","name":"(Hat_Barbershop)","outfit_type":0,"outfit_set":35,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"de0ac50d-2adb-4114-bd2e-68953b13d706","name":"(Blazer)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"de19b239-3ed0-4f03-acb6-da69b5aa52ea","name":"(Shirt_Zombie)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"dedd2e98-2721-4de6-92f2-9d399f34e8fe","name":"(Shirt_BogMonsterShirt)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"df0e06c4-8ba6-4d7a-bfb2-3759d8fe88c7","name":"(Shirt_Skeleton_Hoodie)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"e042433a-40a2-4758-8074-3b9d407835ec","name":"(Shirt_FuschiaMarine)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"e13f6558-d49c-4314-820c-962ef48eb0c6","name":"(shirt_royalLord)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"e15b13a7-9e9a-4b32-ba2c-0cb31ed55a8c","name":"(Neck_Winter)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"e1a13235-e3b3-4b39-8e6f-e6449d4321b7","name":"(Eye_Pirate_Eyepatch)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"e2620c2c-c28e-45ce-a83c-0588329b9c81","name":"(Eye_MirrorShades_TealRed) Variant","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"e286863c-2967-4d00-b837-b49487b9484a","name":"(Hair_FauxHawk)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"e2ObiQFqCUOdKniKL9a1EA","name":"(Hat_QpidLaurel)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"e36bcd98-7e85-43fa-89f8-57e4ec33823a","name":"(Hair_Bob_Bangs)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"e41c32ac-61b7-4e59-9d0e-b231ebdc2a68","name":"(WristBand_Team_Emissive)","outfit_type":203,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":true}, + {"guid":"e49d5d78-157e-4bc8-b878-ff7c4963fdc4","name":"(Hair_Mohawk)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"e4ba395e-f291-4e7b-8193-45f4422f41ce","name":"(Shirt_Punk)","outfit_type":101,"outfit_set":31,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"e5b83dfc-b2e1-4dcb-a4ab-9d3a4c8a34ae","name":"(Hair_WavyLong)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"e60fccda-8711-463d-b8a3-7cd5e76903b2","name":"(Shirt_Conductor)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"e614df5d-3cfe-4bca-b9c2-30c267b9c94e","name":"(Hat_Knight_Helmet)","outfit_type":0,"outfit_set":1,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"e6216087-fb37-46f1-bda4-a88c4640f339","name":"(Hat_JackfrostHair)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":true,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"e9913b06-58cb-4b90-bd08-981a3f19f352","name":"(Shirt_Construction)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"E9VTiIVFPEa-OuQRto0e_Q","name":"(Shirt_SteamPunk)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"eada710d-ea0c-473f-b9c6-23e210607b24","name":"(Wrist_Golfer)","outfit_type":200,"outfit_set":27,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"eb830b12-c1b5-4796-b9cf-ccf0f0e1d4f1","name":"(Eye_MardiGrasEyes)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"eb9611c6-bb50-41a2-93e9-7f959815a846","name":"(Hair_DreadsLong)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"ec5255d5-edaf-4888-9fe1-e89ac0e0d300","name":"(AstronautTorso)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ec836d2a-b1b8-4566-b8eb-123ae2845956","name":"(Hat_FighterPilot)","outfit_type":0,"outfit_set":26,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ecc1dbe6-ca06-4564-b2a6-30956194d1e9","name":"(Wrist_WristBand)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"eceb7d49-be41-4228-86a9-2973d62e7135","name":"(Wrist_PirateCaptain)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"eda29bce-5a07-4439-a2a7-cafa11e2ed62","name":"(Belt_PaintballBelt)","outfit_type":102,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":true,"is_team_item":false}, + {"guid":"ef7771eb-c5f3-44fd-8c27-f581b035ef10","name":"(Hat_Bearhat)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ef84cb35-5e25-4aa6-82c9-17f01a5040c7","name":"(Hat_Beret)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"EJFGAW-TnUCaeD59parEHg","name":"(Belt_PoolFloatie_BirdHead)","outfit_type":102,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"eS_or4AnMka9M_bD7YN28g","name":"(Wrist_MagicalGirl)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"exQz9Zr3HEyOR9nDDrimlQ","name":"(Hat_LettermanShades)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f17a2f9e-1eab-44e8-b10a-1f53c5d0778c","name":"(Hair_Scientist)","outfit_type":2,"outfit_set":23,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"f218e2de-46fd-45b9-9e0e-670b6bb905e7","name":"(Hat_AstronautHelmet)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f277af6a-0807-4716-8948-1bee236ffb74","name":"(Hat_Ranger)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f41653f9-ae02-443c-aaf4-1c6f4e49035d","name":"(FlowerSwimsuit)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f47f81e7-fcdd-4687-9b99-013147bef30f","name":"(Neck_Safari)","outfit_type":100,"outfit_set":33,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f51a7ed5-52ea-485f-a430-303697f6fec9","name":"(RedBeltDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f527ffaf-da03-4519-82ed-46a9cb981dc3","name":"(Glasses_Square)","outfit_type":10,"outfit_set":0,"drawer_index":1,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f5c9743f-e77c-42f2-ba63-9843342cbf8e","name":"(Hat_SeaCaptain)","outfit_type":0,"outfit_set":0,"drawer_index":4,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f60e093f-33be-4527-b827-060adc77b1f4","name":"(Hat_ScoutGarrison)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f684d86f-4a75-4a9a-bc0a-cf58f36c91c5","name":"(Eye_MirrorShades_YellowBlue) Variant","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f6caeb89-dbb4-4471-92fe-100bf48cd5ce","name":"(Dress_WaitressDress)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f76b709c-b173-426e-81f2-279a67d2fde1","name":"(Shirt_Golfer)","outfit_type":101,"outfit_set":27,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"f9dd08f8-16d3-4c39-af4f-89f7bb6e80d3","name":"(Hair_UnderCutSheet)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"fb907eb2-91c8-45ff-84a5-462bb86ea7de","name":"(Shirt_Ranger)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"fbdca48d-4ae6-41ad-8c33-b70a9e33fd77","name":"(Hair_JackFrostNoCrystalsHair)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"fc96ea5d-b49b-479b-a963-92f1eab44b2c","name":"(Neck_ButterflyWings)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"fcfcaf63-deb4-45f7-b711-c051c9ea45cb","name":"(Hair_Bun)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"fe34adaf-47d3-435b-b2fd-b2fee05fceae","name":"(Shirt_Scientist)","outfit_type":101,"outfit_set":23,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ff15e25d-d467-47a8-9ecc-e415d5acb90c","name":"(Shirt_Boxer)","outfit_type":101,"outfit_set":14,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ff5c62b3-4f41-4acd-bfd8-8db00de4f36c","name":"(Shirt_Chef)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ff7e6d41-5c27-4516-930c-ad0e9a23cce2","name":"(Shirt_FighterPilot)","outfit_type":101,"outfit_set":26,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ffea7a65-613f-4835-921e-6dd15f357b7e","name":"(Hair_LongBangs)","outfit_type":2,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"FHKZvPFMLkO8iAIPPQzu1A","name":"(Shirt_50sDressValentine)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"GAWURQSqrEW4G5slrPMgZA","name":"(Shirt_BowlingSweater)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"gwLiwmqBIk6glFxvaTzxeA","name":"(Hat_SteamPunk)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"h_To9WQYdEusYQxlbMmMmQ","name":"(Neck_DraculaCape)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"hT7nSD7Ts06F_1SNlOMLwg","name":"(Hat_MagicalGirl)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"hTHM3Kjqy0ykqBD8UCRQJw","name":"(Shirt_Thriller)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"iai6P7dDTUq8S4hqQmY1YA","name":"(Shirt_SodaJerk)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"iBQ-v6xaqkipUlfzkSHEeQ","name":"(Neck_SteampunkSatchel)","outfit_type":102,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ilDgmey2ZUWlFiuEAN7YoQ","name":"(Wrist_IceQueen)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ImwG0snB9ECc_gqRRBoz6A","name":"(Neck_TreasureHunterBelt)","outfit_type":102,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"J6IQt4G1zUeN8v9Wn50BUw","name":"(Hat_HeadbandTied)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"jiBzrzDPkkuL6DqIFKpcCw","name":"(Neck_VampireHunter)","outfit_type":103,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"JTrfBXhIT02o2-ssP94zew","name":"(Hat_HeadBackBow)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"jUpFzO5MPEGTbS-E0a99Nw","name":"(Shirt_Tuxedo)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"JWanxvOHyESg9F_HlmTOfA","name":"(Hat_SteampunkFemme)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"JWfs9IiQs0Weu8MmyGpqLQ","name":"(Shirt_VampireHunter)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"kghEf_iMKUSxPO-h5iRhSQ","name":"(Eye_SquareWide)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"KNZ1DvCLC0WHyH4opYAXtw","name":"(Eye_SciFiVisor_Contest)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"KPmC_wyX_ECe7fdKwoLMGg","name":"(Hat_GraduationCap)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"LK8jt9zCuUGwLRccgOOvjA","name":"(Shirt_SteampunkFemme)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"Lli8-aqt1EmCmyXwBRQaYA","name":"(Hat_Mummy)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":true,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"m5Plrduzr0yj_9mTn1x3Vw","name":"(Shirt_GuardiansJacket)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"M9weaekvIUimiJSVJqZl-w","name":"(Hat_TutorialCap)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"mfFLpKQRE0anhrmeUvW0bA","name":"(Hat_LaurelCrown)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"MJkDYDH510izAab13ZfXQw","name":"(Shirt_MagicalGirl)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"MJQiQ3LATEOymxCC8wfSjw","name":"(Shirt_TutorialGown_2)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"n52um7A-b0utGxixd1ESvA","name":"(Shirt_Biker)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"O8VqC11xNkSKpDh6Omp_0w","name":"(Neck_TutorialSash_3)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"oRUh266ohkWQil14uxcEdQ","name":"(Shirt_Mummy)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"OTnfT-4NQU6Gd_iVPDx8eg","name":"(Hat_Frankenstein)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":true,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"oYvOo47DJE-DEPlpn5xNUw","name":"(Neck_FannyPack)","outfit_type":102,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"pNOfTDOgJUGQvGAlTjBoCg","name":"(Neck_ShortFatNecktie)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"pQqNvrhDJ0uuipKHA2qUOA","name":"(Shirt_Suspenders)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"PryEX4MEvkGKwST6dqJHjA","name":"(shirt_QpidToga)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"q7pSsRMNHkqEJmYHuhhG6g","name":"(Wrist_Grillmaster)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":true,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"QCtqxbvYOkan79YFugH_Cw","name":"(Shirt_Countess)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"QK6lBlX_wUCiSbJ9RkIwdA","name":"(Shirt_TreasureHunter)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"qxovV6Vym0ufL2cTCyKjOQ","name":"(Wrist_SteamPunk)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ReqOfohe6UamzaiHG1x3Ow","name":"(Hat_TruckerHat)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"rJ9zPPGdlk2i24sxLfkqng","name":"(Shirt_Snorkeler)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"s6mUIqiPCk6e57UYCx3SZA","name":"(Hat_Witch)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"sL7iHCnjyEuQKzrTvciEzQ","name":"(Hat_BrideofFrank)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":true,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"sOREgAGs-06CeGGsG-WIkw","name":"(Neck_SteamPunk)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"-SQLtL4rdEWTJdw2IroQCQ","name":"(Hat_TreasureHunter)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"t_66r_j5oUSh9awIPmdUKg","name":"(Neck_TreasureHunterBandolier)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"t9co2516nkuJO4LIJA1bJA","name":"(Wrist_Countess)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"TcgHS_0aPkehrBg7ytMOMQ","name":"(Eye_Clubmasters)","outfit_type":10,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"TjrFi6TFhUC5tQXWkZiK9A","name":"(Shirt_TreasureHunterTank)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"tkbHKZ9wu0W00VGeUbIaaw","name":"(Wrist_Frankenstein)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"tTKv7eRODka2KC_hsY2O3Q","name":"(Neck_Grillmaster)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"-twtjyBdQ02EAdOfBGTiEw","name":"(Mouth_VanDyke)","outfit_type":20,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":true,"uses_team_color":false,"is_team_item":false}, + {"guid":"U_H976qYRUmg9e2clsAflg","name":"(Shirt_GraduationGown)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"UcPo9SsF3EGTCBB2J-Efdw","name":"(Hat_Headphones)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"UGkQa4atNUe1cE5TN79s4Q","name":"(Belt_PoolFloatie_Unicorn)","outfit_type":102,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"uubY_Cn9A0-Ww4lsIRCF6w","name":"(Neck_DraculaMedal)","outfit_type":103,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"vDSlMnayzEqWXwT-lkcKSA","name":"(Shirt_Tourist)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"vP9EKTMXP0yni8U7uCz2-g","name":"(Neck_TutorialSash_2)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"vpP2ZvnL0Uy3n-I_Jv3eeQ","name":"(Hat_CowboyHairy)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"VQnoLstCyEGDPy1c38-GvA","name":"(Hat_BikerHelmet)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"W7AJ4UYlVkWF5nLbmpmRBg","name":"(Hat_IceQueen)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"Wua43ED71UqL71ATtyS0iQ","name":"(Hat_SodaJerk)","outfit_type":0,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"WuShlrJnHUKuscn8e6Rt2g","name":"(Neck_MagicalGirl)","outfit_type":100,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"wy2cfgb8mUqjMrIOwHH2ew","name":"(Wrist_Witch)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"zpjkkb1HtUORoPaODFuuxg","name":"(Wrist_Mummy)","outfit_type":200,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false}, + {"guid":"ZsggwOJ8XUOGfaJltM418g","name":"(Shirt_Frankenstein)","outfit_type":101,"outfit_set":0,"drawer_index":0,"hides_hair":false,"hides_hand":false,"uses_hair_color":false,"uses_team_color":false,"is_team_item":false} + ], + "prefab_guids": ["-SQLtL4rdEWTJdw2IroQCQ","-twtjyBdQ02EAdOfBGTiEw","0088603e-ec3b-4478-8694-e6fb1989b3f2","01689baf-405e-4b38-9f09-b624e03865b8","02077915-57d3-4e26-b4fe-8e0399cbb293","022ce375-ed02-479d-b87d-9d72d126bbd4","02a5b48c-abba-4385-967b-ba900424829e","033e328f-d9d2-4e53-af20-44f18e3ea208","03f8c394-28fa-4087-978b-8d108f0bd969","05ec4e87-088f-4281-a118-2e63c2585200","06306723-ca20-4aa6-b7b3-917113f41ac3","07205057-0804-41de-abe4-bd4027fee1df","0753d7a4-8247-4fca-a6fc-359c26086140","08d56627-4500-4724-8692-a23c96ddb3ec","09177621-9ecd-4f6a-b6a5-64490139141d","0ab5790f-a537-4306-ba66-5eb87c02ec7d","0ab6c95c-e157-48bd-a172-2642896b4ffc","0bc6bd1b-8b7e-4945-a1d6-c26b9c043318","0c537a84-9095-406f-8c87-1464f3a774ba","0d4e7ebe-efd9-4e68-9900-5645916e7596","0QOlT9c5xUuinpfZaoEe8Q","0yFzgVESokC3gFd3hrS8bg","102c625b-b988-4bf8-a2aa-a31ad7029cdc","1041d745-c5e9-4218-a3e1-198f5c9c418c","105a5100-cb57-410f-a26b-1ee21002f067","11e508a7-fe7d-470e-b270-2dba33e2f7a1","123de433-a005-4994-8a11-d2d84ad66b11","125a3a62-1005-47dc-9fd8-ee09ea803e9f","13e70c80-69a4-4ad5-90e5-4dd5e03da677","14ef6b00-debf-4a85-9755-b4d37df496d3","1596c113-aa3a-4f5c-b398-e5c3575548e7","17236d76-e3b6-42f6-9179-6fcf0479436f","1736a60b-6fb6-49bd-948a-fd3f3fc1be50","17aed0a0-70ed-49da-ad09-5bc4746f7718","18cd3470-2dd9-4a0a-98bd-eae2d5f5d522","18feb3c2-049f-4f91-9f4e-6b5fcb4ab70c","193a3bf9-abc0-4d78-8d63-92046908b1c5","1974dd48-aa44-434f-879a-eaff0c7c06e6","1a028850-64ee-40ab-b547-ebbcfa1b04b3","1a71064b-794f-40fa-9109-8ad36602b6e1","1b37d6cd-0a89-4604-a046-e5cff55b0cf6","1beaf288-b5a5-475c-8149-c8f3d1146083","1d27b674-f9e2-4ffc-9d8c-a58a1be06457","1e504fee-b593-4037-bd8f-b88025147991","1ebbfdfc-f72b-4d70-99d4-4d527f896aa7","1fcaf4fd-9012-407e-b691-fbc3ccf22421","1fd69ef8-0b74-4962-af5a-67f0bf0358f2","204bc384-e6d2-41b8-8ce9-0b26be5d85b7","20b424a9-861a-4d39-a583-22d7b7348c14","21599b51-c50f-43d8-ac5f-62c30cd02ca5","21caa68e-c3fa-474c-af5e-af1e742b7a60","2296ed0d-df56-4d46-b33a-aae9230a47fc","22ef1089-d073-4fe8-b038-6f34adf0b85f","22pyI3Kz-UullhcebC6DLQ","241506f6-bf88-4b46-b5fe-513a225421f4","24a240f4-1574-420b-b898-a7e91f170759","24b8b757-4168-4b3b-ba11-46f29d19044d","24c4404a-839f-46bd-98b6-6848d747a98b","24d94673-f394-42e6-a3c8-276f8b8aa722","25d4c652-1730-4925-925e-31a290a2272a","274cb9b2-2f59-47ea-9a8d-a5b656d148c6","2786fc22-3d6c-440d-afcc-15890c061577","27cdecd2-88f1-40a8-80a9-87abd827c3c5","27e742bf-3081-4274-ba70-301f0edeb4f1","28374ebc-4050-4ea0-b3b6-41ebe75cedf7","28e5dd74-7963-42cf-aa68-f8005aaf312e","293e7ad3-01c9-43af-96e3-06c34145ff12","2b2eb9e5-d52e-4004-bdb2-0bcf6910992d","2b7f4dae-51da-4af0-b009-12d6005c85c6","2bd53190-46f8-4956-9388-87c5a04efc4f","2c3773a5-a0b4-41c5-a4df-6572dff516c9","2c679f89-c76e-4cfb-94e9-448c8fd44d55","2caHvVQ1vECrPv-YcdxdyA","2cb4f372-3372-4583-8b57-c4e3988e3c28","2dFMy4zc7ky_Fd5fzfgInw","2e59d8d0-91a0-4449-bfdc-a5d663fd9343","2e5afc40-7e25-418c-8b1b-316b8ef479a7","2f8518fc-c989-40de-98b4-c6f09b304166","2faa26b6-f7c3-4b0d-8f70-1f9454e64a54","30380607-8b54-4b63-81d3-cb3f08e452c1","3044adce-90c0-4f66-81f5-39c4fca86fdf","34144f4c-f6ef-49e5-a286-1781e82e6782","34654303-9760-447f-8614-1c26506db994","348ac48b-986e-4d42-a0f7-1aea16b88271","34d45669-b319-4c7d-bd05-078961d70d0b","350c5c1e-318b-4233-a7e5-1f49909d8e65","35f60d27-22cf-4e17-8f39-c9e3a9fa2291","3757149b-f478-4975-81e5-d808a1bed8d5","37668d3d-c9f4-4bd6-81d4-a4f64ba2d61b","38b38e24-d636-41c6-b80e-a285ae3106dc","39278302-b52a-48a8-a566-ed756a5fa1a9","3a790be3-2937-44d4-be01-b5d65353bd3d","3af4c506-d49c-4126-9437-1d3bb1658863","3b00c71f-79c2-4a04-9fc0-a23b4c85562f","3bb50a88-09ae-48c7-a42c-df5879680eff","3c9acb9e-3353-4359-aa5a-ae9512b086b4","3d5184e1-0201-4476-bf4b-4eb28190de62","3d9d52cb-6a3d-436b-90e7-e76f238329ee","3dc003c5-428b-47d6-80fb-6520b63119e8","3dec4865-cf23-4c19-a460-dd2db80ee851","3df394de-9c3e-4141-aba4-12eac4742102","3e139dd9-4757-43a8-8546-8da70dc17f0a","3e3f5ed5-d810-4054-b648-d93167b90033","3R_zf8xliEiurVU8GwaItg","4025e4c4-abaa-4729-a2a3-a7175313e5ed","40528de7-38a3-4a7c-8f93-6d3bfa5573f2","40734c4d-e1fe-426c-8921-930f7463d8e8","418db0e0-c7af-40c2-9cac-d461ce41e210","418ed21e-766f-4c69-9d57-6f866dcc7feb","42aa7888-f0b9-4584-9cc1-966585c6b36e","4308da04-af84-40bb-845a-7a9e1a2726ec","43354e59-4938-44bc-81e0-4fba47d0ac12","439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee","459ec62c-67db-4b72-9e29-0ad3ecd0b737","45eaab67-19c2-4601-8f80-3565a4dceba4","45f5e714-8a5f-4385-a97f-675066167011","473c672d-2b79-48a2-a49d-fd68160c5bde","474db08a-d4dc-4625-9f5a-1a6ae0b70827","47629ed1-5378-427c-ad61-4b44d2fe90cc","4763436c-2dcc-482a-bf37-a6b16d7aae6e","49397e16-1027-4286-9bc4-b59eae565ff0","4ce7a70f-15a0-4cad-98ee-5cbb40827338","4d507dfa-4a99-4ac0-8537-229e9dc0eb4a","4d533b3a-b5fa-446a-842d-92798ad5b493","4e42e02e-33fd-4ff9-b74e-9bde26253c35","4fcb5b84-13a9-43e6-9680-72b17874ae53","50c9c6f8-2963-4ef3-95d5-e999a898269f","5154be09-bb8a-4917-95a0-51c91b6a4e09","515dfc36-7f7b-47bc-abee-380d68f41be6","529015c7-3b4a-4a73-b2c3-33103339b6e3","52VsI_lKlkSUAOJlfDnyrQ","57552652-d902-43b7-a68e-5c7da87582bc","5800d2b5-75b8-442f-8b10-63bd3d73a1b8","587ca2bb-dd42-42bf-833f-4e2a97349866","59d9560d-e5af-4205-b860-3afea1cc31a9","5a38f864-7d1c-4989-8558-40b9668f78ef","5ac25e5a-8c8e-445c-82a4-7272baec1eb5","5accb7dc-90a3-46ee-bad2-8fd1861d50f1","5b01eaa3-0cac-40c0-b72a-b3f6a868ae0c","5b3dc472-773e-4714-a9d0-7e7bbf6bc695","5b6535f0-86cd-417a-bcce-a1ace9a5f260","5bde836f-9343-4934-b01a-606f707020ef","5cd08cfb-c729-4c30-96d9-6a99bb934d91","5d1c42a0-7e36-431a-8e78-a8b74046d153","5e3929d3-d291-4e91-a0ba-2ef203e407d3","5e9bbda6-d681-42b1-9cc4-5758acf34bc0","5f1bc3c2-dfbb-4258-9b10-608535e34db2","5f492f8f-ab61-484a-9587-747132b54309","5f600892-aa67-484a-afc7-14c219d89c52","5fwjFQoC_Uu9ChvF6uNGZw","5PUja-WX00Sme9yBwvDq-g","60067e91-18b8-43ab-ae20-a8ea74c757bf","61ba3c90-c81e-4deb-bc79-50c0f1fe3e83","62ce4109-8dee-4895-bf1b-bfa143db4c7e","6427bd57-fcb2-420f-8b3e-d1da43470b84","64ad8789-1645-45f7-819a-a413ff7c9622","657efe89-620a-46a2-8b58-cafd855c7cd5","664485e9-a159-4fcb-b32c-2b114b4a1218","66f8f55d-cad4-4afb-93e8-b6bdf04b37b3","67117b3d-0669-4cf3-8a4f-066cabd4d8b1","6930ce13-4be4-4ab9-9817-667bd261ffc3","6a98bbb6-bbe0-4dc3-97a9-e253dc506a2b","6b337a35-95d9-4b3a-9891-d9fbb06262aa","6b9e022c-0b68-48fd-8eca-da8573c18900","6d36091e-7083-4e3d-8729-94612602c8cb","6d48c545-22bb-46c1-a29d-0a38af387143","6d815b35-6f68-4ed4-817d-70f141e1a571","6dd261d8-9806-4dac-81c4-a58f6d6a7f9a","6QT99hx6DE6t9cKAs61i3w","6RhwWExXekaYpK0NGHSXGQ","703f1987-2b31-4b6f-bab7-1939b1105ac0","71014872-d879-4858-9d68-58da3c673d8b","7134c60b-0640-463a-8d97-caa9184bef93","71921831-ba6f-408b-a00e-2fd97663636f","71fd5353-b439-4a23-b471-bfb99b8154d7","739fd42a-8a8c-44a5-afa3-e0974802c6ae","73a119ab-d2c8-4da9-98b2-29edbb38e2c2","73c0e2ba-cba1-482a-89d5-1508997aceeb","74365234-cb72-409d-8cf5-9a7209f707e1","745c5110-104e-4b37-aabf-7c690fd54811","74b91f2b-d4d3-4e41-8946-b1296ab7a773","74c65554-0d15-409b-b9a8-0b2620a7d7e2","74e6878f-94e0-41f2-93ea-de6412238385","75-HXZBOAEiDBqclnOn8YQ","7553d851-1986-4325-870d-e425dfb7d007","76369aef-aeda-46d2-996a-cd00594d0543","77c37481-a185-47ff-af55-65f9fdfcf9e5","77d3c585-4928-4471-a425-89036efe7299","79b90274-6eec-4664-acfb-4a123334661e","7a6fe0e6-f930-4fb8-9b2f-4c857a273dd9","7a730848-ab20-445d-b416-1a72cab49324","7a7d8a06-7982-4bcb-b7bd-4d03b48889b4","7b857a8c-92ad-4028-a2c2-b3c20cdab5f2","7be2eafc-21ea-4266-ae64-8888c06798d6","7c1552a6-bc36-46e6-884c-1de6b8111a92","7db6b49f-3e2a-4da8-bdfa-e9ad9ebc5ba6","7dc6c916-b887-4929-93b5-dcea3022e6a9","7dd6f7b0-7ba0-429f-a04f-e32d3a79ee61","7e22a4bd-3e87-4d50-8843-9e2e3cf45285","7LSm-xCYakmgcAr--Fn-JA","7VQvz9sbzUWvGalxd9rISg","8263a9ca-8c57-4e81-b3e3-24f2ca19a70a","83be5ba4-525a-4781-a5f6-839c22e4d1d3","843e3364-a743-4dfc-a990-0436e47b8c10","84cd594c-1cd8-4b4d-8409-85c8fd5fb02a","86af3a97-18d8-4dda-a2f2-cfd3b4224254","86dd9ff9-643f-4f11-8243-93ad948db4cc","87792b6e-1468-45e1-b28c-83f27ada7299","880a3cc0-7407-4b61-b759-f9dd890fe9e5","88739d79-aeb9-471a-b25b-776f46bba9f6","896c2491-2f96-4986-9cbd-b3b31ef5d8c5","899f1d17-eee1-4b7d-a112-fbbba597e738","8aa79563-ace1-4ba7-ad0c-f3210a78142f","8b065e2a-b106-4f11-b415-4c47ce87993e","8b9f1413-e786-4a30-946c-9292f207875a","8c51fc1a-b1ad-4002-a13c-4313abd92c7f","8d10cc78-6b00-45f3-affb-205e9cc5b03f","8d17031a-c0a5-4145-92b7-1f0c2d8f0ce6","8d703940-224a-4b0c-a04d-f365550071e1","8d97303c-5138-4da1-a74d-378da19d11cb","8df990d9-141a-4757-b4f2-138d5e68ab74","8fc7aaef-adec-4534-8f91-445e4e7095d5","9073d6a0-3bc1-4c82-aeb3-d4ee195538b4","9168b3f9-c7e5-40b6-bb2c-9e708596e675","92302d9d-c527-418c-ac5d-1fa869727505","94eb7ef3-528f-4f59-92a0-48b0ec287527","95ab7a7c-c35d-4da5-9955-0921064470b6","967187b8-df20-4f4f-b48f-10b9ea75cedf","96bc2f9f-ce3a-4c4c-b92c-cfed4ed4594f","973d84ee-5b88-475c-b579-a76c34a7c533","973dc863-b9a5-40d8-8f15-4096e75bd2cb","97eae086-cfea-45ff-9b77-1d184256d493","988e4069-4341-428b-bbf5-023309891385","9a366f8b-ea5b-49ae-a98c-f6b282e9bf14","9b5406c4-8589-47e4-9f69-4fa4733615e7","9bf5d259-7774-4cbe-a90f-7f188cc0dce7","9bf9d502-8a3c-4b24-9565-0a2c2d9864fd","9c28e450-8e12-455c-a4c8-c0a2160d4190","9c34c2c1-07d7-4180-94a2-be8c9caaae84","9c8fc7f0-8f99-4aad-a34f-8d979f6ae352","9cdb4965-6f00-4304-afed-e301e73a2b3a","9d395a84-4342-4b1c-940c-213ec2eb56f9","9d68e9f4-6ea0-4717-bb78-f2783a14a6b6","9d9fadb6-97eb-480e-a224-4e0179082071","9e6397dd-b7b0-4d32-8c86-1910106a8478","9eb7f505-5afd-4b28-9886-7996d38c59c6","9o0gWLJjREG552gxRreT8A","a12f724f-4a73-4ab8-aad4-6bfc662b4dd6","a16d59da-caa5-4388-af67-315bfcb16f64","a302741d-313b-4682-82e5-ac76764e662c","a44766dc-19ad-4476-849c-5113a5faabd0","a4c26527-8f75-44da-b150-7aaf8ef739c1","a5b747b8-c1a5-4bce-999f-e13dbe77bde6","a61e86cd-f7e7-4ac7-a6e8-df269bc5cb01","a628d62c-fa30-4405-bcbc-3e646e3a3434","a6cbfe76-534a-4655-a8a8-3fed13d001c7","a6f1825e-1647-4d06-85d7-ac2d139612c0","a96e168c-0c4d-4c70-9f8a-c8f07164ce99","a986ff33-9e16-4219-8ebb-2e7b4c772ca7","ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5","abc25091-ed5f-4c72-9364-fffeef1bc239","AbYdU7O_9U-Lwg7zFVZzXQ","ad0bdec5-f767-42e7-99e0-221473464a85","af33bdb1-b7fa-4fe6-b37a-b1fe6a420879","AIpF1QUeMUWytpHYBDBAkg","akAhnN0cFkecj8q3spixkw","ASHnWS0h9kOJhbOUwxWKFQ","awOFE0_GSECr8GBNBo6a_w","b02053b3-013f-42b0-8881-85f5be2b8cda","b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc","b148cb1e-df81-442f-aea6-ab1727aad00e","b1bfe0b4-1ff6-420f-acfc-2331d34246dc","b1c3ccc8-91d7-434e-b9cc-bd632fffc01b","b33dbeee-5bdd-443d-aa6a-761248054e08","b537c30c-faef-4649-9a1f-be3985936f93","b6610b7b-3978-46fd-9af6-41b4568c9b4d","b6rLwzD4NkKV7xKn9ZYVkA","b795c631-f075-4d32-ac87-c732b36cb932","b861e5f3-fc6d-43b3-9861-c1b45cb493a8","b89be768-a169-4b24-8022-751e7b817865","b904304b-cce0-466f-8569-c621eeadd4f7","b95678aa-351a-4929-8a0e-0274a183eb18","b99a89c7-38c5-4196-9862-4e1bf9cb0ce1","b9d08f9a-b23d-4d7e-978b-a0cd8adbfe9c","b9e0f03b-2609-4911-82e9-1e324dbbe8be","ba84b23e-01ee-4668-a7ef-2e3e0eca2f23","bb54e0e9-5d90-41f3-94c0-8e82f1791af1","bb8602d4-f91f-4e35-9c88-1cdf3a04ff0a","bc7299ac-be2b-404f-b8e6-402e2c0660f0","bf13fa1c-f991-4aaa-9402-aac109be9562","bf9dc196-5727-4752-8126-7ad5fcd96d55","bfd9c70b-9ad8-4f47-a840-c03231f9ad41","BJ2XrGvl40iIl28IbTfbKg","BqpRbr_T1kO_YJqB1787kA","BR0mZKsKaE-t4R8CzEwuyg","bu9tb-K7NEiocvZY3o7ukw","c0e59f27-a13d-4bd9-b3b8-a38a360f649b","c161dc73-6ce3-49c4-8d15-45e6911f84e4","c3745cee-d87e-4a23-b6e2-64ebf05c8b11","c45ed7b8-99bd-4a4b-a9ff-e16edf5d7a18","C4uuztTiYUe20PACMARksA","c6c08eb5-381a-4193-9722-80da95d62abe","c70005d5-6276-4a98-acb3-6a77bc19379a","c855dcc3-96cb-470d-b159-d37a025a47d1","c87239ca-a255-4b13-bbc5-1b38236fb4cc","c8a97aeb-e8dc-40ba-b6bc-a7c7be907381","ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af","cbN5e-t_jEKa4hzir5JAxQ","cc96f8a5-bc5b-4f89-83b7-ecd53905ada7","cca7ab3d-5609-4700-92fd-3280fcea9b77","CdeM6RJmPUumXtdmdM3RXA","ce250e13-8137-4f62-9e52-2b1cebe7d0ad","ce81210f-8eb8-4cd2-95d5-046d4da229c8","cea5c141-32d8-4dae-80d9-6fb74834a79c","cec9cfac-a45b-4edb-b26d-7453cc2bfaf2","CQuKKNpan0O7rhR1fr14-Q","CTcrvbo3OEepIV4oW8bx4w","d0a9262f-5504-46a7-bb10-7507503db58e","d2d3264b-fbf6-40b5-9f10-c85ec737d112","d3b8bd7b-ca9a-4f5d-b115-3659dc294a03","d3d69bee-60bc-44a3-ad7c-65b3b69d1e59","d57cf97e-b5d2-4e47-8231-a226a161f6e3","d6b0a1e7-e918-43e5-8191-3a8d9d01df7c","d766c8d3-76e8-4021-b0a4-24b78fb7eaba","d8280c0c-d803-4513-be10-a0ba96d8821e","d84c0ff9-8fbe-4ed8-abf3-7996e81888ab","d86e2e65-5d87-466d-b885-8c52ac2eeeb0","d8f408f0-78f5-4e8a-b42a-e7b09632d1fd","d960b47d-f8bd-4c2e-813c-a6a09876ea18","da4e7b34-2095-4a9e-801e-4f409039e0dd","da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf","dcf9012d-f9e9-4f15-872e-f130af3b2efa","de0ac50d-2adb-4114-bd2e-68953b13d706","de19b239-3ed0-4f03-acb6-da69b5aa52ea","dedd2e98-2721-4de6-92f2-9d399f34e8fe","df0e06c4-8ba6-4d7a-bfb2-3759d8fe88c7","e042433a-40a2-4758-8074-3b9d407835ec","e13f6558-d49c-4314-820c-962ef48eb0c6","e15b13a7-9e9a-4b32-ba2c-0cb31ed55a8c","e1a13235-e3b3-4b39-8e6f-e6449d4321b7","e2620c2c-c28e-45ce-a83c-0588329b9c81","e286863c-2967-4d00-b837-b49487b9484a","e2ObiQFqCUOdKniKL9a1EA","e36bcd98-7e85-43fa-89f8-57e4ec33823a","e41c32ac-61b7-4e59-9d0e-b231ebdc2a68","e49d5d78-157e-4bc8-b878-ff7c4963fdc4","e4ba395e-f291-4e7b-8193-45f4422f41ce","e5b83dfc-b2e1-4dcb-a4ab-9d3a4c8a34ae","e60fccda-8711-463d-b8a3-7cd5e76903b2","e614df5d-3cfe-4bca-b9c2-30c267b9c94e","e6216087-fb37-46f1-bda4-a88c4640f339","e9913b06-58cb-4b90-bd08-981a3f19f352","E9VTiIVFPEa-OuQRto0e_Q","eada710d-ea0c-473f-b9c6-23e210607b24","eb830b12-c1b5-4796-b9cf-ccf0f0e1d4f1","eb9611c6-bb50-41a2-93e9-7f959815a846","ec5255d5-edaf-4888-9fe1-e89ac0e0d300","ec836d2a-b1b8-4566-b8eb-123ae2845956","ecc1dbe6-ca06-4564-b2a6-30956194d1e9","eceb7d49-be41-4228-86a9-2973d62e7135","eda29bce-5a07-4439-a2a7-cafa11e2ed62","ef7771eb-c5f3-44fd-8c27-f581b035ef10","ef84cb35-5e25-4aa6-82c9-17f01a5040c7","EJFGAW-TnUCaeD59parEHg","eS_or4AnMka9M_bD7YN28g","exQz9Zr3HEyOR9nDDrimlQ","f17a2f9e-1eab-44e8-b10a-1f53c5d0778c","f218e2de-46fd-45b9-9e0e-670b6bb905e7","f277af6a-0807-4716-8948-1bee236ffb74","f41653f9-ae02-443c-aaf4-1c6f4e49035d","f47f81e7-fcdd-4687-9b99-013147bef30f","f51a7ed5-52ea-485f-a430-303697f6fec9","f527ffaf-da03-4519-82ed-46a9cb981dc3","f5c9743f-e77c-42f2-ba63-9843342cbf8e","f60e093f-33be-4527-b827-060adc77b1f4","f684d86f-4a75-4a9a-bc0a-cf58f36c91c5","f6caeb89-dbb4-4471-92fe-100bf48cd5ce","f76b709c-b173-426e-81f2-279a67d2fde1","f9dd08f8-16d3-4c39-af4f-89f7bb6e80d3","fb907eb2-91c8-45ff-84a5-462bb86ea7de","fbdca48d-4ae6-41ad-8c33-b70a9e33fd77","fc96ea5d-b49b-479b-a963-92f1eab44b2c","fcfcaf63-deb4-45f7-b711-c051c9ea45cb","fe34adaf-47d3-435b-b2fd-b2fee05fceae","ff15e25d-d467-47a8-9ecc-e415d5acb90c","ff5c62b3-4f41-4acd-bfd8-8db00de4f36c","ff7e6d41-5c27-4516-930c-ad0e9a23cce2","ffea7a65-613f-4835-921e-6dd15f357b7e","FHKZvPFMLkO8iAIPPQzu1A","GAWURQSqrEW4G5slrPMgZA","gwLiwmqBIk6glFxvaTzxeA","hT7nSD7Ts06F_1SNlOMLwg","hTHM3Kjqy0ykqBD8UCRQJw","h_To9WQYdEusYQxlbMmMmQ","iai6P7dDTUq8S4hqQmY1YA","iBQ-v6xaqkipUlfzkSHEeQ","ilDgmey2ZUWlFiuEAN7YoQ","ImwG0snB9ECc_gqRRBoz6A","J6IQt4G1zUeN8v9Wn50BUw","jiBzrzDPkkuL6DqIFKpcCw","JTrfBXhIT02o2-ssP94zew","jUpFzO5MPEGTbS-E0a99Nw","JWanxvOHyESg9F_HlmTOfA","JWfs9IiQs0Weu8MmyGpqLQ","kghEf_iMKUSxPO-h5iRhSQ","KNZ1DvCLC0WHyH4opYAXtw","KPmC_wyX_ECe7fdKwoLMGg","LK8jt9zCuUGwLRccgOOvjA","Lli8-aqt1EmCmyXwBRQaYA","m5Plrduzr0yj_9mTn1x3Vw","M9weaekvIUimiJSVJqZl-w","mfFLpKQRE0anhrmeUvW0bA","MJkDYDH510izAab13ZfXQw","MJQiQ3LATEOymxCC8wfSjw","n52um7A-b0utGxixd1ESvA","O8VqC11xNkSKpDh6Omp_0w","oRUh266ohkWQil14uxcEdQ","OTnfT-4NQU6Gd_iVPDx8eg","oYvOo47DJE-DEPlpn5xNUw","pNOfTDOgJUGQvGAlTjBoCg","pQqNvrhDJ0uuipKHA2qUOA","PryEX4MEvkGKwST6dqJHjA","q7pSsRMNHkqEJmYHuhhG6g","QCtqxbvYOkan79YFugH_Cw","QK6lBlX_wUCiSbJ9RkIwdA","qxovV6Vym0ufL2cTCyKjOQ","ReqOfohe6UamzaiHG1x3Ow","rJ9zPPGdlk2i24sxLfkqng","s6mUIqiPCk6e57UYCx3SZA","sL7iHCnjyEuQKzrTvciEzQ","sOREgAGs-06CeGGsG-WIkw","t9co2516nkuJO4LIJA1bJA","TcgHS_0aPkehrBg7ytMOMQ","TjrFi6TFhUC5tQXWkZiK9A","tkbHKZ9wu0W00VGeUbIaaw","tTKv7eRODka2KC_hsY2O3Q","t_66r_j5oUSh9awIPmdUKg","UcPo9SsF3EGTCBB2J-Efdw","UGkQa4atNUe1cE5TN79s4Q","uubY_Cn9A0-Ww4lsIRCF6w","U_H976qYRUmg9e2clsAflg","vDSlMnayzEqWXwT-lkcKSA","vP9EKTMXP0yni8U7uCz2-g","vpP2ZvnL0Uy3n-I_Jv3eeQ","VQnoLstCyEGDPy1c38-GvA","W7AJ4UYlVkWF5nLbmpmRBg","Wua43ED71UqL71ATtyS0iQ","WuShlrJnHUKuscn8e6Rt2g","wy2cfgb8mUqjMrIOwHH2ew","zpjkkb1HtUORoPaODFuuxg","ZsggwOJ8XUOGfaJltM418g","_OWVy3z6iU-M3-zbQgSLig","_qXaNbm4yEeyalo372QbvA"], + "mask_guids": ["005699fe-2c69-4bec-831f-031fe8ad7f0a","00ac4e37-6eac-4ed5-b46c-2c075038bc3f","0189ec4c-0389-4ebc-b9ba-32303a6341c2","018a5c07-e956-457d-a540-a5e2cd68da09","0272eb19-fc0e-4ad6-8db9-cc0cda2528b0","0440f08f-ef1d-49d8-942b-523056e8bb45","05cff8b8-d437-485a-b4b8-41d35becd022","05f0ee6e-c824-470e-9178-5ed576c6fe0c","0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd","0dabda0d-842c-4d39-a6b9-a8ca3ec20825","0dd6596c-b4ea-4d55-a219-e1f44e4a376d","0e42108e-3b08-49b4-86c0-7984719e8380","0ec6628c-a106-4452-af82-105a3e3a9f2e","0f36bb97-c61b-4281-929f-ff1d0d11be86","0f49ac81-63bd-4845-8bdc-1944181a8aa3","0p5N9U5qokKdQhW07yYLGQ","0XPd5RzNJEepHztqwAznHQ","10fYSIBytk-yUjveDa407w","15099be7-38b7-4b0b-ac13-1bacedbf5fb2","15edb8d7-2ff6-4170-a7e6-acd9e965cab5","1695608b-e9cc-4a03-b56d-0f09d2804379","1ba65dd5-9df9-4ff3-be96-f83a7abf67b3","1e3195ab-f7f6-41f4-a5ec-378d09ecdf69","1f18670d-df52-4d2b-8009-a32cf7c67b40","1fede91a-434e-49a5-882e-e6db107112a8","1hVOI7IuY0mKvtr2PZXLww","27a5df72-4095-4837-bf24-cdd3d95a43e9","2c0d912d-c184-4eab-8ee4-73b90ba7b0a6","2c8924aa-68f8-4912-9759-18992f72f08a","2d7361d2-31a2-404b-90da-10e89114e1f2","2ee3944d-e9fb-4959-973e-570a7fc6cc57","2UNd6eViFUO359xjSiuu2A","3248a3ec-50d1-4c0a-96ae-25eb9d8ed622","3259238f-4a9e-4e06-99de-6e7324fa0894","3290f148-1bd1-4ffd-a947-61fd0529af05","32d91d32-b102-4093-927a-6dc3ac2e9e86","33ee376a-586e-4aff-a7e4-4213445a3e92","3485a30d-27f8-450b-b5aa-1ff3cd227d19","3b825377-d2eb-41e4-a736-b107f88f62d6","3ba69a39-a4e7-4505-bae7-8db31360ec1c","3d971439-41d1-43a2-8434-704f9ec8d906","3DdSzSqV1UG5AtPr8ro3Kg","3drRcfyKkku0qGwCy16m9Q","3pdsIrhM9EW-y-LNnjbFxg","435eba0d-d8f9-43cc-91aa-2959d63c6fa7","464fcf20-361f-4dc9-b074-150c3bc27523","47514b9b-0d17-4727-97ed-dab67e7fe031","4cc0ea9f-65af-486f-8319-02b85ab5197f","4e378419-d043-4e86-ad0e-66288ac21cf4","4EHIyZkOOEqnM-PuD_a5xw","4zPm9miF5UOouEWFpOxYZg","50NUFrDxbUKmXbyAHBenvQ","51a1809d-becd-42e0-8dfb-188d6f07ba1c","52053afd-9f12-4fec-a358-7eb3fe8c19bb","54778d93-2cff-48db-b25b-65c08b0e5be8","54a5c055-f757-4c00-a1e1-8b0b6552c608","556f876a-70da-4104-b792-fca8ad67be0c","5659ce2c-9cb0-49da-8c39-4b7df0f8deec","56a92c8d-af53-413e-929e-4a9a3cfad780","5814a331-6155-4df4-b921-3ffc6059e44c","5a6db6ba-2a0f-4399-b537-dd78db94bf90","5b8d9490-e5bc-480e-91bc-65d1797b52ad","5fd9e83a-cfaf-4c98-bd31-91a7484c26dd","5RUG6i2Mx0-55IO-IlhUdQ","6094370d-5e28-473a-b422-f3f1c75d5db8","65b89eae-347e-4eff-9ff3-7f25cace0125","65ed43ff-dfc4-4cab-b0b9-77dc94165e24","66cc536d-8cb5-4262-b7f0-49a33b97ea10","6b38ab79-142b-446e-978c-ad829ade8823","6ce21c65-f4e9-41ac-be94-8eb1a08d5c76","6Jv4AJGBYUGP_n4sDcz2Kw","6nmHu9zj8k-tEz0tBHblnQ","6UBZuZQBG0eZtNmty2IGdA","6WVXb20giEWyZbW2gWGz_w","71fd06e3-cac8-4923-a09c-ef91183712d2","73fc2e2a-a036-410b-bafa-3a07658245b5","7634b7ef-9607-497a-b41f-c0191dc75998","7660e11a-7ef0-43d3-bc02-76b3a4aab064","7869b2a5-cd89-4033-b9f7-4f1451ecb47b","7a7bc314-4ffd-4ac8-9ed6-6ca73589c132","7ac81d60-05b8-4a85-982c-f69213a93818","7b187c06-7ba2-4f3c-bca9-fdf98146f385","7c4d447c-6594-4bd8-9510-12ad683ab116","7d040391-03f7-4b3d-bcfd-a1278ff459c9","7e688b30-ed13-4790-9ad8-c49885d1b7e8","7ef7d0cf-99f1-4872-9d3d-8230cc9fa368","8212812b-e7e7-4f6d-aa27-d1112af45473","83158223-e268-4036-8a6a-218a8430eb2c","84c016ad-133f-45e3-a412-cac35612540b","8768aa33-b014-4062-b323-b146dd7d78d2","8ec5de40-73fc-4f09-9bf3-ddf4701107bc","8eef8837-9141-4088-835a-b2f47f4126f8","8ffce0d6-d03e-4097-bc46-09b8894bc1d3","8kdJkmwNM0WQmMVrb8fK8A","9009bcdf-b6f7-4dae-b2ec-895511d118ff","90e0da8b-1941-4ca8-b7e5-b4e31921873c","91a451c1-b285-4c48-b14d-59ded8cc006f","92714eac-cc07-4f74-a6f5-cfbbadd07e06","93987d32-7a6e-45df-af56-76f912969ce7","944d934d-5e21-42d7-8196-fff9a81ee469","959ed05b-0215-4bbe-bb3d-64e4448d676b","96b36c98-cba1-4337-91dd-c74c00316f97","970a8c7e-d091-4719-bb9c-e3be8d46ee68","98553538-cafd-4df9-8f37-bb0a4bb478a5","9b2102f0-e16b-464f-9f1c-f068de52bb89","9c03f381-7357-4d0f-8cda-8737d4c43d25","9c55b609-b7dc-4070-a3ad-6351a0054a06","9d6ac0b7-bbef-40bc-9173-effe65d7c0ff","9f1dd474-34a3-47eb-bb72-dbfc8d2d6ecf","9skD_xxLq0mpfP13f9n8hQ","9zGwmle_yk25V-KnC_PJEg","9ZP617DQk0mZ10J3d5q9oA","a0271cd0-e172-4d3f-aa2f-9806f21a82d2","a240d44f-963d-4d35-b22f-980938c7788d","a3Er_BCApUiPtBaGkSH9LA","a4903da3-249d-4f0a-a746-b547fda371ff","a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9","A7Y52vGIN0ijEKsWR_GQLg","a8a91216-0fcc-461e-a682-95ee82509cce","abdf525b-b871-41be-a1c8-810f49740ad4","ACrBLiAIgEur9V6l_B0CvA","adae82eb-a4b9-4541-acf2-76cea839a593","afd88a79-7f03-40f6-bcb4-c247dd86edc4","AK4CoAB2IUGkwqECNaO75g","av4PwLz6YkmpBRImQZgn4Q","axPBGoqorEe77Oqqjfzb5w","AZnPWCN41kmNwFwDqWGFWQ","b-IM3eYMJUa40395O5XeLg","B-sIpWNOfEigMeQF-WVR1g","b0298c45-0dd1-453e-bad4-41e4656bb38e","b292eb4b-07e3-4a48-99b5-3c6587a1e02e","b75ef67d-00c3-4ac1-9b72-212032460294","b78008e8-abbd-4ece-be34-9a911f721fcc","b94411f6-5b71-48d0-ace1-ac6f63167065","be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54","be6b3fa1-3388-48ac-8957-d7b8c85ff968","bNA44H2wzUy-FkWgyOq1Kw","bVrkIyADCUiGNYWdmP_HPw","byvLynH8x06OQ7GNCXOVYA","c2052d8c-fbcd-4462-8f08-bcf5b3dfde2f","c2a91a10-2f5b-4ab0-a79f-51408cf4b047","c2a9c007-2748-4fa2-ab9b-0081eef792f1","c8ccb85e-52cd-4b3b-aadf-289819067125","C8gCmBZ8RUG83vvDw_UYew","ca37823a-b8f3-4414-b38b-de0dc7fa4295","cb9e9e05-c481-4397-86e7-c2111bc0e817","ccf1ccc1-e229-4157-bb74-f2cdef01e547","cdbe2cc8-4fd7-451f-9b66-0df0e0e7a85e","ce3782aa-56cc-40d7-9019-8ce714cb7748","cf119781-5bd9-4b85-9a0b-12e82e988c23","cfabdefe-0890-436e-b2a3-b5c712e22955","CUCKsNVnIkavHjcspWAv_A","c_t9XDbn-Eqml1O-wC5zJQ","d015cae7-a905-49e4-8823-6dec069689a6","d21e1b6f-9c9e-42b4-bad5-201ec43161e9","d2a692e6-e1a9-4cfe-8154-10b52be7f8c8","d30e3afe-414c-4b60-8be9-6fa4156a4b65","d344b8cc-85a8-4ace-9f92-38c84f396e99","d738ad4c-c9b5-41e6-8979-d586c964fb6c","d759c88d-a159-4c26-86dd-b054bb05b3ed","d9be3a98-9da9-419e-9dce-fe58a3ad9c4e","da36bbcc-2a9a-4ba2-9e4a-6f52c9b1ef74","daa38a31-fb7b-4e26-a2f5-728d6dcec81a","dafa658e-753b-46cb-bd85-85c1de5e6ea7","dd7472ed-bdcb-4811-81ba-5ed682fb7675","ddf429fd-a883-48a5-869b-bc12e44d30bf","DE1C-RJkuEWIvpR0q1i42g","DfyODsHfIESXxVjcfZmBOA","dpBx3DxB7kKJphxCvfmKKw","DRJcNhkqvkKFEaZpOguR6w","dUu7b-JFOky9c2lIVCNmcg","e0186718-4a18-434b-b96e-0c9c912f0d1f","e17481fc-b2b6-47a5-91e5-7205f45a3247","e4b53a9a-ac95-49ae-bf68-615bb23fa075","e5635772-4e84-4b30-b77f-4a1196cb599b","e65530f7-543c-43e9-bf12-b6cda2bc755c","efeb07ab-6253-436d-bb66-991ddad72e58","EiIow5mS80qpoS10r1ZdvQ","eZGiYEx6sk2PsN39RzQwlQ","f1cd10fb-3d0b-436f-a173-65bc0d88d8bf","f4cea991-fc3d-4715-bf82-8a8fb60e8799","f5z_jhdcqEGjo-1WUmldaw","f600037d-c9c0-43fa-b45b-02f456f9dd5f","f6f26e2b-657b-454a-8663-55d6b9b6e50c","f79f8a46-ef1d-4d8c-a4fc-383e3a9eb10b","f7c7d05f-5454-445a-af38-25f110bd204a","f88178b8-b204-4fa5-9dfd-6e438bf1e247","fa9a3d8e-16c9-4a7e-9166-3226e0d13c39","fd67ec40-f898-4df6-b846-8bf350f362b8","fe01b6ed-3c36-47b6-bcfa-47864ef9d610","ff5ae41e-e446-4a96-8c11-49d85556fa81","FVdDQFOMQ06teQm2ANDPng","fXnd_COSzUeznwe7P4ZYRg","fz_0gZ6RXEC78NpYsx_eqA","GJRG8sCM7kSGfUd9gLwpgQ","GtoHkTpyA0iGIHsxphwivg","GV8z2dlqGEqIAYaGrF1ZYQ","GXS9LQgPU0mJIEuXo_Oocw","H3OQDBef1UaacSD52p8Hdw","hCeaYHy1TE2s6rTPxAQP9A","hjk9OO8CW0KNGR7A6_p84A","HQQWccdyakaEmntPhvC6CQ","HTjlzAlWZ0ezY6w3ngSQSg","HTU3Wp_ri0GNYGFSezFHSg","i8bvbGAKREmAEbjKO-geOQ","ica0LZyM_kOox8gxW7CaMw","iQjkS1xYP0S63ltvKpkz5g","IQvHmZ8ei02hVuXZiXb6TA","IWg9wuda4kaCilJk8JQpTQ","I_OF1c0YWESMwxJr9sXYOA","JmFzNL5v_0q1p0bgoOlBpQ","jQjfiz2i6kaPfrkjnBGxBA","lb5tuJOqnUKp9prjjlgHmA","lSDrKR69rkSFULy9GA__1w","m0R4Jtf4HES8krxMnwIWFg","m5Vw_MMCWUiSOOCL94QO0w","M5z2wk-GDkOAjvZzqsfcNQ","MBuxBAIkpkWI26d-arAKQg","mG60EPIQIkaJNgOkonVmZg","mkDVc0Mz4kmuLZ7powBHpQ","Mrf17aidXEeCF-fFURUUcQ","MV1HW2x43kG4rrhyoC6mmw","N5wN1l0tYEau5np-9PoIiA","nalaCGqtL0SFA4Itwqmp8g","nbhzQy_Hdkeohh4S1IezwA","NH0L99bd00OVKMyltuSnHQ","nW_fBsuZA0OfIjTMXm6ZCA","oGrNKZLpMU2bLwNEe4MPnw","oHKhT-GTx0iFWrrlz9s6Tw","ojCeJ1VwtUelyVqFnUwK5A","ORmS9HkfoUeFuO4RhqcW7g","oSaMpc5rTE-U6yhjtPWQgg","ovy_3j_qwE-7mmks1Z47ig","pHw_MghIYUazSFve6L237w","PioQ0o3yP0a6szPZ4EKs2A","PMGQ8fLc2UuJXMy42Ati-w","PoMG28QAskagun1LSd4C_A","PZHohDVX0U2frX13N4KYDQ","q0nBwoIx_UWPe93wHDWTYA","Q7hqvEo7DUyD9AqIOunWWA","QBzMp7MdAUOLIofvddui1A","qCIoeLQw-Uq-SKIetIgIpA","rcuK1wZcXkCe0_LGX7E-Tw","rgzG7sfrlkqd9EO13LNmpQ","Rk18_7icF0Srgh7M1_G-hA","RNN0ejjlm0yqGDcEuUkLjw","rzoH8P44a0esN-HnYd3aQg","r_GLekt6mkWxXw8zPPR8Mg","sHyNs-dpc0-_Ah-HJVczcw","SroEVC6vBkG42ILwCtKB9w","SSY8ZYMgaE2Bl4oFmMjKQA","StxMBEprVkqMa_IoDG-Q9A","sV_ByCug30G1Gw-77KUe1Q","sx10-Hsfl0OS62pIFvHH8A","sxWOOK1RwUOp5YqZ7a36xg","t-ZEWJCgFEyGP2PjNblQMA","T7zwcIIc8Ee3ud8a2JCuwQ","Too5jpb1w0iQenkjRRS-QA","tqyzKPdDwkWq3jfZ8jZdaA","uB4gFxfrh0W5OtkmZdFN1Q","Uhw5NBQy-EOIiCWiZFiL7g","uiAwVt3HK0mRVtWp9B-gTg","URWHffwjGUKgXeV01upePA","uTkbswCedUGyuZcaX0W6Ww","Uvarm_CwTUWWQYZvzI8OaQ","vm1ESK9ls0SaxSwHplNALQ","VO_3cgqQqUOTz30TVUxrFA","VSZX99mSt0e7gPRM2_XzMw","w07bkcUoQEiNJTVDeSywzQ","WeyKw_WS70Cx3NZdOaxGeA","WF_fg80f30qvHit0vGBwCA","wzt-5C_LjEmIQWUVPcYWhA","x10FeRBsQU2DIIpG8XIlwg","xCpdHx1iM06Gg0tYoGv0DA","XFuSiGupZ02Fut1Dxum_Iw","xnxDimLvVUCI9tEJqWDDcA","xTqtLPQVyEKjAQZYl6HGow","yBNsYcZXzEiuR0u3tWnnWQ","yKb2A8M_m0GQDhFWCRUHTQ","Yn1uv3Y8JE6VSxsf0LOBvw","YQQamy-tQkCkpZPXKepUrQ","YSrGwXybGEqaqHNCS64qOA","yzL0M_N5Q0eAa8IxACHPAQ","ZpyoWbO6GkGglXlMuYUARw","ZtCqMnFTQUWv2h0MfZ4m6A","_Yq00H-NWUyQNvGxRunHbg","_yvss6L59US4-8QYZgcCdA","_yznwUwL80ejrS3vki0IKg"], + "swatch_guids": ["-e6bmXnwZU2EVwZ8RupKbg","-HYrY2A5Tk2UWMi4_hmYbg","-Iqmonry4EKoqAULCKzRPA","-k9_-2onrEG407or8t1RFw","-Nf7WM2TeEKujB637h3tRA","-tDd0gdxQkuvL2id10AweQ","002b07bb-4256-44ce-8ca4-25510e70b6b5","0217fd2e-ff9d-49f7-9bc8-8d0d6b8bf250","026ca50e-3a81-43cf-87f5-950687b7bbdd","032pE8s9Ik-2CImFVDrSqg","05ac07e1-67f0-486c-abf5-a62866475abb","071d1bf3-86dc-4a31-b54a-e6579fab8649","0724b166-9ace-46ff-a191-b13f135b00a8","076c96ae-9f9b-45bf-9dc7-ce73d5ddf422","0809a5cf-a0f3-4614-9f98-71518524518a","08b7c12d-3317-4d27-9b40-34737a709454","0ba92db2-690a-44ce-92f4-130e9503b6c1","0c433086-9501-48c4-8d0e-4474eb420c61","0d2830e7-8eb0-4fa3-99bc-7c80f01dd991","0ecb8a2a-cffc-47db-aeda-fb0684aef1e5","0f71b9a2-dd88-435c-a571-4503d12dcf2d","0ffad843-d6c9-425a-8686-7217009c867e","0iSsaY-HgkmLaRHCn5vEdw","0ps19D7b_U2iejdKQFORyA","0S7Nqm2kv0qMDTh-5Wy0MQ","1421caa9-9a0d-42c2-b847-1500ba2e483a","14afd295-bcd0-4595-80dc-d477618b9ab9","14b43bf9-5ba0-484e-ae5d-55cfeb09a70d","17040a88-87f1-4613-b439-3316e33dbd48","1773d5d8-4714-4721-b30c-97e25d6f2a5a","178c6beb-c737-434c-a53b-d14f438f6a98","17b16982-871c-476a-860f-f6953c89ee8c","17e44296-1cd9-4f8e-9aa7-3837dc69279a","18b52db5-0261-46a0-9214-ff510d455ed9","1919d319-6cce-4500-8766-15fed6a13fa8","192ba73d-6990-42da-8fe0-914c2a7181eb","1acb1f56-4509-449f-9c6a-065bdeaa9ee3","1b1d08f2-12ca-43dd-a44f-ea2820b919b4","1FcyrVGyGEC9dmOq_51nFQ","1VGZ1QCvcU6rsKVkUDBNjQ","225209dc-3afc-40cf-858e-89f7afa00c0d","22dc463a-a89b-46ad-9a0d-557c742b4a80","23428ca9-be75-4146-b400-df01d1dadac9","24zVmiGiaESrM5qcRfF6gA","272fe8eb-5061-4729-a7a8-414ff667a82f","2738ab5f-8c94-46b3-bac6-4a8111c41c21","27680277-aec1-41be-a476-9f51cf775580","27af51bc-8226-4911-8d47-22d28ea8e378","2913bd93-bf35-4bdf-b83a-d40a10213adc","29EZ6r2SsUakWjzuevxeoA","2bb65868-ee12-4871-b50d-64bbc9066f0b","2be2f040-68e4-44dd-9099-29f11110f3a4","2cf2b644-3fa6-449c-9fe8-d0ab917f3106","2lD-mjl3pk2wWv24RY2tbA","2sf-FU4WckGBDWUkVz4z1g","2Z_FxSPiyUODtlFZWF9DFQ","304e5cf1-5c94-481f-8e49-e6a943e6b300","30c61e63-5e09-4a6f-8915-1e1fe4d55cf0","31ac9d4d-3d6b-48f2-85d5-dc4b8165a17a","31jelNWFyk6FlWt4HyPAlw","32a5e42d-2b60-40b1-a145-ed96fc91af8e","32dde6dc-e3dc-43ee-9b7c-987bc6778c17","357eb160-360b-4a63-840d-65d11cc8ffc2","36e63797-5a42-4779-a5cc-ef5c2e7d17d4","37f8378b-b98c-477a-b8c9-9340772800b5","381a9f13-5b44-48e6-8a49-221422a963f5","390a476a-5545-48eb-955c-1bf1a16012bf","39f04eee-3a58-4e53-9f5e-7e995d46ed71","3b2cfc85-4e2a-4e0d-b738-c296ae8692f8","3df5d13b-ba77-4b51-90b2-7a946e4d0be7","3f6c8bd2-2c1d-4c8c-a9de-16600fee9645","3frH6KxZ9kGUYkjeCDpylQ","3PfP661Fg0CruzRxis9EiA","40283127-2ffc-4989-82f6-29c3381bf9e2","4169bfed-8403-4590-a29d-4941275ac0b6","426d0592-f8e4-4bc6-9fe1-a62b91fd57d5","42ZNGHXMvU29yo9s9tVpOQ","43501b0c-eaaa-4ab3-9d03-2ca61d2e8fc9","4398ce1a-e6ee-4da4-ad0d-7abfab41020c","46970903-9799-4d25-b90a-7162c1f5dbef","47da86b9-4e6e-423c-b8c4-ea6bf8472509","4828b50c-95b6-466a-bb25-514891d78202","484b6c13-af22-4ad5-8c43-34c0de095d49","48abd952-214f-48b2-a8f1-1146f6f69aa2","48c18418-01dd-40ff-9dfe-c4cf5f4e1245","49bbd11a-e5c9-47ff-b04d-72eb321e8a57","49f5864f-9d40-497c-88c8-e87f64d41d74","4a85c181-92fe-47a5-925e-05f0ccb25006","4c26d1e7-2382-4478-b9c4-a068ed01a607","4c845dbb-26c3-48a1-b562-e28a7416b154","4d52bbd6-ef74-45ae-a6c2-bde10ab8eb16","4Eck_dNL1UKlIZWsA1YppQ","4effd67c-a52f-4b41-a5b0-04287f91a7bc","4ElTihrMA0ipcgeb9UG01Q","4scG_UBlzE-5DWRhSrtm6g","4VkPVbBVLE-eJHilwGMAbQ","51ef8d39-2b94-4f9e-9620-07b6b0a913a5","531b4297-b38b-4362-9854-4f2139ea43be","53dd9e44-7096-438e-a7c3-11d7c307426f","54d3c589-30ba-44c1-a77c-8f9e16eb0b20","556141f7-6fbc-4065-9faa-8d8240a93d7f","55901f12-d5b5-4fa8-b4c8-e479689ee39d","55b4e661-43e1-4708-82d5-2dd2bebd4f45","565c17e0-496d-45bd-a2c3-564e118c06cc","5864d23d-1a86-40a6-a52c-46ee670e4508","596097a8-d545-4256-959d-1d3cd96fc64c","59866578-d4e0-417b-9483-233ab108b1ac","59a62fa3-9e15-463e-9bd5-3aecdf604c1b","5a614f97-ef7f-4b70-afff-9ed837a86407","5a96ac4d-03c3-4214-9dcb-c2044ff28f2e","5b5281c9-b795-4bb2-ba7b-ba52047ebe3f","5b58d879-2bc4-47ed-a996-1af2ace16425","5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b","5c8f36c5-07da-49f4-af02-d23207a43216","5e00a832-3f25-4dd3-84d2-b6ed6a4b4c37","5e7a48ed-8f08-4990-a3ae-05722c9f59a8","5fb2e235-8c67-4335-b432-edb10db2b139","5iCrYb8MBkqN6IBEe3IipQ","5TJPDmvEBEOkP82ZWLFqtg","5_-QLURc_0W8qeeN3Q3iGQ","6145292a-9002-41c6-8cd5-43679b6f542f","61ff7a02-50ff-49ea-b95e-c31b95a30c8e","61VZkFjB402brzWm4UsftA","624c85e5-095a-48b6-85fb-c9bbade19a8d","62bd5dda-08da-4528-83c1-f0ca3812dc6e","631c9015-2a00-47ea-bb35-465a2ddbca5d","639f5e2c-6fbd-4ab4-aa10-7772e0932798","643b5f2d-0666-4869-b699-03742de34d16","64545dcc-a8eb-4c69-a539-78ee73a2d327","64850553-cdfe-455a-ac00-dafbe63d613e","6564acf1-4d70-4f92-92ac-08e2b76dbb6b","657f28d5-672a-4f27-86c6-3cb00f25bb84","6584dada-499e-4d7c-958d-466955b20640","67490e3b-0f9d-4bf8-84fb-54957b981c8c","67bcca75-4ab1-4964-8688-9908c464d355","67fc9269-8dd8-4d6f-b594-c77e716f2482","68273c9e-96d6-4ddb-aaab-d99229e864d5","6d703981-2734-4c45-8983-cdd5f328902f","6dd95046-acf8-42fe-ab78-80a334096a9d","6e0f3af8-297f-4ea7-8f9e-4a1bc57d3e35","6e152cc7-e93a-4408-8092-236db420b680","6f2e74bf-1e95-463d-97db-d5d1a53b2c28","6kPcLZtEqU2Hs4yBFvc_sA","6t2rCb6WWEW6pJbcjsAFCw","6vjA_vHq70Gclypll_GS_A","6y_fzLhG7E65Px3-CYiyXQ","70112887-dc8d-49d5-96ed-0845204e0b58","713781e5-ce47-41c6-a24f-94d42a565e30","71e5a0fe-fc8d-420b-af3b-e74160e0d1f3","7221132e-053a-4c58-b719-ed3c8558907d","724c79a3-822c-422f-9462-a5374ee0211c","72a010c0-bddd-4e5d-bca6-9821d82de9a6","74b009f4-e128-4c59-8a6a-4c0ba6119bde","758752bd-db2f-43d2-b580-55b3e1efffd5","761a3193-60f0-4190-80c7-285b8192e794","76CA_KCr806y1FA35m-YTg","78eb0969-f246-4cac-acd6-295df7c092e4","7afe1bb0-5ca9-4263-8f85-49d3a18353c7","7amHTgEv-UOJSGl0ofo4mw","7b0d6661-e856-4ec1-ab90-d89c28a70826","7B7vpnMTfkGLcMdHCZ_kWA","7b82c3c5-fd1f-45ec-b83f-e32668aa8dce","7c1aaa7e-503b-46ef-b1c5-0c61a75b916e","7d8e55fe-3c34-4b4b-9753-0021f6cc6454","7f9cb939-36a4-40da-8337-025e1d7bf8e5","7fb8496f-0e6c-4e48-b8fa-5709ebbf4820","7WrSbK4Ml0K2z-ig5HIUCw","8053b5e5-0f67-4329-a45d-1ee9fc830820","815138e6-b0dc-459c-9538-20f5bbd37d6b","827a1538-51bb-4fef-99c1-7f1bebd9866c","82de1ca3-67d6-454d-84fb-0eeece71fcbc","830be2fa-60a5-48cc-931f-34b670eae4bd","83522c12-9549-4d74-8c87-bae210bcd2bf","8377ab96-c908-457f-9fee-b784c9a759f3","857fdcd8-704f-43d0-92b9-bca26619b153","85eb7f47-bb31-4c66-a8b3-562d35028e6c","88c3ed05-ce41-4a9d-a4a7-8a428d240b3f","891d902e-3257-4a8c-9001-858046007997","8ANbOhNnO0yLQOCIBsDwfg","8c74ee27-3d56-401b-8a8c-7868a80770e8","8c7b09f2-6213-4ef9-87ab-a2df72d07a22","8c94e45c-6605-4b4f-9ba4-2c6c6e557228","8cab8cc6-9a2d-434c-8b72-e66edbdd223c","8hUBfmq28EixYjv92Xcrgg","8iZO162KNUiMC8l_z3OC_A","8xjU4GQ4OkaqGyad-ypwtw","8zXWUxwZHU6tThCV4p5iGg","90869e86-cbc5-4024-9d08-fcdc41c64eb5","909ed249-ce09-4304-8b02-56794fa7567d","90bc2d31-1715-4d1a-813a-7c57e66cb017","90qQe0AR3kOou0MsuoTmPA","916384ca-e209-42a0-bd64-c64f5a40b56f","922f229c-124f-4f07-b4e3-19138420ca19","926ae943-76cb-46dd-9c66-a6ee44ea9194","9374bf66-2ee5-493b-8439-efce4b201904","941c046e-4e95-49f8-a7d7-19071fcc3c94","95e4cc30-cb68-473d-a395-feadf5b51512","96e2b29e-e553-40f1-9eb6-8a9f7e64da39","96e42c7a-2881-4299-a2fe-80c0d4d5ca26","97938f4c-52b8-4c85-8575-856654666316","97bbb384-b37e-4ea1-902c-88fbd6854ab5","9967ce54-c2b7-437a-946e-11f8ee6d6fdd","9ad37173-e640-4b9e-a474-716d482b73d2","9b35ae52-febe-486a-8557-ec7190a9756e","9b9t8_aKH0-gQXMhZycaOA","9GhnX4Pj4E-wxbPlD8GB-g","9QG6qp5FBU-u0-JK-yMMqA","a089bcc6-798d-4777-98e2-7afd290dc6b6","a1c51d1e-1d5f-4f8a-bc0f-6a20eddfe58a","a36d252b-16ac-4a82-bece-de145147e7d9","a38254c0-00e3-429b-9d5d-b9e6a9812e69","a5925738-a451-4f25-b622-ccbfa12bd88c","a5nUvJZgTEGBUvdHx0Y6Ng","a6796264-9c7e-4605-830c-06db8d0dc780","A6VnGtpY9Ey0atf3zaLo3g","a819f49b-6c7a-49d3-9e6a-d9d79ef5019f","a8de80d3-286a-4fdc-bed2-75d52f8c0964","AAbGHOOitUu9enweOXbrkw","aBQ3R9boek-mYeSYSNvDbQ","AC3zfm38JEyBZWuXDh4Ukg","ad61c418-6d77-4a99-8ac5-9f10f5a3d42f","afWY5VGiF0isGn6qpo4KeQ","apSQiQ1eO0mEzRsbugeu9w","b1ea158d-1663-4db1-bb09-9847f6e9d1e6","b415fe60-3e9d-4f4d-a53a-ca15eac6af7d","b42d903b-b393-4f49-af5e-38d0fb6c42b0","b444ed0a-eab5-4bd6-af34-d080de74a149","b520f3bc-599a-498c-8a4f-97cc25aa741f","b539777f-48b2-44a2-9d63-17ac703e4d57","b5d62931-e756-4a8a-8f3b-760beba49319","b6c25a3b-125b-4aab-bed0-e6e5ced2c21c","b7oobDcDtE-69fhM-5g5AQ","b995b419-1897-4c1d-98be-83d551f7ba71","b9f51861-ad10-4878-a9b1-d849bccf532a","ba6b6e1a-a09a-4ba0-9523-552869f03336","baffdefe-de21-4551-9330-b50873965bdd","bb01b51e-a95f-441e-8c52-abc3aeed7145","bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a","bd4a84e2-b67a-4269-a26a-17fb23ddb09e","bda7a57e-5227-49f4-a119-e3f166604738","bde08851-a9e4-4faa-8f04-a0a661672472","bea795cc-aabd-4229-84dd-49ab4f91a445","befa2f11-b9fd-457c-b1d5-9d82e90b70ce","bf82f2f6-9af8-431e-a296-0890dea48ba7","bgRKH1wfqkWVHcuSnFYnlQ","Blev_VFjRUysY_nymGWAVg","BLgkgLeS-EyazgPQxGcavg","bzYdPb8GWUS-CgT4wrziIA","c0c33a35-494c-4a1a-aa53-d27d9b9ef5a4","c0ff7719-c56a-4934-831b-506ad0f51cca","c1e430c5-fd8d-4032-9e3f-8d87cc8784da","c3c9f89c-580c-43c4-b38b-07394044e0e1","C3ll7uPfUkeZ1QLX80_BpQ","c4ebfd1a-fcda-4b98-a151-a941b1801867","c5884778-a87f-4612-9717-86fbb65d440f","c5deba2a-6e35-4b13-8e94-8ba5457f39df","c64ac7a9-7fd4-4587-8626-0413a190b706","c6cf096c-f0cb-4500-80a0-bc2916b5a426","c72911d0-453f-4732-b1bd-dad520220a06","c8cc9589-4831-4e73-a865-ef8c139a2ba7","c9589974-c261-485e-b571-cb2f34fb178f","C9cPjOoiCUCHrCBi809CMQ","cac27853-e2df-4d77-b3fd-af3bc0813f01","CahBzgN3D0y46AQsvGC14A","CavRDqIlmUeTZBtCLn1hZQ","cbe29e9f-f2ac-47fb-97e1-8bad16abb89d","cd5d7285-202d-42d0-b93f-04245875793e","cdca593a-8840-4140-9145-fe962732040c","ce465d53-3807-423f-baa0-bdba2a75ceb3","cea53c7f-24a9-4e3b-80b9-fbf95738f973","cf515c87-3c0a-4443-9f63-5ea1bdbaa61a","cKHAetNcgUOxFfDkAPPF9w","clFrpYWFQEmDkE14f3o_Ig","cpTKHXjj0kulivZCKVQqiA","D-SngK7hhkOpoZ2aMWv9EA","d0bb468c-b78f-4e80-916d-65fec3ec0010","D0tP58wC30mJ00vNxw8hqA","d1eb3573-672c-4bb4-9ec5-7c81e6a38d3d","d2b40639-5d34-45b6-904e-6dd29030ff44","d3-sxPTJOk2rKo-Jgy3wvw","d320ad9b-ba5c-45ea-b2b8-0d3e409a9db1","d3a9b576-5433-4305-bd96-b08820c09212","d3KmqVuMQkCYK6sVTMOTOA","d5987fee-e3cc-4336-853a-5df57f73df05","d5efaf80-7f34-4c9a-bc3a-b80b1ac70ace","d61711fd-589a-4669-b991-9408a58fffd9","d66aa400-aa5a-4539-a25d-5f8ce94dc281","d6823e01-69f0-4f85-b94a-74894356a2cf","d6edbc00-3c1d-4f49-8412-3ef8c7c5f4c2","d78643e4-c0d2-4836-bf22-b5a0e2d65b28","d7f0ffd3-ccf9-465e-8afe-4d7efb78dd2c","d81d0b62-e052-46c2-9252-4bc5fb219f53","d8d490aa-7088-4163-9305-1441367f490c","d9mwowRelE2lDKEA0UQMZQ","da185fbb-ad2b-4f30-87a2-491f24b78ebb","da6d0ced-3d43-4878-a868-925f2ed6fd5b","dDLQbD9jtkOmw3PR5QJdfg","de0a5a3a-8aff-4799-8269-9efa3aeb383a","dee70c38-7a99-4c2b-9181-665f1bf75aca","dro7VLV8BkGjqpPAbJheYA","DRS8xu3AqUOy2L0RKF3f7g","dyEaPloE8kazWC7Zblv8uQ","DzgN67HoikexE2rTlZLdYQ","e02838f8-4029-455a-b35c-dd40f972b05b","e0397982-c2c2-4733-9a40-46e18675b5af","e13cf33d-7588-4e2e-955e-f0a29d6380b7","e1f49b74-b071-4bd1-9c4b-af36d24d45c5","E1GfXDLUaUKaG9hTLAfFPA","e3454bc7-c88d-4958-8b81-db3fe5472a1e","e37b07d1-6b9d-49b9-94ca-1c728e228cfe","e4IDo-fuH0C9YByzcX5k-A","e548124c-d7f3-45de-aaa0-f66130eca576","e5de2e45-207c-46af-9a5e-eb01a0c621fe","e69372eb-2fd8-4e17-a448-ee0be61a2c7a","e821aa74-2d47-4388-bcfb-1893b8a83a52","e98c3f98-7844-4d6e-a21c-05027033c18c","eb3SmJTIh0628LrZUkI4KQ","EbVDqEJQOk6j0UvX3t-8rQ","EC0ttvk6eU2HEMacLAx2Iw","ed134bee-d199-43eb-98bd-206571603f40","EDLxuGjEVEGdOPXDqyN-xA","EfdMcnfHt0mr0PQ_maaYOg","EiGgN7uVukqoRE7UJcMLTw","ERnUPsyT6kuEnIRZF8zPwg","Euf1h1qsZ02evDsyoHYbPQ","eV3Nv6SWS0yl3f-fgjPb-w","f0pjsV0XMUWoE_z2kqOlSA","f3b2330a-b981-4c7e-902f-1f57382f2388","f4ad4b9e-dd42-4711-9437-5beae51966f6","f5403c89-9769-479b-8a05-c71607f9b189","f750de46-3758-4f7d-9709-0a84b1027009","F7luQ18ZXEaITFiCqqwkpg","f841cdfa-45ba-4f09-a117-503eef34cb4b","f8635fe2-90bc-42c8-9bbe-3387b5157e18","f8b0cfe8-e129-4578-8bb5-f60af5d38599","f9aaa7c5-59bc-486c-9a6e-27a8ac618f84","faedf215-289b-40f7-90cc-f5b98517d133","FAviMCQ_EE2Mpt6QPo5OEw","FaWMLkJJgEKFIO493fzGxg","FC8ZFBPt4EyTe7IYbv2Tpg","fcbdfbfd-254a-4538-b119-43e23404b8b5","fD5vtKxBWECH54Jj4FbnUg","fe304452-b57b-45bc-9c1c-b85b70bcec12","fe5288bc-0e02-4ea3-b17d-dee0e576979d","FE7_gQNBt0CXzMN6di5HWA","fed760b2-15e2-46b2-b1e3-cc3c8d780d5d","fEPwvuJCWkGBe4IekXpmLw","ffHKr5EcIU24PbAtWSnalw","FFIpxInE-EKbk2GY-pIkyg","FNe9LkWJMUeC3frYJMyEaQ","FsATqzRVekO9hQ3u_q7keg","FUP4iRnoc0ikNmjrFD06lg","FvEjo9let0e_Sr-kWq5AvQ","fvoaMN4M10WS6jiA6XlOJQ","fwIgUAM_C0WhKds0O6DyPg","FwiizGjYnUaM1VJ53R1cCQ","fXNkMWFMV0aXalK_y_8SHw","f_c50saStkaGJuhLjJ9lfA","g4diCmk0wEmIKUVozk9tDA","G4HIPUjekUigiU0-Kr9mdA","gDH1WmW5rk2RdY4wJYYZow","GDvaUU10-EKh3pKUQ9tnag","GH9q8_0O9EqjGdz7lGWD2g","ghM-uFq5-0Ci38AOHa9zZA","GK3yolCNlEm3B_5DtHkE8w","GmS0wKwzVEqdf3NoTMHG5A","GQSpXmD0B02_gwzpq6StFA","grutFwJD_0Cb3t6fddNg6w","Gtq8yRlt1UeFW8U-sc7FiA","GWi1rEebqkCV_TS2WzQhcA","H6fcuLxeBUSY3oERFc-plA","HdwZhjrqxE6wuf68MnDhDw","hf0gkFVS8ESvceRsC3LAXQ","hhs9GY3XZkWb-8TXODvo6A","Hk6svQ5Sr0GDKLxOyktk9w","HncURj5yi0ykk2cVyCZ4Sw","HWc25-d8dUmNN2F3n8mV5A","hYu9itGe1Ee5Neanhj9VtQ","hZKPr4sg10SRj9DLuEgOrw","I1J-xY4YNU2dB_od4P_7Dg","i1XbjBqTu0CILIuJClWimA","i33RmJJCwUem-ADkOwMNQQ","i3rD_hnQG0a-5f6Ve4AfWg","i5RKLo-RbEqisnGII1wpaQ","iaDL2im4lkCXUo0NWM-F3g","Ib_iMwvUm0CJ-t_Thwt77Q","IjRMopsYlUqSE6327I9TIQ","iJW8XGJfU0G12iXarjxhdQ","imDGL6dhrka-KliYlfpdgw","iokdlBfjuEuAwqIMOQwxdA","ITH2OpzjP022vZ5JgxV_iQ","iufpnteVCEiiZu_m-yPovQ","IzkFARlv6EenmrfcNkcOuw","iZy7RmfAz0603oMO-jD-WA","J-K34UX_B0yObjQI9tn0vQ","JFalLDUvm0aMm3wh1UVfVg","jGj28vhq8EGwP2RuM074aQ","jkuw9EhCpEKn-2lKJXWd3Q","jna5wcWK90WokJyoeJH1iw","jQwNBXyoU0Gk4FyNw6jFxg","JS7aa4QSUUOpUYPqRZEb5g","Jt0QqNrAp0qzUR_RQoivVg","JTgXKeT7w0K2Mw4Jk2HCqg","JVZHZm6nGEqE2zEEPoHbwA","JWBK5v78Y0uRVEXlD-Qk6Q","jwEB6LyEaEekqtPNRc6TVA","jXBi28cRV0CEQgKIrpD0_A","K17dIz_TRUq5D50NBfLjyw","k3dbqP5z30KU37MVPpsEPQ","K5vPqD3BrEuODRDzFdC6Hw","kkvNbVRjcEKtChpy3W1bqQ","kLv_wsbbGUmlfIUO1QLYLw","kLyM-6otSUy2ql5SEmnF9Q","kmj5zOjcwku_WWKroCeiVQ","KqjQ1xRpokGLKp9GVN1EFA","KTidYfH0ckWxJGs5qzUBTQ","kTP3xKhzBkuQF3SqvmYFKA","KwCEye0nsESFs-yj4h9sjQ","KwhdQ1deNE6Y6jzO9vaAnQ","L2iFQcL170OajqfW7t6sVw","L333w3dfIEWDbtt1Yn1gBg","lBe3yxuE40eBmTrnEHBqQQ","lBePHNL9LkyaAjEFyFE8Sw","LDqcPkvNXE2gFXxbpNkfhA","lDXambcP60CeY2C8qeGDFw","Lf4FjROSAEqXedFxpXFQfg","lgqLGi01o02ZFC9Xs5Ofdg","lhfOKGDAgEKWucerCmH7Fg","LOeHr4xoik6VOv1fOeImjA","LRv1X0ZrjkqO7azGpZ7uMg","lsyYlxY9o0a3TbUrHkxeyQ","lvDIOeSI10SNxnCO_M_9zA","Lxx3q6JRnUW_cPG_kGG4ag","LyVf9lnzdku1F4iPBGqk-g","M1JVd5ur7U2ZtpvhEhPNhw","M4765UHzsUeNTwH-Pcxp1g","m4W5D0Oa9kaWa-8I96YYzA","MbusQ9bte0i9zirlxtNkSQ","MFrcSQ1DYUm8imvy4ypgvw","mhsglVEtREmElF5wuLw3NQ","mqdncbYcuUSGFvFqEU0z4A","MQOxmUk-mUG-Ee9m9nX31Q","mraJrTA4V0eARb_Kq7yYVw","MS7EPNLJEEyMLC6_yHQYtA","msOFHoFN9UORkeXXtdqBsw","mudt-Z5-5USdnIq-xaDgOw","mvj5RDTuh0qoql0OyBFI9g","mvMeyStxGUGlNLSYNeSubQ","MxnAq_XOw0uTzQ8IIxqb_Q","N9CBuYaMtUOrCks5UcOycg","newGzX_zA0eGUX3Iayq5Ag","nKauYAyWhkm2AMHFyC-fLg","nLRxEIBhUkyQsfrk3nzsDA","nO-YacKU1kmLjTFAAqNeYQ","ns5tIRishUOp-nYqLedcwA","nX7krD8e2km_ZlOLWcoxdQ","nZor-clxlkm8EO9Gco0Otg","o2WfRyjJy0CKoUg8YIxNjQ","OfenZaa1-UGdQ8YVOrXjzw","ojvIFCTpJkeegestF065wA","okpg4ftsak-wc8Zu83uytw","oKxmH-1s2EyIDkZoOQSQQw","OnQeVxdstkS2zGxLBAuM3Q","OqsbZ8Xls0i8uA6ha7H3qQ","OrqqGMhK1UKGowFxpJi2mw","oT1vLrHW6UC2hWLIT3f_SQ","Ot9EG-FfKEauT0u6pSZuWg","oZ8k0Qv3SkG-DkY0_dP7UA","P9bBSL0YLEyjN8PxMfgbKQ","PcUZrdKXhk2XHa5Gy0YnnA","PEGqjoxP6E2yRx_A-HphrQ","pjkTGVHkIE-BzCVi1KAesQ","PMa9370LPEig_7pKwCceEQ","poqpmOloPUKotHH9p0YAyw","pqLkLbri9Eu48eryQIFGMg","prEZHWvOM0ysaNfAe06ywA","PwsLxCnzOkSW-RGz3tNb1w","PZx7Iae0pkiDaxT9t3-vgg","QBglgj80zkuvUkk55Y5Feg","QkfA71-1z0qOiZAVrbzhtA","QP38EMygY0S-KIvdGatARA","QzjakcO8tUaPeiI5KBLKtg","rcdwgNvO30Cvx2ycE1GfHw","rCeRk8Pdl0W5M47JAQPhpw","RfZNDPUok0WyQEa3Ko08ow","RKmRHk2N_U2gd91nybo6vg","rmq1AOQvtUGOHvbPUximwg","rNhOQoP3XkOBHV28ze2NpA","rnM1qDlYBU6_Dl1bjg0J1Q","rzUd1IMTKUmW0HeMPSIOBQ","S0AKRB_m2k-I7snHnUigtw","s2kCppLsN0uY7Hy6qjQc-g","s6DQxJ3gY0CG8L7JmcmRTQ","S9uymuvraUCOM8gmB7bnEA","sqPMH0LhLkeG-4XkxpU-sw","Su7kiW24d0KapNns96JoFQ","SWvJWvMDpE-it5PFoPgERg","sX0ZZDcCUkyFSJISfvq0vg","SXG9rdEkxkOR31Nk0aTOww","t1MeoroFOUioYmPfT9mCeA","Tbrc2MlSlE6mKua8qSoluQ","TEgugZRs80iJbhL87RdYwg","teVKVbVEsEyqOSuRdqHQzA","TnEDe1QoMUCUdqPL_7CxTA","TtncjvYcK0qHqWN-IMMoqg","tyzdtSkR9k-YCnjNvPAT6w","TZ3oQBp_3kWY4Vzscgv5Wg","U2ZbHn8WJEySUXVpzAY6ig","uiBklotTUEqRI2F67vyJyA","uTHwo8qd6Ei3Oy4gz_j9Ow","UUyjVU4mvECPhI9xTMYR1Q","Uz2hrptCa0erjqyPoiZxew","u_Gzj9Nro0WS48xKnH9few","v3af60p1Q02NpdCqIRO9lw","VDo682NvSUa8xYn8zTX-AQ","viL4CPch2ECwG-RNaWcyZw","VpqHNpPiY0CfRJEjRRM-mw","VQjkyb5aoEKHKo_eZsq9ug","VqtOYBiKRkaSW4xAlBxQgg","vuSwk04zykCe3ANKN838OA","Vxq6v55a_kGPODzwB5MC1g","V_duN2oWaUmmao1xHhzCJA","W4a144so90eRzCojUnDowA","wat8f6fs80KptZbW39e8tA","wCe2yC2U1kyYvPd8Z1rX2w","whEHo6Rhr0CxsDBsBD32WA","wHJnbDOo8ESmUbQDr0k31w","Wj7I2YiVoE6zp8J_HYs8tw","wkqKPZmnoU-8TXSWzi2-Ow","WOxMXWKGrUWhnNInttZ-Zw","X3MkhL55Rk-0UK6h4dw0Eg","XFCpIdeDXUqeYCIgh4fGtg","xGmnUGF-Ck-jP43r8tVVjg","XNq4Cns4lEGydtktdsXlmQ","XOBIPrKCr02xa4tKj8wN9g","xpkEQ6yjhU-WfFPSjvPdHg","XrSL-v2MbkK0md-FMOweOA","xVwzV9-m6E-8PVpkg4D-AQ","Y0e7o0_BKUm7XJSLediN5w","yA0KhVg19kWqG1UkyxQogQ","yaQ7AlUc3kGq8_RW3eTSmQ","yM5xRrd9OkmDGyQddwOP6g","Yr0mF1EWzEaIyRMOWFiY6w","yrQCLXiqRUSwnuQRYX2Rsw","ytmSzjhCr0Sm58MSNbwJUA","YvPeCbQS2UOcLjPfoIML_g","yZR5aX3gQ0WpRUT6cfK1cA","y_gTbnuYS0GKJmnCs2retw","Z1F9rjKqCE-Hd2-Ip5g3gA","Z1Ib9r_fkUqgoZk0uid_Xw","Z8Bfk0MTb0evZehvvgzuBA","zaSTWOUy_k6cnoVVYzypWg","zGOD45zNAk2i6P_zPsNPfg","Zljmtj5WqUaRvB3XQTgJXg","zlqLrGEa8E2WW9ey_szREA","ZNKGkou_gU-1cxBFy7pDKw","ZNPzHmrU6kGVQW7Q5ei03w","ZpcM5H2Q1UCt0hh82nSu9w","ZSOuWuKVCkqaIKc5d23G8Q","zu0Jk24WTkan6XWSwGU1Uw","ZvPidQ57rUS50Qz89ERmLw","Zz4XatKfUEapReRAQHxKKA","_9QlnTPXuEO5OQ3H0CwFkA","_uh3kj6hKUyBKOSR3R8dog","_ycDQ6RcyEGAntVlSDpPeA"], + "decal_guids": ["0c496f32-0011-4c4d-9778-59446f70c032","0qh4mcxJH0Wsrb3nz8UeGw","167ba0d4-4fac-4143-b5ae-1dbd8e02dc76","237e0aa2-5911-4704-8d2a-d446ddd2d842","2tFUNn-4kkK6ICo_Zp065g","49934059-cc45-46ce-8277-f96959e1f6ba","4ca3347a-dc87-412f-9eb1-799372e6fdd1","6a007c73-d930-408a-93df-041a715e0dd4","6c395d04-2aa6-4a3e-8c2c-695932bc9736","703ff56b-560d-4ff4-8c63-d195e879a328","75162f12-44e7-499f-bc6c-9b0612e9164b","770e7674-6f9b-41f8-b023-ae79738db0d3","8f19d7f9-4f28-48ab-a1c5-d8f4f788514c","9e782751-71c6-4208-87da-3a3cffa6a4f0","a74e6c0d-1300-4928-9141-977a711b71c4","br9hzhDoZUmIevgl7SJkmg","ceb07e4f-50a9-4ed0-b50b-c59d92ba66b5","d42UBeUO4UO3Q2F8IpeRbQ","d461ca71-45c9-415e-8e09-ba93e8d73450","e38294cf-6279-4863-9aea-1a19aeaf8476","f5270130-6634-4052-aa3e-9849ddf5314e","fJVxsLWuTESd-2xTCJPZ2w","hHhjZeLcaUGN1OyFoYHznw","MPfe2wahQECCDGfhZE-drA","n4hqSCv9OUewI30KnbPhFw","nHg4Uifww0GEX15L27WonA","NxMm0sSBdUatynU16o0_Zw","OZb8bb5FNUuFYOkwANvxAA","tzMFIJyxZE6cZNuLdaKs7w","v0GsPFC7cU-_JNG4-ERfog"] +} diff --git a/DorkNet.Server/Data/color_variants.json b/DorkNet.Server/Data/color_variants.json new file mode 100644 index 0000000..5297865 --- /dev/null +++ b/DorkNet.Server/Data/color_variants.json @@ -0,0 +1,2646 @@ +{ + "matches": [ + { + "swatch_name": "Windbreaker_Red_Swatch", + "swatch_guid": "grutFwJD_0Cb3t6fddNg6w", + "color": "Red", + "item_name": "(Wrist_80sWindbreaker)", + "item_guid": "52VsI_lKlkSUAOJlfDnyrQ", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Windbreaker_Blue_Swatch", + "swatch_guid": "EbVDqEJQOk6j0UvX3t-8rQ", + "color": "Blue", + "item_name": "(Wrist_80sWindbreaker)", + "item_guid": "52VsI_lKlkSUAOJlfDnyrQ", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Windbreaker_Yellow_Swatch", + "swatch_guid": "C3ll7uPfUkeZ1QLX80_BpQ", + "color": "Yellow", + "item_name": "(Wrist_80sWindbreaker)", + "item_guid": "52VsI_lKlkSUAOJlfDnyrQ", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Windbreaker_Salmon_Swatch", + "swatch_guid": "JS7aa4QSUUOpUYPqRZEb5g", + "color": "Salmon", + "item_name": "(Wrist_80sWindbreaker)", + "item_guid": "52VsI_lKlkSUAOJlfDnyrQ", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Knight_SRank_Swatch", + "swatch_guid": "ITH2OpzjP022vZ5JgxV_iQ", + "color": "SRank", + "item_name": "(Shirt_KnightTorso)", + "item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Knight_Blue_Swatch", + "swatch_guid": "ed134bee-d199-43eb-98bd-206571603f40", + "color": "Blue", + "item_name": "(Shirt_KnightTorso)", + "item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Knight_Red_Swatch", + "swatch_guid": "59866578-d4e0-417b-9483-233ab108b1ac", + "color": "Red", + "item_name": "(Shirt_KnightTorso)", + "item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Knight_Black_Swatch", + "swatch_guid": "e1f49b74-b071-4bd1-9c4b-af36d24d45c5", + "color": "Black", + "item_name": "(Shirt_KnightTorso)", + "item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Knight_Green_Swatch", + "swatch_guid": "PMa9370LPEig_7pKwCceEQ", + "color": "Green", + "item_name": "(Shirt_KnightTorso)", + "item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Knight_White_Swatch", + "swatch_guid": "22dc463a-a89b-46ad-9a0d-557c742b4a80", + "color": "White", + "item_name": "(Shirt_KnightTorso)", + "item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "LumberJack_Blue_Swatch", + "swatch_guid": "cKHAetNcgUOxFfDkAPPF9w", + "color": "Blue", + "item_name": "(Shirt_Lumberjack)", + "item_guid": "350c5c1e-318b-4233-a7e5-1f49909d8e65", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "LumberJack_Green_Swatch", + "swatch_guid": "7amHTgEv-UOJSGl0ofo4mw", + "color": "Green", + "item_name": "(Shirt_Lumberjack)", + "item_guid": "350c5c1e-318b-4233-a7e5-1f49909d8e65", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "SpaceMarine_Red_Swatch", + "swatch_guid": "d78643e4-c0d2-4836-bf22-b5a0e2d65b28", + "color": "Red", + "item_name": "(Shirt_SpaceMarine)", + "item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "SpaceMarine_Pink_Swatch", + "swatch_guid": "a36d252b-16ac-4a82-bece-de145147e7d9", + "color": "Pink", + "item_name": "(Shirt_SpaceMarine)", + "item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "SpaceMarine_Cyan_Swatch", + "swatch_guid": "c8cc9589-4831-4e73-a865-ef8c139a2ba7", + "color": "Cyan", + "item_name": "(Shirt_SpaceMarine)", + "item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "SpaceMarine_Light_Swatch", + "swatch_guid": "68273c9e-96d6-4ddb-aaab-d99229e864d5", + "color": "Light", + "item_name": "(Shirt_SpaceMarine)", + "item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "SpaceMarine_Dark_Swatch", + "swatch_guid": "a089bcc6-798d-4777-98e2-7afd290dc6b6", + "color": "Dark", + "item_name": "(Shirt_SpaceMarine)", + "item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "SpaceMarine_SRank_Swatch", + "swatch_guid": "4ElTihrMA0ipcgeb9UG01Q", + "color": "SRank", + "item_name": "(Shirt_SpaceMarine)", + "item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Hibiscus_Yellow_Swatch", + "swatch_guid": "596097a8-d545-4256-959d-1d3cd96fc64c", + "color": "Yellow", + "item_name": "(Hat_Hibiscus)", + "item_guid": "a61e86cd-f7e7-4ac7-a6e8-df269bc5cb01", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Hibiscus_White_Swatch", + "swatch_guid": "ce465d53-3807-423f-baa0-bdba2a75ceb3", + "color": "White", + "item_name": "(Hat_Hibiscus)", + "item_guid": "a61e86cd-f7e7-4ac7-a6e8-df269bc5cb01", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Blazer_CandyCane_Swatch", + "swatch_guid": "_ycDQ6RcyEGAntVlSDpPeA", + "color": "CandyCane", + "item_name": "(Blazer)", + "item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706", + "item_outfit_type": 101, + "mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54" + }, + { + "swatch_name": "Blazer_Shamrock_Swatch", + "swatch_guid": "7WrSbK4Ml0K2z-ig5HIUCw", + "color": "Shamrock", + "item_name": "(Blazer)", + "item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706", + "item_outfit_type": 101, + "mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54" + }, + { + "swatch_name": "Raincoat_Shirt_Blue_Swatch", + "swatch_guid": "GH9q8_0O9EqjGdz7lGWD2g", + "color": "Blue", + "item_name": "(Shirt_Raincoat)", + "item_guid": "9e6397dd-b7b0-4d32-8c86-1910106a8478", + "item_outfit_type": 101, + "mask_guid": "T7zwcIIc8Ee3ud8a2JCuwQ" + }, + { + "swatch_name": "Boxer_Green_Swatch", + "swatch_guid": "rzUd1IMTKUmW0HeMPSIOBQ", + "color": "Green", + "item_name": "(Shirt_Boxer)", + "item_guid": "ff15e25d-d467-47a8-9ecc-e415d5acb90c", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Boxer_Red_Swatch", + "swatch_guid": "8c7b09f2-6213-4ef9-87ab-a2df72d07a22", + "color": "Red", + "item_name": "(Shirt_Boxer)", + "item_guid": "ff15e25d-d467-47a8-9ecc-e415d5acb90c", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_Red_Swatch", + "swatch_guid": "viL4CPch2ECwG-RNaWcyZw", + "color": "Red", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_EmissiveBlack_Swatch", + "swatch_guid": "Uz2hrptCa0erjqyPoiZxew", + "color": "EmissiveBlack", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_EmissiveRed_Swatch", + "swatch_guid": "I1J-xY4YNU2dB_od4P_7Dg", + "color": "EmissiveRed", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_TwoRed_Swatch", + "swatch_guid": "hf0gkFVS8ESvceRsC3LAXQ", + "color": "TwoRed", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_EmissiveBlue_Swatch", + "swatch_guid": "2sf-FU4WckGBDWUkVz4z1g", + "color": "EmissiveBlue", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_Purple_Swatch", + "swatch_guid": "a5nUvJZgTEGBUvdHx0Y6Ng", + "color": "Purple", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_EmissiveOrange_Swatch", + "swatch_guid": "d9mwowRelE2lDKEA0UQMZQ", + "color": "EmissiveOrange", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_Orange_Swatch", + "swatch_guid": "JWBK5v78Y0uRVEXlD-Qk6Q", + "color": "Orange", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_Blue_Swatch", + "swatch_guid": "8ANbOhNnO0yLQOCIBsDwfg", + "color": "Blue", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_TwoBlue_Swatch", + "swatch_guid": "4scG_UBlzE-5DWRhSrtm6g", + "color": "TwoBlue", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_TwoOrange_Swatch", + "swatch_guid": "nX7krD8e2km_ZlOLWcoxdQ", + "color": "TwoOrange", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_TwoPurple_Swatch", + "swatch_guid": "OqsbZ8Xls0i8uA6ha7H3qQ", + "color": "TwoPurple", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_EmissivePurple_Swatch", + "swatch_guid": "3PfP661Fg0CruzRxis9EiA", + "color": "EmissivePurple", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Headphones_TwoBlack_Swatch", + "swatch_guid": "ns5tIRishUOp-nYqLedcwA", + "color": "TwoBlack", + "item_name": "(Hat_Headphones)", + "item_guid": "UcPo9SsF3EGTCBB2J-Efdw", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "JackfrostHair_Hat_Lilac_Swatch", + "swatch_guid": "KwhdQ1deNE6Y6jzO9vaAnQ", + "color": "Lilac", + "item_name": "(Hat_JackfrostHair)", + "item_guid": "e6216087-fb37-46f1-bda4-a88c4640f339", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Baseball_Red_Swatch", + "swatch_guid": "de0a5a3a-8aff-4799-8269-9efa3aeb383a", + "color": "Red", + "item_name": "(Hat_Baseball)", + "item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Baseball_Green_Swatch", + "swatch_guid": "e5de2e45-207c-46af-9a5e-eb01a0c621fe", + "color": "Green", + "item_name": "(Hat_Baseball)", + "item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "ButtonTop_Orange_Swatch", + "swatch_guid": "e0397982-c2c2-4733-9a40-46e18675b5af", + "color": "Orange", + "item_name": "(ButtonTop)", + "item_guid": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "ButtonTop_Tan_Swatch", + "swatch_guid": "49f5864f-9d40-497c-88c8-e87f64d41d74", + "color": "Tan", + "item_name": "(ButtonTop)", + "item_guid": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "MagicalGirl_Blue_Swatch", + "swatch_guid": "VQjkyb5aoEKHKo_eZsq9ug", + "color": "Blue", + "item_name": "(Wrist_MagicalGirl)", + "item_guid": "eS_or4AnMka9M_bD7YN28g", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "MagicalGirl_Red_Swatch", + "swatch_guid": "FE7_gQNBt0CXzMN6di5HWA", + "color": "Red", + "item_name": "(Wrist_MagicalGirl)", + "item_guid": "eS_or4AnMka9M_bD7YN28g", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "MagicalGirl_Green_Swatch", + "swatch_guid": "kTP3xKhzBkuQF3SqvmYFKA", + "color": "Green", + "item_name": "(Wrist_MagicalGirl)", + "item_guid": "eS_or4AnMka9M_bD7YN28g", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "MagicalGirl_Orange_Swatch", + "swatch_guid": "hYu9itGe1Ee5Neanhj9VtQ", + "color": "Orange", + "item_name": "(Wrist_MagicalGirl)", + "item_guid": "eS_or4AnMka9M_bD7YN28g", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Wizard_SRank_Swatch", + "swatch_guid": "RKmRHk2N_U2gd91nybo6vg", + "color": "SRank", + "item_name": "(Hat_Wizard)", + "item_guid": "01689baf-405e-4b38-9f09-b624e03865b8", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Wizard_Purple_Swatch", + "swatch_guid": "d3KmqVuMQkCYK6sVTMOTOA", + "color": "Purple", + "item_name": "(Hat_Wizard)", + "item_guid": "01689baf-405e-4b38-9f09-b624e03865b8", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Wizard_White_Swatch", + "swatch_guid": "d3a9b576-5433-4305-bd96-b08820c09212", + "color": "White", + "item_name": "(Hat_Wizard)", + "item_guid": "01689baf-405e-4b38-9f09-b624e03865b8", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Wizard_Green_Swatch", + "swatch_guid": "357eb160-360b-4a63-840d-65d11cc8ffc2", + "color": "Green", + "item_name": "(Hat_Wizard)", + "item_guid": "01689baf-405e-4b38-9f09-b624e03865b8", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Wizard_Black_Swatch", + "swatch_guid": "7afe1bb0-5ca9-4263-8f85-49d3a18353c7", + "color": "Black", + "item_name": "(Hat_Wizard)", + "item_guid": "01689baf-405e-4b38-9f09-b624e03865b8", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Wizard_Red_Swatch", + "swatch_guid": "a38254c0-00e3-429b-9d5d-b9e6a9812e69", + "color": "Red", + "item_name": "(Hat_Wizard)", + "item_guid": "01689baf-405e-4b38-9f09-b624e03865b8", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Punk_Pink_Swatch", + "swatch_guid": "4169bfed-8403-4590-a29d-4941275ac0b6", + "color": "Pink", + "item_name": "(Wrist_Punk)", + "item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Punk_WristPink_Swatch", + "swatch_guid": "f3b2330a-b981-4c7e-902f-1f57382f2388", + "color": "WristPink", + "item_name": "(Wrist_Punk)", + "item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Punk_WristRed_Swatch", + "swatch_guid": "baffdefe-de21-4551-9330-b50873965bdd", + "color": "WristRed", + "item_name": "(Wrist_Punk)", + "item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Punk_Green_Swatch", + "swatch_guid": "0724b166-9ace-46ff-a191-b13f135b00a8", + "color": "Green", + "item_name": "(Wrist_Punk)", + "item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Punk_WristGreen_Swatch", + "swatch_guid": "a6796264-9c7e-4605-830c-06db8d0dc780", + "color": "WristGreen", + "item_name": "(Wrist_Punk)", + "item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Hat_GraduationCap_Green_Swatch", + "swatch_guid": "FaWMLkJJgEKFIO493fzGxg", + "color": "Green", + "item_name": "(Hat_GraduationCap)", + "item_guid": "KPmC_wyX_ECe7fdKwoLMGg", + "item_outfit_type": 0, + "mask_guid": "yzL0M_N5Q0eAa8IxACHPAQ" + }, + { + "swatch_name": "EquestrianJacket_Maroon_Swatch", + "swatch_guid": "d6823e01-69f0-4f85-b94a-74894356a2cf", + "color": "Maroon", + "item_name": "(EquestrianJacket)", + "item_guid": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "EquestrianJacket_Gray_Swatch", + "swatch_guid": "4828b50c-95b6-466a-bb25-514891d78202", + "color": "Gray", + "item_name": "(EquestrianJacket)", + "item_guid": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Shirt_VampireHunter_White_Swatch", + "swatch_guid": "9QG6qp5FBU-u0-JK-yMMqA", + "color": "White", + "item_name": "(Shirt_VampireHunter)", + "item_guid": "JWfs9IiQs0Weu8MmyGpqLQ", + "item_outfit_type": 101, + "mask_guid": "x10FeRBsQU2DIIpG8XIlwg" + }, + { + "swatch_name": "Shirt_VampireHunter_Midnight_Swatch", + "swatch_guid": "CavRDqIlmUeTZBtCLn1hZQ", + "color": "Midnight", + "item_name": "(Shirt_VampireHunter)", + "item_guid": "JWfs9IiQs0Weu8MmyGpqLQ", + "item_outfit_type": 101, + "mask_guid": "x10FeRBsQU2DIIpG8XIlwg" + }, + { + "swatch_name": "Shirt_VampireHunter_Leather_Swatch", + "swatch_guid": "Z8Bfk0MTb0evZehvvgzuBA", + "color": "Leather", + "item_name": "(Shirt_VampireHunter)", + "item_guid": "JWfs9IiQs0Weu8MmyGpqLQ", + "item_outfit_type": 101, + "mask_guid": "x10FeRBsQU2DIIpG8XIlwg" + }, + { + "swatch_name": "Scallywag_Bracelet_SRank_Swatch", + "swatch_guid": "X3MkhL55Rk-0UK6h4dw0Eg", + "color": "SRank", + "item_name": "(Wrist_Scallywag_Bracelet)", + "item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Scallywag_Bracelet_Red_Swatch", + "swatch_guid": "62bd5dda-08da-4528-83c1-f0ca3812dc6e", + "color": "Red", + "item_name": "(Wrist_Scallywag_Bracelet)", + "item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Scallywag_Bracelet_Blue_Swatch", + "swatch_guid": "cdca593a-8840-4140-9145-fe962732040c", + "color": "Blue", + "item_name": "(Wrist_Scallywag_Bracelet)", + "item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "PurpleJacket_Red_Swatch", + "swatch_guid": "657f28d5-672a-4f27-86c6-3cb00f25bb84", + "color": "Red", + "item_name": "(PurpleJacket)", + "item_guid": "745c5110-104e-4b37-aabf-7c690fd54811", + "item_outfit_type": 101, + "mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c" + }, + { + "swatch_name": "PurpleJacket_Blue_Swatch", + "swatch_guid": "b42d903b-b393-4f49-af5e-38d0fb6c42b0", + "color": "Blue", + "item_name": "(PurpleJacket)", + "item_guid": "745c5110-104e-4b37-aabf-7c690fd54811", + "item_outfit_type": 101, + "mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c" + }, + { + "swatch_name": "PurpleJacket_Black_Swatch", + "swatch_guid": "8cab8cc6-9a2d-434c-8b72-e66edbdd223c", + "color": "Black", + "item_name": "(PurpleJacket)", + "item_guid": "745c5110-104e-4b37-aabf-7c690fd54811", + "item_outfit_type": 101, + "mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c" + }, + { + "swatch_name": "FighterPilot_Orange_Swatch", + "swatch_guid": "cea53c7f-24a9-4e3b-80b9-fbf95738f973", + "color": "Orange", + "item_name": "(Wrist_FighterPilot)", + "item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8", + "item_outfit_type": 200, + "mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7" + }, + { + "swatch_name": "FighterPilot_Black_Swatch", + "swatch_guid": "8c74ee27-3d56-401b-8a8c-7868a80770e8", + "color": "Black", + "item_name": "(Wrist_FighterPilot)", + "item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8", + "item_outfit_type": 200, + "mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7" + }, + { + "swatch_name": "FighterPilot_Blue_Swatch", + "swatch_guid": "e37b07d1-6b9d-49b9-94ca-1c728e228cfe", + "color": "Blue", + "item_name": "(Wrist_FighterPilot)", + "item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8", + "item_outfit_type": 200, + "mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7" + }, + { + "swatch_name": "Neck_Grillmaster_Denim_Swatch", + "swatch_guid": "eV3Nv6SWS0yl3f-fgjPb-w", + "color": "Denim", + "item_name": "(Neck_Grillmaster)", + "item_guid": "tTKv7eRODka2KC_hsY2O3Q", + "item_outfit_type": 100, + "mask_guid": "t-ZEWJCgFEyGP2PjNblQMA" + }, + { + "swatch_name": "Neck_Grillmaster_White_Swatch", + "swatch_guid": "Lxx3q6JRnUW_cPG_kGG4ag", + "color": "White", + "item_name": "(Neck_Grillmaster)", + "item_guid": "tTKv7eRODka2KC_hsY2O3Q", + "item_outfit_type": 100, + "mask_guid": "t-ZEWJCgFEyGP2PjNblQMA" + }, + { + "swatch_name": "BabydollDress_Green_Swatch", + "swatch_guid": "c9589974-c261-485e-b571-cb2f34fb178f", + "color": "Green", + "item_name": "(BabydollDress)", + "item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "BabydollDress_Black_Swatch", + "swatch_guid": "e13cf33d-7588-4e2e-955e-f0a29d6380b7", + "color": "Black", + "item_name": "(BabydollDress)", + "item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "BabydollDress_Pink_Swatch", + "swatch_guid": "da6d0ced-3d43-4878-a868-925f2ed6fd5b", + "color": "Pink", + "item_name": "(BabydollDress)", + "item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "WitchHunter_Purple_Swatch", + "swatch_guid": "71e5a0fe-fc8d-420b-af3b-e74160e0d1f3", + "color": "Purple", + "item_name": "(Shirt_WitchHunter)", + "item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "WitchHunter_Green_Swatch", + "swatch_guid": "64545dcc-a8eb-4c69-a539-78ee73a2d327", + "color": "Green", + "item_name": "(Shirt_WitchHunter)", + "item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "WitchHunter_Blue_Swatch", + "swatch_guid": "aBQ3R9boek-mYeSYSNvDbQ", + "color": "Blue", + "item_name": "(Shirt_WitchHunter)", + "item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "WitchHunter_SRank_Swatch", + "swatch_guid": "Tbrc2MlSlE6mKua8qSoluQ", + "color": "SRank", + "item_name": "(Shirt_WitchHunter)", + "item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "WitchHunter_White_Swatch", + "swatch_guid": "e02838f8-4029-455a-b35c-dd40f972b05b", + "color": "White", + "item_name": "(Shirt_WitchHunter)", + "item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "WitchHunter_Red_Swatch", + "swatch_guid": "909ed249-ce09-4304-8b02-56794fa7567d", + "color": "Red", + "item_name": "(Shirt_WitchHunter)", + "item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Smuggler_Green_Swatch", + "swatch_guid": "076c96ae-9f9b-45bf-9dc7-ce73d5ddf422", + "color": "Green", + "item_name": "(Shirt_Smuggler)", + "item_guid": "25d4c652-1730-4925-925e-31a290a2272a", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Smuggler_SRank_Swatch", + "swatch_guid": "bzYdPb8GWUS-CgT4wrziIA", + "color": "SRank", + "item_name": "(Shirt_Smuggler)", + "item_guid": "25d4c652-1730-4925-925e-31a290a2272a", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Smuggler_Grey_Swatch", + "swatch_guid": "90869e86-cbc5-4024-9d08-fcdc41c64eb5", + "color": "Grey", + "item_name": "(Shirt_Smuggler)", + "item_guid": "25d4c652-1730-4925-925e-31a290a2272a", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Smuggler_Dark_Swatch", + "swatch_guid": "e821aa74-2d47-4388-bcfb-1893b8a83a52", + "color": "Dark", + "item_name": "(Shirt_Smuggler)", + "item_guid": "25d4c652-1730-4925-925e-31a290a2272a", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Smuggler_Cherry_Swatch", + "swatch_guid": "fed760b2-15e2-46b2-b1e3-cc3c8d780d5d", + "color": "Cherry", + "item_name": "(Shirt_Smuggler)", + "item_guid": "25d4c652-1730-4925-925e-31a290a2272a", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Smuggler_Yellow_Swatch", + "swatch_guid": "002b07bb-4256-44ce-8ca4-25510e70b6b5", + "color": "Yellow", + "item_name": "(Shirt_Smuggler)", + "item_guid": "25d4c652-1730-4925-925e-31a290a2272a", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "SodaJerk_Green_Swatch", + "swatch_guid": "oKxmH-1s2EyIDkZoOQSQQw", + "color": "Green", + "item_name": "(Shirt_SodaJerk)", + "item_guid": "iai6P7dDTUq8S4hqQmY1YA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "SodaJerk_Mustard_Swatch", + "swatch_guid": "MxnAq_XOw0uTzQ8IIxqb_Q", + "color": "Mustard", + "item_name": "(Shirt_SodaJerk)", + "item_guid": "iai6P7dDTUq8S4hqQmY1YA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "SodaJerk_Blue_Swatch", + "swatch_guid": "XFCpIdeDXUqeYCIgh4fGtg", + "color": "Blue", + "item_name": "(Shirt_SodaJerk)", + "item_guid": "iai6P7dDTUq8S4hqQmY1YA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Hat_TutorialCap_2_Swatch", + "swatch_guid": "gDH1WmW5rk2RdY4wJYYZow", + "color": "2", + "item_name": "(Hat_TutorialCap)", + "item_guid": "M9weaekvIUimiJSVJqZl-w", + "item_outfit_type": 0, + "mask_guid": "GV8z2dlqGEqIAYaGrF1ZYQ" + }, + { + "swatch_name": "Hat_TutorialCap_3_Swatch", + "swatch_guid": "wHJnbDOo8ESmUbQDr0k31w", + "color": "3", + "item_name": "(Hat_TutorialCap)", + "item_guid": "M9weaekvIUimiJSVJqZl-w", + "item_outfit_type": 0, + "mask_guid": "GV8z2dlqGEqIAYaGrF1ZYQ" + }, + { + "swatch_name": "BaseballHelmet_Red_Swatch", + "swatch_guid": "a1c51d1e-1d5f-4f8a-bc0f-6a20eddfe58a", + "color": "Red", + "item_name": "(Hat_BaseballHelmet)", + "item_guid": "293e7ad3-01c9-43af-96e3-06c34145ff12", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "BaseballHelmet_Green_Swatch", + "swatch_guid": "9967ce54-c2b7-437a-946e-11f8ee6d6fdd", + "color": "Green", + "item_name": "(Hat_BaseballHelmet)", + "item_guid": "293e7ad3-01c9-43af-96e3-06c34145ff12", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "TruckerOveralls_Mustard_Swatch", + "swatch_guid": "nKauYAyWhkm2AMHFyC-fLg", + "color": "Mustard", + "item_name": "(Shirt_TruckerOveralls)", + "item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "TruckerOveralls_Red_Swatch", + "swatch_guid": "iokdlBfjuEuAwqIMOQwxdA", + "color": "Red", + "item_name": "(Shirt_TruckerOveralls)", + "item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "TruckerOveralls_Green_Swatch", + "swatch_guid": "EiGgN7uVukqoRE7UJcMLTw", + "color": "Green", + "item_name": "(Shirt_TruckerOveralls)", + "item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "PirateCaptain_Black_Swatch", + "swatch_guid": "d81d0b62-e052-46c2-9252-4bc5fb219f53", + "color": "Black", + "item_name": "(Neck_PirateCaptain)", + "item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc", + "item_outfit_type": 102, + "mask_guid": "" + }, + { + "swatch_name": "PirateCaptain_SRank_Swatch", + "swatch_guid": "xGmnUGF-Ck-jP43r8tVVjg", + "color": "SRank", + "item_name": "(Neck_PirateCaptain)", + "item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc", + "item_outfit_type": 102, + "mask_guid": "" + }, + { + "swatch_name": "PirateCaptain_Red_Swatch", + "swatch_guid": "556141f7-6fbc-4065-9faa-8d8240a93d7f", + "color": "Red", + "item_name": "(Neck_PirateCaptain)", + "item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc", + "item_outfit_type": 102, + "mask_guid": "" + }, + { + "swatch_name": "Bowtie_SodaJerk_Swatch", + "swatch_guid": "e4IDo-fuH0C9YByzcX5k-A", + "color": "SodaJerk", + "item_name": "(Bowtie)", + "item_guid": "83be5ba4-525a-4781-a5f6-839c22e4d1d3", + "item_outfit_type": 100, + "mask_guid": "" + }, + { + "swatch_name": "FlapperGlove_Black_Swatch", + "swatch_guid": "30c61e63-5e09-4a6f-8915-1e1fe4d55cf0", + "color": "Black", + "item_name": "(Wrist_FlapperGlove)", + "item_guid": "473c672d-2b79-48a2-a49d-fd68160c5bde", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "FlapperGlove_Gold_Swatch", + "swatch_guid": "2cf2b644-3fa6-449c-9fe8-d0ab917f3106", + "color": "Gold", + "item_name": "(Wrist_FlapperGlove)", + "item_guid": "473c672d-2b79-48a2-a49d-fd68160c5bde", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "BusinessTie_Valentine_Swatch", + "swatch_guid": "31jelNWFyk6FlWt4HyPAlw", + "color": "Valentine", + "item_name": "(BusinessTie)", + "item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe", + "item_outfit_type": 100, + "mask_guid": "" + }, + { + "swatch_name": "BusinessTie_CandyCane_Swatch", + "swatch_guid": "W4a144so90eRzCojUnDowA", + "color": "CandyCane", + "item_name": "(BusinessTie)", + "item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe", + "item_outfit_type": 100, + "mask_guid": "" + }, + { + "swatch_name": "Vest_Red_Swatch", + "swatch_guid": "ffHKr5EcIU24PbAtWSnalw", + "color": "Red", + "item_name": "(Shirt_Vest)", + "item_guid": "3d9d52cb-6a3d-436b-90e7-e76f238329ee", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "HeadBackBow_Blue_Swatch", + "swatch_guid": "GmS0wKwzVEqdf3NoTMHG5A", + "color": "Blue", + "item_name": "(Hat_HeadBackBow)", + "item_guid": "JTrfBXhIT02o2-ssP94zew", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "HeadBackBow_Red_Swatch", + "swatch_guid": "ZNKGkou_gU-1cxBFy7pDKw", + "color": "Red", + "item_name": "(Hat_HeadBackBow)", + "item_guid": "JTrfBXhIT02o2-ssP94zew", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "HeadBackBow_White_Swatch", + "swatch_guid": "-Iqmonry4EKoqAULCKzRPA", + "color": "White", + "item_name": "(Hat_HeadBackBow)", + "item_guid": "JTrfBXhIT02o2-ssP94zew", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "HeadBackBow_SolidBlack_Swatch", + "swatch_guid": "f_c50saStkaGJuhLjJ9lfA", + "color": "SolidBlack", + "item_name": "(Hat_HeadBackBow)", + "item_guid": "JTrfBXhIT02o2-ssP94zew", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "HeadBackBow_Hearts_Swatch", + "swatch_guid": "D-SngK7hhkOpoZ2aMWv9EA", + "color": "Hearts", + "item_name": "(Hat_HeadBackBow)", + "item_guid": "JTrfBXhIT02o2-ssP94zew", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "HeadBackBow_SolidBlue_Swatch", + "swatch_guid": "8iZO162KNUiMC8l_z3OC_A", + "color": "SolidBlue", + "item_name": "(Hat_HeadBackBow)", + "item_guid": "JTrfBXhIT02o2-ssP94zew", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "HeadBackBow_SolidPink_Swatch", + "swatch_guid": "FUP4iRnoc0ikNmjrFD06lg", + "color": "SolidPink", + "item_name": "(Hat_HeadBackBow)", + "item_guid": "JTrfBXhIT02o2-ssP94zew", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "FurCoat_Black_Swatch", + "swatch_guid": "43501b0c-eaaa-4ab3-9d03-2ca61d2e8fc9", + "color": "Black", + "item_name": "(FurCoat)", + "item_guid": "77c37481-a185-47ff-af55-65f9fdfcf9e5", + "item_outfit_type": 101, + "mask_guid": "2c0d912d-c184-4eab-8ee4-73b90ba7b0a6" + }, + { + "swatch_name": "FurCoat_White_Swatch", + "swatch_guid": "643b5f2d-0666-4869-b699-03742de34d16", + "color": "White", + "item_name": "(FurCoat)", + "item_guid": "77c37481-a185-47ff-af55-65f9fdfcf9e5", + "item_outfit_type": 101, + "mask_guid": "2c0d912d-c184-4eab-8ee4-73b90ba7b0a6" + }, + { + "swatch_name": "FlapperHeadband_Gold_Swatch", + "swatch_guid": "b6c25a3b-125b-4aab-bed0-e6e5ced2c21c", + "color": "Gold", + "item_name": "(Hat_FlapperHeadband)", + "item_guid": "18feb3c2-049f-4f91-9f4e-6b5fcb4ab70c", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "FlapperHeadband_Black_Swatch", + "swatch_guid": "565c17e0-496d-45bd-a2c3-564e118c06cc", + "color": "Black", + "item_name": "(Hat_FlapperHeadband)", + "item_guid": "18feb3c2-049f-4f91-9f4e-6b5fcb4ab70c", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Wrist_VampireHunter_Red_Swatch", + "swatch_guid": "iJW8XGJfU0G12iXarjxhdQ", + "color": "Red", + "item_name": "(Wrist_VampireHunter)", + "item_guid": "_OWVy3z6iU-M3-zbQgSLig", + "item_outfit_type": 200, + "mask_guid": "N5wN1l0tYEau5np-9PoIiA" + }, + { + "swatch_name": "Wrist_VampireHunter_Black_Swatch", + "swatch_guid": "Vxq6v55a_kGPODzwB5MC1g", + "color": "Black", + "item_name": "(Wrist_VampireHunter)", + "item_guid": "_OWVy3z6iU-M3-zbQgSLig", + "item_outfit_type": 200, + "mask_guid": "N5wN1l0tYEau5np-9PoIiA" + }, + { + "swatch_name": "Wrist_VampireHunter_Leather_Swatch", + "swatch_guid": "D0tP58wC30mJ00vNxw8hqA", + "color": "Leather", + "item_name": "(Wrist_VampireHunter)", + "item_guid": "_OWVy3z6iU-M3-zbQgSLig", + "item_outfit_type": 200, + "mask_guid": "N5wN1l0tYEau5np-9PoIiA" + }, + { + "swatch_name": "PeaCoat_Purple_Swatch", + "swatch_guid": "uiBklotTUEqRI2F67vyJyA", + "color": "Purple", + "item_name": "(Shirt_Peacoat)", + "item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "PeaCoat_Navy_Swatch", + "swatch_guid": "fvoaMN4M10WS6jiA6XlOJQ", + "color": "Navy", + "item_name": "(Shirt_Peacoat)", + "item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "PeaCoat_Tan_Swatch", + "swatch_guid": "PwsLxCnzOkSW-RGz3tNb1w", + "color": "Tan", + "item_name": "(Shirt_Peacoat)", + "item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "PeaCoat_Red_Swatch", + "swatch_guid": "jXBi28cRV0CEQgKIrpD0_A", + "color": "Red", + "item_name": "(Shirt_Peacoat)", + "item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Hat_CatEars_Gray_Swatch", + "swatch_guid": "96e42c7a-2881-4299-a2fe-80c0d4d5ca26", + "color": "Gray", + "item_name": "(Hat_CatEars)", + "item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62", + "item_outfit_type": 0, + "mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69" + }, + { + "swatch_name": "Hat_CatEars_Brown_Swatch", + "swatch_guid": "713781e5-ce47-41c6-a24f-94d42a565e30", + "color": "Brown", + "item_name": "(Hat_CatEars)", + "item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62", + "item_outfit_type": 0, + "mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69" + }, + { + "swatch_name": "Hat_CatEars_White_Swatch", + "swatch_guid": "c1e430c5-fd8d-4032-9e3f-8d87cc8784da", + "color": "White", + "item_name": "(Hat_CatEars)", + "item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62", + "item_outfit_type": 0, + "mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69" + }, + { + "swatch_name": "Hat_CatEars_Gold_Swatch", + "swatch_guid": "37f8378b-b98c-477a-b8c9-9340772800b5", + "color": "Gold", + "item_name": "(Hat_CatEars)", + "item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62", + "item_outfit_type": 0, + "mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69" + }, + { + "swatch_name": "TruckerJacket_White_Swatch", + "swatch_guid": "yA0KhVg19kWqG1UkyxQogQ", + "color": "White", + "item_name": "(Shirt_TruckerJacket)", + "item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "TruckerJacket_Tan_Swatch", + "swatch_guid": "o2WfRyjJy0CKoUg8YIxNjQ", + "color": "Tan", + "item_name": "(Shirt_TruckerJacket)", + "item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "TruckerJacket_RebelRose_Swatch", + "swatch_guid": "4Eck_dNL1UKlIZWsA1YppQ", + "color": "RebelRose", + "item_name": "(Shirt_TruckerJacket)", + "item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "TruckerJacket_Black_Swatch", + "swatch_guid": "P9bBSL0YLEyjN8PxMfgbKQ", + "color": "Black", + "item_name": "(Shirt_TruckerJacket)", + "item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "TruckerJacket_Olive_Swatch", + "swatch_guid": "bgRKH1wfqkWVHcuSnFYnlQ", + "color": "Olive", + "item_name": "(Shirt_TruckerJacket)", + "item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "80sSpandex_White_Swatch", + "swatch_guid": "zu0Jk24WTkan6XWSwGU1Uw", + "color": "White", + "item_name": "(Shirt_80sSpandex)", + "item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "80sSpandex_Red_Swatch", + "swatch_guid": "6t2rCb6WWEW6pJbcjsAFCw", + "color": "Red", + "item_name": "(Shirt_80sSpandex)", + "item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "80sSpandex_Green_Swatch", + "swatch_guid": "3frH6KxZ9kGUYkjeCDpylQ", + "color": "Green", + "item_name": "(Shirt_80sSpandex)", + "item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "80sSpandex_Blue_Swatch", + "swatch_guid": "Lf4FjROSAEqXedFxpXFQfg", + "color": "Blue", + "item_name": "(Shirt_80sSpandex)", + "item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "80sSpandex_Yellow_Swatch", + "swatch_guid": "i1XbjBqTu0CILIuJClWimA", + "color": "Yellow", + "item_name": "(Shirt_80sSpandex)", + "item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "WaitressApron_Red_Swatch", + "swatch_guid": "HdwZhjrqxE6wuf68MnDhDw", + "color": "Red", + "item_name": "(Belt_WaitressApron)", + "item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7", + "item_outfit_type": 102, + "mask_guid": "" + }, + { + "swatch_name": "WaitressApron_Mustard_Swatch", + "swatch_guid": "b7oobDcDtE-69fhM-5g5AQ", + "color": "Mustard", + "item_name": "(Belt_WaitressApron)", + "item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7", + "item_outfit_type": 102, + "mask_guid": "" + }, + { + "swatch_name": "WaitressApron_Green_Swatch", + "swatch_guid": "Blev_VFjRUysY_nymGWAVg", + "color": "Green", + "item_name": "(Belt_WaitressApron)", + "item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7", + "item_outfit_type": 102, + "mask_guid": "" + }, + { + "swatch_name": "Shirt_MartyRecFly_Hipster_Swatch", + "swatch_guid": "kkvNbVRjcEKtChpy3W1bqQ", + "color": "Hipster", + "item_name": "(Shirt_MartyRecFly)", + "item_guid": "74c65554-0d15-409b-b9a8-0b2620a7d7e2", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Hat_SkiBuddy_White_Swatch", + "swatch_guid": "KwCEye0nsESFs-yj4h9sjQ", + "color": "White", + "item_name": "(Hat_SkiBuddy)", + "item_guid": "6QT99hx6DE6t9cKAs61i3w", + "item_outfit_type": 0, + "mask_guid": "c_t9XDbn-Eqml1O-wC5zJQ" + }, + { + "swatch_name": "Wrist_Grillmaster_Blue_Swatch", + "swatch_guid": "90qQe0AR3kOou0MsuoTmPA", + "color": "Blue", + "item_name": "(Wrist_Grillmaster)", + "item_guid": "q7pSsRMNHkqEJmYHuhhG6g", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Wrist_Grillmaster_Black_Swatch", + "swatch_guid": "hZKPr4sg10SRj9DLuEgOrw", + "color": "Black", + "item_name": "(Wrist_Grillmaster)", + "item_guid": "q7pSsRMNHkqEJmYHuhhG6g", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Scientist_Blue_Swatch", + "swatch_guid": "KTidYfH0ckWxJGs5qzUBTQ", + "color": "Blue", + "item_name": "(Hair_Scientist)", + "item_guid": "f17a2f9e-1eab-44e8-b10a-1f53c5d0778c", + "item_outfit_type": 2, + "mask_guid": "" + }, + { + "swatch_name": "RedFlowerDress_Gray_Swatch", + "swatch_guid": "1acb1f56-4509-449f-9c6a-065bdeaa9ee3", + "color": "Gray", + "item_name": "(RedFlowerDress)", + "item_guid": "515dfc36-7f7b-47bc-abee-380d68f41be6", + "item_outfit_type": 101, + "mask_guid": "2d7361d2-31a2-404b-90da-10e89114e1f2" + }, + { + "swatch_name": "RedFlowerDress_Yellow_Swatch", + "swatch_guid": "e98c3f98-7844-4d6e-a21c-05027033c18c", + "color": "Yellow", + "item_name": "(RedFlowerDress)", + "item_guid": "515dfc36-7f7b-47bc-abee-380d68f41be6", + "item_outfit_type": 101, + "mask_guid": "2d7361d2-31a2-404b-90da-10e89114e1f2" + }, + { + "swatch_name": "YellowBlueDress_White_Swatch", + "swatch_guid": "bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a", + "color": "White", + "item_name": "(YellowBlueDress)", + "item_guid": "2296ed0d-df56-4d46-b33a-aae9230a47fc", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "YellowBlueDress_Purple_Swatch", + "swatch_guid": "830be2fa-60a5-48cc-931f-34b670eae4bd", + "color": "Purple", + "item_name": "(YellowBlueDress)", + "item_guid": "2296ed0d-df56-4d46-b33a-aae9230a47fc", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "TennisSkirt_Yellow_Swatch", + "swatch_guid": "c5deba2a-6e35-4b13-8e94-8ba5457f39df", + "color": "Yellow", + "item_name": "(TennisSkirt)", + "item_guid": "21caa68e-c3fa-474c-af5e-af1e742b7a60", + "item_outfit_type": 101, + "mask_guid": "b75ef67d-00c3-4ac1-9b72-212032460294" + }, + { + "swatch_name": "TennisSkirt_Red_Swatch", + "swatch_guid": "758752bd-db2f-43d2-b580-55b3e1efffd5", + "color": "Red", + "item_name": "(TennisSkirt)", + "item_guid": "21caa68e-c3fa-474c-af5e-af1e742b7a60", + "item_outfit_type": 101, + "mask_guid": "b75ef67d-00c3-4ac1-9b72-212032460294" + }, + { + "swatch_name": "LaserTag_Cyan_Swatch", + "swatch_guid": "08b7c12d-3317-4d27-9b40-34737a709454", + "color": "Cyan", + "item_name": "(Hat_LaserTag)", + "item_guid": "123de433-a005-4994-8a11-d2d84ad66b11", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "LaserTag_Green_Swatch", + "swatch_guid": "2Z_FxSPiyUODtlFZWF9DFQ", + "color": "Green", + "item_name": "(Hat_LaserTag)", + "item_guid": "123de433-a005-4994-8a11-d2d84ad66b11", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "LaserTag_Orange_Swatch", + "swatch_guid": "jkuw9EhCpEKn-2lKJXWd3Q", + "color": "Orange", + "item_name": "(Hat_LaserTag)", + "item_guid": "123de433-a005-4994-8a11-d2d84ad66b11", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Letterman_Red_Swatch", + "swatch_guid": "VpqHNpPiY0CfRJEjRRM-mw", + "color": "Red", + "item_name": "(Wrist_Letterman)", + "item_guid": "2dFMy4zc7ky_Fd5fzfgInw", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Letterman_Purple_Swatch", + "swatch_guid": "iufpnteVCEiiZu_m-yPovQ", + "color": "Purple", + "item_name": "(Wrist_Letterman)", + "item_guid": "2dFMy4zc7ky_Fd5fzfgInw", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Letterman_Black_Swatch", + "swatch_guid": "Z1F9rjKqCE-Hd2-Ip5g3gA", + "color": "Black", + "item_name": "(Wrist_Letterman)", + "item_guid": "2dFMy4zc7ky_Fd5fzfgInw", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Letterman_Green_Swatch", + "swatch_guid": "apSQiQ1eO0mEzRsbugeu9w", + "color": "Green", + "item_name": "(Wrist_Letterman)", + "item_guid": "2dFMy4zc7ky_Fd5fzfgInw", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "TrackSuit_Pink_Swatch", + "swatch_guid": "026ca50e-3a81-43cf-87f5-950687b7bbdd", + "color": "Pink", + "item_name": "(TrackSuit)", + "item_guid": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba", + "item_outfit_type": 101, + "mask_guid": "adae82eb-a4b9-4541-acf2-76cea839a593" + }, + { + "swatch_name": "QuiltedCoat_Orange_Swatch", + "swatch_guid": "-HYrY2A5Tk2UWMi4_hmYbg", + "color": "Orange", + "item_name": "(Shirt_Quiltedcoat)", + "item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "QuiltedCoat_Green_Swatch", + "swatch_guid": "QBglgj80zkuvUkk55Y5Feg", + "color": "Green", + "item_name": "(Shirt_Quiltedcoat)", + "item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "QuiltedCoat_Plum_Swatch", + "swatch_guid": "IjRMopsYlUqSE6327I9TIQ", + "color": "Plum", + "item_name": "(Shirt_Quiltedcoat)", + "item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Shirt_SkiBuddy_Blue_Swatch", + "swatch_guid": "fXNkMWFMV0aXalK_y_8SHw", + "color": "Blue", + "item_name": "(Shirt_SkiBuddy)", + "item_guid": "6RhwWExXekaYpK0NGHSXGQ", + "item_outfit_type": 101, + "mask_guid": "HQQWccdyakaEmntPhvC6CQ" + }, + { + "swatch_name": "Dodgeball_Gray_Swatch", + "swatch_guid": "0ba92db2-690a-44ce-92f4-130e9503b6c1", + "color": "Gray", + "item_name": "(Shirt_Dodgeball)", + "item_guid": "d3d69bee-60bc-44a3-ad7c-65b3b69d1e59", + "item_outfit_type": 101, + "mask_guid": "944d934d-5e21-42d7-8196-fff9a81ee469" + }, + { + "swatch_name": "Dodgeball_Black_Swatch", + "swatch_guid": "4a85c181-92fe-47a5-925e-05f0ccb25006", + "color": "Black", + "item_name": "(Shirt_Dodgeball)", + "item_guid": "d3d69bee-60bc-44a3-ad7c-65b3b69d1e59", + "item_outfit_type": 101, + "mask_guid": "944d934d-5e21-42d7-8196-fff9a81ee469" + }, + { + "swatch_name": "Hoodie_Gray_Swatch", + "swatch_guid": "JTgXKeT7w0K2Mw4Jk2HCqg", + "color": "Gray", + "item_name": "(Shirt_Hoodie)", + "item_guid": "b6rLwzD4NkKV7xKn9ZYVkA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Hoodie_Green_Swatch", + "swatch_guid": "ytmSzjhCr0Sm58MSNbwJUA", + "color": "Green", + "item_name": "(Shirt_Hoodie)", + "item_guid": "b6rLwzD4NkKV7xKn9ZYVkA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Hoodie_Blue_Swatch", + "swatch_guid": "u_Gzj9Nro0WS48xKnH9few", + "color": "Blue", + "item_name": "(Shirt_Hoodie)", + "item_guid": "b6rLwzD4NkKV7xKn9ZYVkA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Hoodie_Pink_Swatch", + "swatch_guid": "TEgugZRs80iJbhL87RdYwg", + "color": "Pink", + "item_name": "(Shirt_Hoodie)", + "item_guid": "b6rLwzD4NkKV7xKn9ZYVkA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Hoodie_Orange_Swatch", + "swatch_guid": "rNhOQoP3XkOBHV28ze2NpA", + "color": "Orange", + "item_name": "(Shirt_Hoodie)", + "item_guid": "b6rLwzD4NkKV7xKn9ZYVkA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Hoodie_Purple_Swatch", + "swatch_guid": "mvMeyStxGUGlNLSYNeSubQ", + "color": "Purple", + "item_name": "(Shirt_Hoodie)", + "item_guid": "b6rLwzD4NkKV7xKn9ZYVkA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Hoodie_Red_Swatch", + "swatch_guid": "yM5xRrd9OkmDGyQddwOP6g", + "color": "Red", + "item_name": "(Shirt_Hoodie)", + "item_guid": "b6rLwzD4NkKV7xKn9ZYVkA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Hoodie_White_Swatch", + "swatch_guid": "-Nf7WM2TeEKujB637h3tRA", + "color": "White", + "item_name": "(Shirt_Hoodie)", + "item_guid": "b6rLwzD4NkKV7xKn9ZYVkA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "DeepSeaDiver_Black_Swatch", + "swatch_guid": "CahBzgN3D0y46AQsvGC14A", + "color": "Black", + "item_name": "(Hat_DeepSeaDiver)", + "item_guid": "4fcb5b84-13a9-43e6-9680-72b17874ae53", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "DeepSeaDiver_Beige_Swatch", + "swatch_guid": "Su7kiW24d0KapNns96JoFQ", + "color": "Beige", + "item_name": "(Hat_DeepSeaDiver)", + "item_guid": "4fcb5b84-13a9-43e6-9680-72b17874ae53", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "QueenCrown_Black_Swatch", + "swatch_guid": "rmq1AOQvtUGOHvbPUximwg", + "color": "Black", + "item_name": "(Hat_QueenCrown)", + "item_guid": "9d395a84-4342-4b1c-940c-213ec2eb56f9", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "QueenCrown_White_Swatch", + "swatch_guid": "Zljmtj5WqUaRvB3XQTgJXg", + "color": "White", + "item_name": "(Hat_QueenCrown)", + "item_guid": "9d395a84-4342-4b1c-940c-213ec2eb56f9", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Jazzercise_Black_Swatch", + "swatch_guid": "3b2cfc85-4e2a-4e0d-b738-c296ae8692f8", + "color": "Black", + "item_name": "(Hair_Jazzercise)", + "item_guid": "49397e16-1027-4286-9bc4-b59eae565ff0", + "item_outfit_type": 2, + "mask_guid": "" + }, + { + "swatch_name": "Jazzercise_Yellow_Swatch", + "swatch_guid": "78eb0969-f246-4cac-acd6-295df7c092e4", + "color": "Yellow", + "item_name": "(Hair_Jazzercise)", + "item_guid": "49397e16-1027-4286-9bc4-b59eae565ff0", + "item_outfit_type": 2, + "mask_guid": "" + }, + { + "swatch_name": "FieldHockey_Blue_Swatch", + "swatch_guid": "7b0d6661-e856-4ec1-ab90-d89c28a70826", + "color": "Blue", + "item_name": "(FieldHockey)", + "item_guid": "a986ff33-9e16-4219-8ebb-2e7b4c772ca7", + "item_outfit_type": 101, + "mask_guid": "1fede91a-434e-49a5-882e-e6db107112a8" + }, + { + "swatch_name": "FlowerSwimsuit_Green_Swatch", + "swatch_guid": "cf515c87-3c0a-4443-9f63-5ea1bdbaa61a", + "color": "Green", + "item_name": "(FlowerSwimsuit)", + "item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d", + "item_outfit_type": 101, + "mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622" + }, + { + "swatch_name": "FlowerSwimsuit_Pink_Swatch", + "swatch_guid": "27680277-aec1-41be-a476-9f51cf775580", + "color": "Pink", + "item_name": "(FlowerSwimsuit)", + "item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d", + "item_outfit_type": 101, + "mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622" + }, + { + "swatch_name": "FlowerSwimsuit_Gray_Swatch", + "swatch_guid": "5a96ac4d-03c3-4214-9dcb-c2044ff28f2e", + "color": "Gray", + "item_name": "(FlowerSwimsuit)", + "item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d", + "item_outfit_type": 101, + "mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622" + }, + { + "swatch_name": "Angler_Blue_Swatch", + "swatch_guid": "6e0f3af8-297f-4ea7-8f9e-4a1bc57d3e35", + "color": "Blue", + "item_name": "(Hat_Angler)", + "item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Angler_Tan_Swatch", + "swatch_guid": "cac27853-e2df-4d77-b3fd-af3bc0813f01", + "color": "Tan", + "item_name": "(Hat_Angler)", + "item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Angler_Gray_Swatch", + "swatch_guid": "97938f4c-52b8-4c85-8575-856654666316", + "color": "Gray", + "item_name": "(Hat_Angler)", + "item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Artist_Blue_Swatch", + "swatch_guid": "8c94e45c-6605-4b4f-9ba4-2c6c6e557228", + "color": "Blue", + "item_name": "(Shirt_Artist)", + "item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Artist_Red_Swatch", + "swatch_guid": "17b16982-871c-476a-860f-f6953c89ee8c", + "color": "Red", + "item_name": "(Shirt_Artist)", + "item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "HockeyShirt_Red_Swatch", + "swatch_guid": "b1ea158d-1663-4db1-bb09-9847f6e9d1e6", + "color": "Red", + "item_name": "(HockeyShirt)", + "item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c", + "item_outfit_type": 101, + "mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b" + }, + { + "swatch_name": "HockeyShirt_Teal_Swatch", + "swatch_guid": "fcbdfbfd-254a-4538-b119-43e23404b8b5", + "color": "Teal", + "item_name": "(HockeyShirt)", + "item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c", + "item_outfit_type": 101, + "mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b" + }, + { + "swatch_name": "HockeyShirt_Purple_Swatch", + "swatch_guid": "d5efaf80-7f34-4c9a-bc3a-b80b1ac70ace", + "color": "Purple", + "item_name": "(HockeyShirt)", + "item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c", + "item_outfit_type": 101, + "mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b" + }, + { + "swatch_name": "Hat_SeaCaptain_Contest_Swatch", + "swatch_guid": "hhs9GY3XZkWb-8TXODvo6A", + "color": "Contest", + "item_name": "(Hat_SeaCaptain)", + "item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e", + "item_outfit_type": 0, + "mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9" + }, + { + "swatch_name": "LaserTagGlove_Orange_Swatch", + "swatch_guid": "EC0ttvk6eU2HEMacLAx2Iw", + "color": "Orange", + "item_name": "(Wrist_LaserTagGlove)", + "item_guid": "3044adce-90c0-4f66-81f5-39c4fca86fdf", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "LaserTagGlove_Green_Swatch", + "swatch_guid": "mhsglVEtREmElF5wuLw3NQ", + "color": "Green", + "item_name": "(Wrist_LaserTagGlove)", + "item_guid": "3044adce-90c0-4f66-81f5-39c4fca86fdf", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "50sDress_Red_Swatch", + "swatch_guid": "Yr0mF1EWzEaIyRMOWFiY6w", + "color": "Red", + "item_name": "(Shirt_50sDress)", + "item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "50sDress_Pink_Swatch", + "swatch_guid": "eb3SmJTIh0628LrZUkI4KQ", + "color": "Pink", + "item_name": "(Shirt_50sDress)", + "item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "50sDress_Teal_Swatch", + "swatch_guid": "QzjakcO8tUaPeiI5KBLKtg", + "color": "Teal", + "item_name": "(Shirt_50sDress)", + "item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "50sDress_White_Swatch", + "swatch_guid": "BLgkgLeS-EyazgPQxGcavg", + "color": "White", + "item_name": "(Shirt_50sDress)", + "item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "50sDress_CandyCane_Swatch", + "swatch_guid": "PEGqjoxP6E2yRx_A-HphrQ", + "color": "CandyCane", + "item_name": "(Shirt_50sDress)", + "item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "50sDress_Halloween_Swatch", + "swatch_guid": "ZvPidQ57rUS50Qz89ERmLw", + "color": "Halloween", + "item_name": "(Shirt_50sDress)", + "item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "50sDress_Shamrock_Swatch", + "swatch_guid": "K17dIz_TRUq5D50NBfLjyw", + "color": "Shamrock", + "item_name": "(Shirt_50sDress)", + "item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "TopHat_White_Swatch", + "swatch_guid": "DzgN67HoikexE2rTlZLdYQ", + "color": "White", + "item_name": "(Hat_TopHat)", + "item_guid": "102c625b-b988-4bf8-a2aa-a31ad7029cdc", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Shirt_GraduationGown_Green_Swatch", + "swatch_guid": "iZy7RmfAz0603oMO-jD-WA", + "color": "Green", + "item_name": "(Shirt_GraduationGown)", + "item_guid": "U_H976qYRUmg9e2clsAflg", + "item_outfit_type": 101, + "mask_guid": "ica0LZyM_kOox8gxW7CaMw" + }, + { + "swatch_name": "DrumMajor_Orange_Swatch", + "swatch_guid": "82de1ca3-67d6-454d-84fb-0eeece71fcbc", + "color": "Orange", + "item_name": "(Hat_DrumMajor)", + "item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "DrumMajor_Green_Swatch", + "swatch_guid": "fe5288bc-0e02-4ea3-b17d-dee0e576979d", + "color": "Green", + "item_name": "(Hat_DrumMajor)", + "item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "DrumMajor_Purple_Swatch", + "swatch_guid": "54d3c589-30ba-44c1-a77c-8f9e16eb0b20", + "color": "Purple", + "item_name": "(Hat_DrumMajor)", + "item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Harness_Teal_Swatch", + "swatch_guid": "TnEDe1QoMUCUdqPL_7CxTA", + "color": "Teal", + "item_name": "(Shirt_Harness)", + "item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Harness_Black_Swatch", + "swatch_guid": "AC3zfm38JEyBZWuXDh4Ukg", + "color": "Black", + "item_name": "(Shirt_Harness)", + "item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Harness_Yellow_Swatch", + "swatch_guid": "dyEaPloE8kazWC7Zblv8uQ", + "color": "Yellow", + "item_name": "(Shirt_Harness)", + "item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Harness_Blue_Swatch", + "swatch_guid": "RfZNDPUok0WyQEa3Ko08ow", + "color": "Blue", + "item_name": "(Shirt_Harness)", + "item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Harness_Orange_Swatch", + "swatch_guid": "sX0ZZDcCUkyFSJISfvq0vg", + "color": "Orange", + "item_name": "(Shirt_Harness)", + "item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Unicorn_Pastel_Swatch", + "swatch_guid": "FNe9LkWJMUeC3frYJMyEaQ", + "color": "Pastel", + "item_name": "(Hat_Unicorn)", + "item_guid": "2caHvVQ1vECrPv-YcdxdyA", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Unicorn_Glowing_Swatch", + "swatch_guid": "mudt-Z5-5USdnIq-xaDgOw", + "color": "Glowing", + "item_name": "(Hat_Unicorn)", + "item_guid": "2caHvVQ1vECrPv-YcdxdyA", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Countess_Black_Swatch", + "swatch_guid": "i5RKLo-RbEqisnGII1wpaQ", + "color": "Black", + "item_name": "(Shirt_Countess)", + "item_guid": "QCtqxbvYOkan79YFugH_Cw", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Countess_Purple_Swatch", + "swatch_guid": "IzkFARlv6EenmrfcNkcOuw", + "color": "Purple", + "item_name": "(Shirt_Countess)", + "item_guid": "QCtqxbvYOkan79YFugH_Cw", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Countess_Green_Swatch", + "swatch_guid": "LyVf9lnzdku1F4iPBGqk-g", + "color": "Green", + "item_name": "(Shirt_Countess)", + "item_guid": "QCtqxbvYOkan79YFugH_Cw", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "CheerDress_Blue_Swatch", + "swatch_guid": "faedf215-289b-40f7-90cc-f5b98517d133", + "color": "Blue", + "item_name": "(FemaleCheerTorso)", + "item_guid": "657efe89-620a-46a2-8b58-cafd855c7cd5", + "item_outfit_type": 101, + "mask_guid": "93987d32-7a6e-45df-af56-76f912969ce7" + }, + { + "swatch_name": "CheerDress_Red_Swatch", + "swatch_guid": "1773d5d8-4714-4721-b30c-97e25d6f2a5a", + "color": "Red", + "item_name": "Cheerful Dress", + "item_guid": "b02053b3-013f-42b0-8881-85f5be2b8cda", + "item_outfit_type": 101, + "mask_guid": "93987d32-7a6e-45df-af56-76f912969ce7" + }, + { + "swatch_name": "Mobster_Valentine_Swatch", + "swatch_guid": "4VkPVbBVLE-eJHilwGMAbQ", + "color": "Valentine", + "item_name": "(Wrist_Mobster)", + "item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Mobster_WhiteHatHacker_Swatch", + "swatch_guid": "40283127-2ffc-4989-82f6-29c3381bf9e2", + "color": "WhiteHatHacker", + "item_name": "(Wrist_Mobster)", + "item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec", + "item_outfit_type": 200, + "mask_guid": "" + }, + { + "swatch_name": "Luchador_Red_Swatch", + "swatch_guid": "916384ca-e209-42a0-bd64-c64f5a40b56f", + "color": "Red", + "item_name": "(Hat_Luchador)", + "item_guid": "1596c113-aa3a-4f5c-b398-e5c3575548e7", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Luchador_Blue_Swatch", + "swatch_guid": "0217fd2e-ff9d-49f7-9bc8-8d0d6b8bf250", + "color": "Blue", + "item_name": "(Hat_Luchador)", + "item_guid": "1596c113-aa3a-4f5c-b398-e5c3575548e7", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "BlueJacket_Mint_Swatch", + "swatch_guid": "cd5d7285-202d-42d0-b93f-04245875793e", + "color": "Mint", + "item_name": "(BlueJacket)", + "item_guid": "62ce4109-8dee-4895-bf1b-bfa143db4c7e", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Steampunk_Contest_Swatch", + "swatch_guid": "fD5vtKxBWECH54Jj4FbnUg", + "color": "Contest", + "item_name": "(Shirt_SteamPunk)", + "item_guid": "E9VTiIVFPEa-OuQRto0e_Q", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "GoldMedal_Bronze_Swatch", + "swatch_guid": "iaDL2im4lkCXUo0NWM-F3g", + "color": "Bronze", + "item_name": "(GoldMedal)", + "item_guid": "bf13fa1c-f991-4aaa-9402-aac109be9562", + "item_outfit_type": 100, + "mask_guid": "" + }, + { + "swatch_name": "GoldMedal_Silver_Swatch", + "swatch_guid": "ERnUPsyT6kuEnIRZF8zPwg", + "color": "Silver", + "item_name": "(GoldMedal)", + "item_guid": "bf13fa1c-f991-4aaa-9402-aac109be9562", + "item_outfit_type": 100, + "mask_guid": "" + }, + { + "swatch_name": "Turtleneck_White_Swatch", + "swatch_guid": "Zz4XatKfUEapReRAQHxKKA", + "color": "White", + "item_name": "(Shirt_Turtleneck)", + "item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Turtleneck_Yellow_Swatch", + "swatch_guid": "G4HIPUjekUigiU0-Kr9mdA", + "color": "Yellow", + "item_name": "(Shirt_Turtleneck)", + "item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Turtleneck_Green_Swatch", + "swatch_guid": "Jt0QqNrAp0qzUR_RQoivVg", + "color": "Green", + "item_name": "(Shirt_Turtleneck)", + "item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Turtleneck_Red_Swatch", + "swatch_guid": "lBePHNL9LkyaAjEFyFE8Sw", + "color": "Red", + "item_name": "(Shirt_Turtleneck)", + "item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Turtleneck_Orange_Swatch", + "swatch_guid": "9b9t8_aKH0-gQXMhZycaOA", + "color": "Orange", + "item_name": "(Shirt_Turtleneck)", + "item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "PB_Trucker_Red_Swatch", + "swatch_guid": "lDXambcP60CeY2C8qeGDFw", + "color": "Red", + "item_name": "(Hat_PB_TruckerHat)", + "item_guid": "0c537a84-9095-406f-8c87-1464f3a774ba", + "item_outfit_type": 0, + "mask_guid": "" + }, + { + "swatch_name": "Witch_Contest_Swatch", + "swatch_guid": "uTHwo8qd6Ei3Oy4gz_j9Ow", + "color": "Contest", + "item_name": "(Shirt_Witch)", + "item_guid": "7LSm-xCYakmgcAr--Fn-JA", + "item_outfit_type": 101, + "mask_guid": "" + }, + { + "swatch_name": "Tennis_Pink_Swatch", + "swatch_guid": "304e5cf1-5c94-481f-8e49-e6a943e6b300", + "color": "Pink", + "item_name": "(Shirt_Tennis)", + "item_guid": "74b91f2b-d4d3-4e41-8946-b1296ab7a773", + "item_outfit_type": 101, + "mask_guid": "90e0da8b-1941-4ca8-b7e5-b4e31921873c" + } + ], + "unmatched": [ + "TShirt_SquadGoals_Swatch", + "TShirt_HangInThere_Swatch", + "TShirt_Orange_Swatch", + "ZebraWhite_Swatch", + "Winter_Gloves_Tree_Green_Swatch", + "Cyber_Trash_Yellow_Swatch", + "Cyber_Trash_Black_Swatch", + "PoofyDress_Purple_Swatch", + "PoofyDress_Orange_Swatch", + "CowboyCherry_Swatch", + "Mobster_HerringBone_Tan_Swatch", + "Laser_Graffiti_Swatch", + "Laser_Ham_Swatch", + "Shuttershades_Salmon_Swatch", + "Shuttershades_Red_Swatch", + "Shuttershades_Blue_Swatch", + "Shuttershades_Yellow_Swatch", + "Countess_Wrist_Purple_Swatch", + "Class_Of_2018_Swatch", + "FighterPilot_Gloves_Orange_Swatch", + "FighterPilot_Gloves_Blue_Swatch", + "Aviator_Gloves_Tan_Swatch", + "Aviator_Gloves_Black_Swatch", + "Plaid_LightBlue_Swatch", + "Plaid_Red_Swatch", + "Astronaut_Glove_Orange_Swatch", + "Fatigue_Swatch", + "Wrist_CowboyWhite_Swatch", + "Wrist_CowboyCherry_Swatch", + "Wrist_CowboyBlack_Swatch", + "Wrist_CowboyLight_Swatch", + "Wrist_CowboyDark_Swatch", + "Winter_Hat_Tree_Swatch", + "Winter_Hat_Red_Swatch", + "Harness_Hat_Teal_Swatch", + "Harness_Hat_Yellow_Swatch", + "Harness_Hat_White_Swatch", + "Harness_Hat_Orange_Swatch", + "Harness_Hat_Black_Swatch", + "Sherlock_Torso_Gray_Swatch", + "Sherlock_Torso_Black_Swatch", + "BowlingShirt_CenterStripe_Burgandy_Swatch", + "BowlingShirt_CenterStripe_Black_Swatch", + "BowlingShirt_CenterStripe_Yellow_Swatch", + "Scuba_Neck_Blue_Swatch", + "Scuba_Neck_Contest_Swatch", + "Cream_Swatch", + "ArtistHat_Red_Swatch", + "ArtistHat_Blue_Swatch", + "ArcherBlack_Quiver_Swatch", + "ArcherBlack_Swatch", + "ArcherBlack_Glove_Swatch", + "ArcherYellow_Swatch", + "ArcherYellow_Glove_Swatch", + "ArcherYellow_Quiver_Swatch", + "Hat_SeaCaptainDarkDarkGold_Swatch", + "Hat_SeaCaptainFushia_Swatch", + "Hat_SeaCaptainDarkGold_Swatch", + "Hat_SeaCaptainDarkSilver_Swatch", + "PolkaBlue_Swatch", + "Letterman_Wrist_Red_Swatch", + "Letterman_Wrist_Purple_Swatch", + "Letterman_Wrist_Green_Swatch", + "Letterman_Wrist_Black_Swatch", + "Yellow_Swatch", + "Windbreaker_Wrist_Yellow_Swatch", + "Windbreaker_Wrist_Red_Swatch", + "Windbreaker_Wrist_Blue_Swatch", + "Windbreaker_Wrist_Salmon_Swatch", + "Beekeeper_Shirt_Teal_Swatch", + "Beekeeper_Shirt_Lavender_Swatch", + "Beekeeper_Shirt_Gold_Swatch", + "2D_Contest_Winner_Swatch", + "Scarecrow_Hat_Contest_Swatch", + "Red_Swatch", + "Hat_Baseball_ThreeColor_BlueRed_Swatch", + "PirateCaptain_Hat_Black_Swatch", + "PirateCaptain_Hat_SRank_Swatch", + "PirateCaptain_Hat_Red_Swatch", + "RoyalCape_Green_Swatch", + "RoyalCape_Blue_Swatch", + "RoyalCape_Purple_Swatch", + "Grey_Swatch", + "BlazerGreyDark_Swatch", + "MagicalGirl_Wrist_Red_Swatch", + "MagicalGirl_Wrist_Orange_Swatch", + "MagicalGirl_Wrist_Green_Swatch", + "MagicalGirl_Wrist_Blue_Swatch", + "PirateCaptain_Belt_Black_Swatch", + "PirateCaptain_Belt_Red_Swatch", + "BowlingShirt_Flames_Red_Swatch", + "BowlingShirt_Flames_Green_Swatch", + "BowlingShirt_Flames_Pink_Swatch", + "BowlingShirt_Flames_Blue_Swatch", + "RoyalCape_Contest_White_Swatch", + "Barber_Torso_Formal_Swatch", + "Barber_Torso_Green_Swatch", + "Aviator_Goggles_Gray_Swatch", + "Aviator_Goggles_Black_Swatch", + "Neck_TutorialGown3_Black_Swatch", + "SafariTorso_Green_Swatch", + "Scallywag_Hat_Blue_Swatch", + "Scallywag_Hat_Red_Swatch", + "TShirt_Laser_Unicorn_Purple_Swatch", + "Astronaut_Helmet_Orange_Swatch", + "ArcherGray_Quiver_Swatch", + "ArcherGray_Swatch", + "ArcherGray_Glove_Swatch", + "Hoodie_Skeleton_NoGlow_Swatch", + "AnglerHat_Blue_Swatch", + "AnglerHat_Gray_Swatch", + "AnglerHat_Tan_Swatch", + "Scuba_eye_Contest_Swatch", + "Scuba_eye_Blue_Swatch", + "BowlingShirt_OneStripe_Green_Swatch", + "BowlingShirt_OneStripe_Pink_Swatch", + "BowlingShirt_OneStripe_Blue_Swatch", + "Golfer_Shirt_Blue_Swatch", + "Golfer_Shirt_Grey_Swatch", + "PaintBallHat_Red_Swatch", + "PaintBallHat_Green_Swatch", + "PaintBallHat_Mustard_Swatch", + "Shirt_TutorialGown3_Black_Swatch", + "Winter_Scarf_Tree_Green_Swatch", + "Blue_Swatch", + "Barber_Hat_Formal_Swatch", + "Barber_Hat_Green_Swatch", + "Vest_HoundsTooth_Black_Swatch", + "Neck_VampiteHunter_Opal_Swatch", + "Neck_VampiteHunter_Ruby_Swatch", + "Neck_VampiteHunter_Amethyst_Swatch", + "Neck_VampiteHunter_Diamond_Swatch", + "Neck_VampiteHunter_Emerald_Swatch", + "Neck_VampiteHunter_Amber_Swatch", + "Neck_VampiteHunter_Topaz_Swatch", + "Winter_Scarf_Red_Swatch", + "Winter_Scarf_Tree_Swatch", + "Floatie_Donut_Swatch", + "Floatie_Dinosaur_Swatch", + "Rainhat_Hat_Red_Swatch", + "Winter_Gloves_Red_Swatch", + "Winter_Gloves_Tree_Swatch", + "Scout_Sash_Blue_Swatch", + "Scout_Sash_Brown_Swatch", + "Scout_Sash_Red_Swatch", + "RoyalLady_Green_Swatch", + "RoyalLady_Black_Swatch", + "RoyalLady_Burgandy_Swatch", + "Mobster_Hat_Brown_Swatch", + "Mobster_Hat_Purple_Swatch", + "Mobster_Hat_Gray_Swatch", + "ElectricianTorso_Gray_Swatch", + "ElectricianTorso_Yellow_Swatch", + "ElectricianTorso_Green_Swatch", + "ElectricianTorso_Beige_Swatch", + "White_Swatch", + "BeltDress_Green_Swatch", + "BeltDress_Black_Swatch", + "BeltDress_Yellow_Swatch", + "FootballHelmet_Orange_Swatch", + "FootballHelmet_Dark_Swatch", + "Aviator_Jacket_Tan_Swatch", + "Aviator_Jacket_Black_Swatch", + "RoyalCrown_Contest_Swatch", + "RoyalCrown_White_Swatch", + "RoyalCrown_Black_Swatch", + "MagicalGirl_Hat_Blue_Swatch", + "MagicalGirl_Hat_Green_Swatch", + "MagicalGirl_Hat_Red_Swatch", + "MagicalGirl_Hat_Orange_Swatch", + "TShirt_KO_Skull_Swatch", + "RoyalLord_Black_Swatch", + "RoyalLord_Burgandy_Swatch", + "RoyalLord_Green_Swatch", + "Hat_Baseball_Logo_Ramen_Swatch", + "Hat_Baseball_Logo_Bucketeers_Swatch", + "Hat_Baseball_Logo_Goblins_Swatch", + "TShirt_Orange_Video_Contest_Generic_Swatch", + "TShirt_Scifi_Face_BLue_Swatch", + "TShirt_Scifi_Face_Pink_Swatch", + "Green_Swatch", + "BlazerDark_Swatch", + "Witch_Contest_Hat_Swatch", + "Witch_Contest_Wrist_Swatch", + "CowboyWhite_Swatch", + "Aviator_Hat_Tan_Swatch", + "Aviator_Hat_Black_Swatch", + "GreenFlower_Swatch", + "BowlingShirt_Diamonds_Purple_Swatch", + "BowlingShirt_Diamonds_Teal_Swatch", + "BowlingShirt_Diamonds_Blue_Swatch", + "FormalDress_Black_Swatch", + "Birthday01_Swatch", + "Scallywag_Eye_SRank_Swatch", + "Ranger_Shirt_Green_Swatch", + "SciFiVisor_Peach_Swatch", + "SciFiVisor_Blue_Swatch", + "SciFiVisor_Orange_Swatch", + "Shirt_TutorialGown2_Orange_Swatch", + "Scifi_Carpet_Swatch", + "Teal_Swatch", + "Charcoal_Swatch", + "Paintball_Red_Swatch", + "Paintball_Blue_Swatch", + "ArcherTan_Glove_Swatch", + "ArcherTan_Quiver_Swatch", + "ArcherTan_Swatch", + "BicycleJacket_Black_Swatch", + "BicycleJacket_DarkBlue_Swatch", + "BicycleJacket_Blue_Swatch", + "Argyle_Brown_Swatch", + "Argyle_Red_Swatch", + "BasketballShirt_Purple_Swatch", + "BasketballShirt_Blue_Swatch", + "Purple_Swatch", + "ScoutSkirt_Shirt_Red_Swatch", + "LaserTagVisor_Green_Swatch", + "LaserTagVisor_Orange_Swatch", + "LaserTagVisor_Cyan_Swatch", + "TShirt_KO_Skull_Pink_Swatch", + "CollarShirt_Torso_Red_Swatch", + "CollarShirt_Torso_Blue_Swatch", + "CollarShirt_Torso_Pink_Swatch", + "CollarShirt_Torso_GoldYellow_Swatch", + "CollarShirt_Torso_GreenFlower_Swatch", + "CollarShirt_Torso_White_Swatch", + "Pearls_Neck_Black_Swatch", + "MagicalGirl_Neck_Orange_Swatch", + "MagicalGirl_Neck_Red_Swatch", + "MagicalGirl_Neck_Green_Swatch", + "MagicalGirl_Neck_Blue_Swatch", + "Cyber_Junk_City_Swatch", + "Firefighter_Hat_Yellow_Swatch", + "Firefighter_Hat_White_Swatch", + "Scout_RaccoonHat_Gray_Swatch", + "JackfrostWings_Neck_Lilac_Swatch", + "CowboyBlack_Swatch", + "Torso_SeaCaptainBlueWhite_Swatch", + "Torso_SeaCaptainBlueDark2_Swatch", + "Torso_SeaCaptainDark_Swatch", + "Torso_SeaCaptainWhiteDark_Swatch", + "Torso_SeaCaptainBlackDark_Swatch", + "Torso_SeaCaptainBlueDark_Swatch", + "Torso_SeaCaptainBlue_Swatch", + "Astronaut_Torso_Orange_Swatch", + "CowboyDark_Swatch", + "Denim_Swatch", + "FootballPads_Orange_Swatch", + "FootballPads_Dark_Swatch", + "RoyalLady_Contest_White_Swatch", + "WaitressDress_Mustard_Swatch", + "WaitressDress_Green_Swatch", + "WaitressDress_Red_Swatch", + "CollarDress_Pink_Swatch", + "CollarDress_LightBlue_Swatch", + "Scarecrow_Wrist_Contest_Swatch", + "Pink_Swatch", + "Mobster_Fashion_Gold_Swatch", + "ArcherSRank_Glove_Swatch", + "ArcherSRank_Quiver_Swatch", + "ArcherSRank_Swatch", + "Royal_Swatch", + "GoldYellow_Swatch", + "Sherlock_Hat_Gray_Swatch", + "Sherlock_Hat_Black_Swatch", + "Happy_Tree_Purple_Swatch", + "Golfer_Glove_Blue_Swatch", + "Golfer_Glove_Grey_Swatch", + "Steampunk_Femme_Contest_Hat_Swatch", + "TShirt_RECS_Logo_Blue_Swatch", + "TShirt_RECS_Logo_Green_Swatch", + "Scout_Shirt_Blue_Swatch", + "DrumMajorGlove_Purple_Swatch", + "DrumMajorGlove_Orange_Swatch", + "ArcherRed_Glove_Swatch", + "ArcherRed_Swatch", + "ArcherRed_Quiver_Swatch", + "Lacrosse_Stripe_Purple_Swatch", + "Winter_Torso_Tree_Swatch", + "Winter_Torso_Red_Swatch", + "Scuba_Shirt_Contest_Swatch", + "Scuba_Shirt_Blue_Swatch", + "LightBlue_Swatch", + "Winter_Hat_Tree_Green_Swatch", + "CowboyLight_Swatch", + "Happy_Tree_Swatch", + "Winter_Torso_Tree_Green_Swatch", + "BlazerMidnightBlue_Swatch", + "Scifi_Carpet_TopHat_Swatch", + "Rebel_Rose_Swatch", + "Mobster_PinStripe_Purple_Swatch", + "Soccer_Shirt_Blue_Swatch", + "Barber_Wrist_Formal_Swatch", + "Steampunk_Femme_Contest_Swatch", + "StollDress_Green_Swatch", + "StollDress_Blue_Swatch", + "BlazerSeaDark_Swatch", + "Orange_Swatch", + "DrumMajorHat_Green_Swatch", + "Golfer_Hat_Blue_Swatch", + "Golfer_Hat_Grey_Swatch", + "RoyalLord_Contest_White_Swatch", + "Black_Swatch", + "Jackfrost_Shirt_Lilac_Swatch", + "CollarShirt_Torso_Plaid_Red_Swatch", + "TShirt_LaserTag_Alpha_Swatch", + "Countess_Green_Wrist_Swatch", + "Hyper_Noodle_Ramen_Swatch", + "Scarecrow_Shirt_Contest_Swatch", + "BicycleHelmet_Yellow_Swatch", + "Soccer_Glove_Blue_Swatch", + "Steampunk_Contest_Hat_Swatch", + "Emerald_Syndicate_Swatch", + "Neck_TutorialGown2_Orange_Swatch", + "Shirt_TutorialGown1_Purple_Swatch", + "2D_Contest_Winner_Yellow_Swatch", + "BlazerGrey_Swatch", + "Stripe3_Swatch", + "Ranger_Hat_Green_Swatch" + ], + "ambiguous": [ + { + "prefix": "windbreaker", + "candidates": [ + "52VsI_lKlkSUAOJlfDnyrQ", + "AIpF1QUeMUWytpHYBDBAkg" + ] + }, + { + "prefix": "lumberjack", + "candidates": [ + "350c5c1e-318b-4233-a7e5-1f49909d8e65", + "61ba3c90-c81e-4deb-bc79-50c0f1fe3e83", + "b537c30c-faef-4649-9a1f-be3985936f93" + ] + }, + { + "prefix": "magicalgirl", + "candidates": [ + "eS_or4AnMka9M_bD7YN28g", + "hT7nSD7Ts06F_1SNlOMLwg", + "MJkDYDH510izAab13ZfXQw", + "WuShlrJnHUKuscn8e6Rt2g" + ] + }, + { + "prefix": "wizard", + "candidates": [ + "01689baf-405e-4b38-9f09-b624e03865b8", + "22ef1089-d073-4fe8-b038-6f34adf0b85f", + "97eae086-cfea-45ff-9b77-1d184256d493" + ] + }, + { + "prefix": "punk", + "candidates": [ + "28374ebc-4050-4ea0-b3b6-41ebe75cedf7", + "ce250e13-8137-4f62-9e52-2b1cebe7d0ad", + "e4ba395e-f291-4e7b-8193-45f4422f41ce" + ] + }, + { + "prefix": "fighterpilot", + "candidates": [ + "5800d2b5-75b8-442f-8b10-63bd3d73a1b8", + "ec836d2a-b1b8-4566-b8eb-123ae2845956", + "ff7e6d41-5c27-4516-930c-ad0e9a23cce2" + ] + }, + { + "prefix": "witchhunter", + "candidates": [ + "418db0e0-c7af-40c2-9cac-d461ce41e210", + "6d36091e-7083-4e3d-8729-94612602c8cb" + ] + }, + { + "prefix": "smuggler", + "candidates": [ + "25d4c652-1730-4925-925e-31a290a2272a", + "3dec4865-cf23-4c19-a460-dd2db80ee851" + ] + }, + { + "prefix": "sodajerk", + "candidates": [ + "iai6P7dDTUq8S4hqQmY1YA", + "Wua43ED71UqL71ATtyS0iQ" + ] + }, + { + "prefix": "piratecaptain", + "candidates": [ + "38b38e24-d636-41c6-b80e-a285ae3106dc", + "3b00c71f-79c2-4a04-9fc0-a23b4c85562f", + "3df394de-9c3e-4141-aba4-12eac4742102", + "eceb7d49-be41-4228-86a9-2973d62e7135" + ] + }, + { + "prefix": "truckerjacket", + "candidates": [ + "8b065e2a-b106-4f11-b415-4c47ce87993e", + "973d84ee-5b88-475c-b579-a76c34a7c533" + ] + }, + { + "prefix": "scientist", + "candidates": [ + "f17a2f9e-1eab-44e8-b10a-1f53c5d0778c", + "fe34adaf-47d3-435b-b2fd-b2fee05fceae" + ] + }, + { + "prefix": "lasertag", + "candidates": [ + "123de433-a005-4994-8a11-d2d84ad66b11", + "24d94673-f394-42e6-a3c8-276f8b8aa722" + ] + }, + { + "prefix": "letterman", + "candidates": [ + "2dFMy4zc7ky_Fd5fzfgInw", + "C4uuztTiYUe20PACMARksA" + ] + }, + { + "prefix": "deepseadiver", + "candidates": [ + "4fcb5b84-13a9-43e6-9680-72b17874ae53", + "8d97303c-5138-4da1-a74d-378da19d11cb", + "b1c3ccc8-91d7-434e-b9cc-bd632fffc01b", + "c3745cee-d87e-4a23-b6e2-64ebf05c8b11" + ] + }, + { + "prefix": "jazzercise", + "candidates": [ + "49397e16-1027-4286-9bc4-b59eae565ff0", + "5f492f8f-ab61-484a-9587-747132b54309" + ] + }, + { + "prefix": "angler", + "candidates": [ + "03f8c394-28fa-4087-978b-8d108f0bd969", + "b1bfe0b4-1ff6-420f-acfc-2331d34246dc" + ] + }, + { + "prefix": "drummajor", + "candidates": [ + "1a028850-64ee-40ab-b547-ebbcfa1b04b3", + "a6f1825e-1647-4d06-85d7-ac2d139612c0" + ] + }, + { + "prefix": "countess", + "candidates": [ + "QCtqxbvYOkan79YFugH_Cw", + "t9co2516nkuJO4LIJA1bJA" + ] + }, + { + "prefix": "mobster", + "candidates": [ + "08d56627-4500-4724-8692-a23c96ddb3ec", + "4308da04-af84-40bb-845a-7a9e1a2726ec", + "76369aef-aeda-46d2-996a-cd00594d0543" + ] + }, + { + "prefix": "steampunk", + "candidates": [ + "E9VTiIVFPEa-OuQRto0e_Q", + "gwLiwmqBIk6glFxvaTzxeA", + "qxovV6Vym0ufL2cTCyKjOQ", + "sOREgAGs-06CeGGsG-WIkw" + ] + }, + { + "prefix": "witch", + "candidates": [ + "7LSm-xCYakmgcAr--Fn-JA", + "s6mUIqiPCk6e57UYCx3SZA", + "wy2cfgb8mUqjMrIOwHH2ew" + ] + } + ] +} \ No newline at end of file diff --git a/DorkNet.Server/Data/images/086ug40lugly2prjest6kw4dz.png b/DorkNet.Server/Data/images/086ug40lugly2prjest6kw4dz.png new file mode 100644 index 0000000..ad36de2 Binary files /dev/null and b/DorkNet.Server/Data/images/086ug40lugly2prjest6kw4dz.png differ diff --git a/DorkNet.Server/Data/images/0c1qdk77fbl8c5slphl3k1t8e.png b/DorkNet.Server/Data/images/0c1qdk77fbl8c5slphl3k1t8e.png new file mode 100644 index 0000000..7daccf3 Binary files /dev/null and b/DorkNet.Server/Data/images/0c1qdk77fbl8c5slphl3k1t8e.png differ diff --git a/DorkNet.Server/Data/images/1g7pq04z9xseircwla8sf9py5.png b/DorkNet.Server/Data/images/1g7pq04z9xseircwla8sf9py5.png new file mode 100644 index 0000000..f6f3f0a Binary files /dev/null and b/DorkNet.Server/Data/images/1g7pq04z9xseircwla8sf9py5.png differ diff --git a/DorkNet.Server/Data/images/3v6ak7k2ljui4ubj0xh21sjys.png b/DorkNet.Server/Data/images/3v6ak7k2ljui4ubj0xh21sjys.png new file mode 100644 index 0000000..0601a30 Binary files /dev/null and b/DorkNet.Server/Data/images/3v6ak7k2ljui4ubj0xh21sjys.png differ diff --git a/DorkNet.Server/Data/images/4o5lschc01nani8xeywao622n.png b/DorkNet.Server/Data/images/4o5lschc01nani8xeywao622n.png new file mode 100644 index 0000000..da4a0ba Binary files /dev/null and b/DorkNet.Server/Data/images/4o5lschc01nani8xeywao622n.png differ diff --git a/DorkNet.Server/Data/images/4xy3n0voagf6t9bbfnxft1txt.png b/DorkNet.Server/Data/images/4xy3n0voagf6t9bbfnxft1txt.png new file mode 100644 index 0000000..8cc3d63 Binary files /dev/null and b/DorkNet.Server/Data/images/4xy3n0voagf6t9bbfnxft1txt.png differ diff --git a/DorkNet.Server/Data/images/50xrbhogfdae0mcgzfkomlghu.png b/DorkNet.Server/Data/images/50xrbhogfdae0mcgzfkomlghu.png new file mode 100644 index 0000000..c08d770 Binary files /dev/null and b/DorkNet.Server/Data/images/50xrbhogfdae0mcgzfkomlghu.png differ diff --git a/DorkNet.Server/Data/images/61qgagldqpy5opgpbrbg5wxvo.png b/DorkNet.Server/Data/images/61qgagldqpy5opgpbrbg5wxvo.png new file mode 100644 index 0000000..3105d92 Binary files /dev/null and b/DorkNet.Server/Data/images/61qgagldqpy5opgpbrbg5wxvo.png differ diff --git a/DorkNet.Server/Data/images/6wvnxmrmsw98wsttajp0nreci.png b/DorkNet.Server/Data/images/6wvnxmrmsw98wsttajp0nreci.png new file mode 100644 index 0000000..8748006 Binary files /dev/null and b/DorkNet.Server/Data/images/6wvnxmrmsw98wsttajp0nreci.png differ diff --git a/DorkNet.Server/Data/images/86s8y4ihyhx7n9ac41gwahwze.png b/DorkNet.Server/Data/images/86s8y4ihyhx7n9ac41gwahwze.png new file mode 100644 index 0000000..0290ff7 Binary files /dev/null and b/DorkNet.Server/Data/images/86s8y4ihyhx7n9ac41gwahwze.png differ diff --git a/DorkNet.Server/Data/images/8gwqibu0anm4j0rw54xo2y0yd.png b/DorkNet.Server/Data/images/8gwqibu0anm4j0rw54xo2y0yd.png new file mode 100644 index 0000000..c29853f Binary files /dev/null and b/DorkNet.Server/Data/images/8gwqibu0anm4j0rw54xo2y0yd.png differ diff --git a/DorkNet.Server/Data/images/8j4f516axpnpjgnj342t85vkd.png b/DorkNet.Server/Data/images/8j4f516axpnpjgnj342t85vkd.png new file mode 100644 index 0000000..5607d30 Binary files /dev/null and b/DorkNet.Server/Data/images/8j4f516axpnpjgnj342t85vkd.png differ diff --git a/DorkNet.Server/Data/images/8ners1eg89dy974ixbim40sky.png b/DorkNet.Server/Data/images/8ners1eg89dy974ixbim40sky.png new file mode 100644 index 0000000..7daccf3 Binary files /dev/null and b/DorkNet.Server/Data/images/8ners1eg89dy974ixbim40sky.png differ diff --git a/DorkNet.Server/Data/images/8x6qaj4nlbudf1412tupxkq9n.png b/DorkNet.Server/Data/images/8x6qaj4nlbudf1412tupxkq9n.png new file mode 100644 index 0000000..ccb923e Binary files /dev/null and b/DorkNet.Server/Data/images/8x6qaj4nlbudf1412tupxkq9n.png differ diff --git a/DorkNet.Server/Data/images/ae6yh8p6y8p3cuyqkc4l8wi4z.png b/DorkNet.Server/Data/images/ae6yh8p6y8p3cuyqkc4l8wi4z.png new file mode 100644 index 0000000..1f251d6 Binary files /dev/null and b/DorkNet.Server/Data/images/ae6yh8p6y8p3cuyqkc4l8wi4z.png differ diff --git a/DorkNet.Server/Data/images/aj7w8q5nrfyt2ssfoovawtz4l.png b/DorkNet.Server/Data/images/aj7w8q5nrfyt2ssfoovawtz4l.png new file mode 100644 index 0000000..24b9cb4 Binary files /dev/null and b/DorkNet.Server/Data/images/aj7w8q5nrfyt2ssfoovawtz4l.png differ diff --git a/DorkNet.Server/Data/images/apo2sd115tuwueyoprp8jhx7b.png b/DorkNet.Server/Data/images/apo2sd115tuwueyoprp8jhx7b.png new file mode 100644 index 0000000..0acc4e9 Binary files /dev/null and b/DorkNet.Server/Data/images/apo2sd115tuwueyoprp8jhx7b.png differ diff --git a/DorkNet.Server/Data/images/d1es44q1u6hlhykxpy8uq0lci.png b/DorkNet.Server/Data/images/d1es44q1u6hlhykxpy8uq0lci.png new file mode 100644 index 0000000..6795bd6 Binary files /dev/null and b/DorkNet.Server/Data/images/d1es44q1u6hlhykxpy8uq0lci.png differ diff --git a/DorkNet.Server/Data/images/image_3DCharades.png b/DorkNet.Server/Data/images/image_3DCharades.png new file mode 100644 index 0000000..8ab80f3 Binary files /dev/null and b/DorkNet.Server/Data/images/image_3DCharades.png differ diff --git a/DorkNet.Server/Data/images/image_Clearcut.png b/DorkNet.Server/Data/images/image_Clearcut.png new file mode 100644 index 0000000..d8aa008 Binary files /dev/null and b/DorkNet.Server/Data/images/image_Clearcut.png differ diff --git a/DorkNet.Server/Data/images/image_CrimsonCauldron.png b/DorkNet.Server/Data/images/image_CrimsonCauldron.png new file mode 100644 index 0000000..774dc8c Binary files /dev/null and b/DorkNet.Server/Data/images/image_CrimsonCauldron.png differ diff --git a/DorkNet.Server/Data/images/image_CyberJunkCity.png b/DorkNet.Server/Data/images/image_CyberJunkCity.png new file mode 100644 index 0000000..1476e54 Binary files /dev/null and b/DorkNet.Server/Data/images/image_CyberJunkCity.png differ diff --git a/DorkNet.Server/Data/images/image_DiscGolfLake.png b/DorkNet.Server/Data/images/image_DiscGolfLake.png new file mode 100644 index 0000000..8d1dc5f Binary files /dev/null and b/DorkNet.Server/Data/images/image_DiscGolfLake.png differ diff --git a/DorkNet.Server/Data/images/image_DiscGolfPropulsion.png b/DorkNet.Server/Data/images/image_DiscGolfPropulsion.png new file mode 100644 index 0000000..1102201 Binary files /dev/null and b/DorkNet.Server/Data/images/image_DiscGolfPropulsion.png differ diff --git a/DorkNet.Server/Data/images/image_Dodgeball.png b/DorkNet.Server/Data/images/image_Dodgeball.png new file mode 100644 index 0000000..f304147 Binary files /dev/null and b/DorkNet.Server/Data/images/image_Dodgeball.png differ diff --git a/DorkNet.Server/Data/images/image_DriveIn.png b/DorkNet.Server/Data/images/image_DriveIn.png new file mode 100644 index 0000000..dda39b0 Binary files /dev/null and b/DorkNet.Server/Data/images/image_DriveIn.png differ diff --git a/DorkNet.Server/Data/images/image_GoldenTrophy.png b/DorkNet.Server/Data/images/image_GoldenTrophy.png new file mode 100644 index 0000000..9640429 Binary files /dev/null and b/DorkNet.Server/Data/images/image_GoldenTrophy.png differ diff --git a/DorkNet.Server/Data/images/image_Homestead.png b/DorkNet.Server/Data/images/image_Homestead.png new file mode 100644 index 0000000..15850ca Binary files /dev/null and b/DorkNet.Server/Data/images/image_Homestead.png differ diff --git a/DorkNet.Server/Data/images/image_IsleOfLostSkulls.png b/DorkNet.Server/Data/images/image_IsleOfLostSkulls.png new file mode 100644 index 0000000..e8e98e2 Binary files /dev/null and b/DorkNet.Server/Data/images/image_IsleOfLostSkulls.png differ diff --git a/DorkNet.Server/Data/images/image_LaserTag.png b/DorkNet.Server/Data/images/image_LaserTag.png new file mode 100644 index 0000000..c5a9dc4 Binary files /dev/null and b/DorkNet.Server/Data/images/image_LaserTag.png differ diff --git a/DorkNet.Server/Data/images/image_Paddleball.png b/DorkNet.Server/Data/images/image_Paddleball.png new file mode 100644 index 0000000..ad1e832 Binary files /dev/null and b/DorkNet.Server/Data/images/image_Paddleball.png differ diff --git a/DorkNet.Server/Data/images/image_Paintball.png b/DorkNet.Server/Data/images/image_Paintball.png new file mode 100644 index 0000000..d041b59 Binary files /dev/null and b/DorkNet.Server/Data/images/image_Paintball.png differ diff --git a/DorkNet.Server/Data/images/image_Quarry.png b/DorkNet.Server/Data/images/image_Quarry.png new file mode 100644 index 0000000..49b7e6e Binary files /dev/null and b/DorkNet.Server/Data/images/image_Quarry.png differ diff --git a/DorkNet.Server/Data/images/image_RecCenter.png b/DorkNet.Server/Data/images/image_RecCenter.png new file mode 100644 index 0000000..83759fc Binary files /dev/null and b/DorkNet.Server/Data/images/image_RecCenter.png differ diff --git a/DorkNet.Server/Data/images/image_RecRoyaleSolos.png b/DorkNet.Server/Data/images/image_RecRoyaleSolos.png new file mode 100644 index 0000000..272df63 Binary files /dev/null and b/DorkNet.Server/Data/images/image_RecRoyaleSolos.png differ diff --git a/DorkNet.Server/Data/images/image_RecRoyaleSquads.png b/DorkNet.Server/Data/images/image_RecRoyaleSquads.png new file mode 100644 index 0000000..e63154a Binary files /dev/null and b/DorkNet.Server/Data/images/image_RecRoyaleSquads.png differ diff --git a/DorkNet.Server/Data/images/image_River.png b/DorkNet.Server/Data/images/image_River.png new file mode 100644 index 0000000..a5519c8 Binary files /dev/null and b/DorkNet.Server/Data/images/image_River.png differ diff --git a/DorkNet.Server/Data/images/image_Soccer.png b/DorkNet.Server/Data/images/image_Soccer.png new file mode 100644 index 0000000..c29f366 Binary files /dev/null and b/DorkNet.Server/Data/images/image_Soccer.png differ diff --git a/DorkNet.Server/Data/images/image_Spillway.png b/DorkNet.Server/Data/images/image_Spillway.png new file mode 100644 index 0000000..e3f0eec Binary files /dev/null and b/DorkNet.Server/Data/images/image_Spillway.png differ diff --git a/DorkNet.Server/Data/images/image_StuntRunner.png b/DorkNet.Server/Data/images/image_StuntRunner.png new file mode 100644 index 0000000..cc5e69a Binary files /dev/null and b/DorkNet.Server/Data/images/image_StuntRunner.png differ diff --git a/DorkNet.Server/Data/images/image_TheRiseofJumbotron.png b/DorkNet.Server/Data/images/image_TheRiseofJumbotron.png new file mode 100644 index 0000000..5f99074 Binary files /dev/null and b/DorkNet.Server/Data/images/image_TheRiseofJumbotron.png differ diff --git a/DorkNet.Server/Data/room_images.json b/DorkNet.Server/Data/room_images.json new file mode 100644 index 0000000..01fb601 --- /dev/null +++ b/DorkNet.Server/Data/room_images.json @@ -0,0 +1,20 @@ +{ + "3DCharades": "aj7w8q5nrfyt2ssfoovawtz4l.png", + "BowlingAlley": "8gwqibu0anm4j0rw54xo2y0yd.png", + "CrimsonCauldron": "ae6yh8p6y8p3cuyqkc4l8wi4z.png", + "DiscGolfLake": "86s8y4ihyhx7n9ac41gwahwze.png", + "DiscGolfPropulsion": "6wvnxmrmsw98wsttajp0nreci.png", + "Dodgeball": "086ug40lugly2prjest6kw4dz.png", + "GoldenTrophy": "1g7pq04z9xseircwla8sf9py5.png", + "IsleOfLostSkulls": "3v6ak7k2ljui4ubj0xh21sjys.png", + "LaserTag": "4xy3n0voagf6t9bbfnxft1txt.png", + "Paddleball": "61qgagldqpy5opgpbrbg5wxvo.png", + "Paintball": "d1es44q1u6hlhykxpy8uq0lci.png", + "Park": "4o5lschc01nani8xeywao622n.png", + "RecCenter": "50xrbhogfdae0mcgzfkomlghu.png", + "RecRoyaleSolos": "8ners1eg89dy974ixbim40sky.png", + "RecRoyaleSquads": "0c1qdk77fbl8c5slphl3k1t8e.png", + "Soccer": "apo2sd115tuwueyoprp8jhx7b.png", + "StuntRunner": "8j4f516axpnpjgnj342t85vkd.png", + "TheRiseofJumbotron": "8x6qaj4nlbudf1412tupxkq9n.png" +} \ No newline at end of file diff --git a/DorkNet.Server/Data/room_locations.json b/DorkNet.Server/Data/room_locations.json new file mode 100644 index 0000000..17eaba9 --- /dev/null +++ b/DorkNet.Server/Data/room_locations.json @@ -0,0 +1,584 @@ +[ + { + "Name": "AR Room", + "ReplicationId": "bf268f5f-b55b-41af-8628-32fa4b5d70b6", + "SceneName": "AR_room", + "foundAt": "0x06515e64" + }, + { + "Name": "Animation Recording Studio", + "ReplicationId": "a95c349c-0f96-4c2d-a4c8-4969ffa8ea44", + "SceneName": "AnimationRecordingStudio", + "foundAt": "0x065158ac" + }, + { + "Name": "AnimationRecordingStudio", + "ReplicationId": "3c6fd16c-43c7-4c0e-bfd9-f90eaf077ba7", + "SceneName": "Record Animations", + "foundAt": "0x06519064" + }, + { + "Name": "Art Testing", + "ReplicationId": "42699ed2-0c1b-4f3d-93a2-ce01dfce7a79", + "SceneName": "ArtTesting", + "foundAt": "0x065150a8" + }, + { + "Name": "ArtTesting", + "ReplicationId": "cc338cd1-9883-0c14-99b3-ab069a360ee3", + "SceneName": "bla", + "foundAt": "0x06517f04" + }, + { + "Name": "Bowling Alley", + "ReplicationId": "ae929543-9a07-41d5-8ee9-dbbee8c36800", + "SceneName": "BowlingAlley", + "foundAt": "0x06515828" + }, + { + "Name": "Calibration", + "ReplicationId": "30040e05-b7b9-9f44-eb08-b9f154d2ecfc", + "SceneName": "PSVR room calibration", + "foundAt": "0x06515fe8" + }, + { + "Name": "Charades", + "ReplicationId": "4078dfed-24bb-4db7-863f-578ba48d726b", + "SceneName": "charades", + "foundAt": "0x06514438" + }, + { + "Name": "Clear Cut", + "ReplicationId": "380d18b5-de9c-49f3-80f7-f4a95c1de161", + "SceneName": "Paintball_ClearCut", + "foundAt": "0x065148ac" + }, + { + "Name": "Clearcut", + "ReplicationId": "28363a22-f0bb-4e46-8346-d76e8ac634f7", + "SceneName": "380d18b5-de9c-49f3-80f7-f4a95c1de161", + "foundAt": "0x06516ae0" + }, + { + "Name": "Clearcut", + "ReplicationId": "1636a319-9826-bfc4-18f8-ee1b1be6f806", + "SceneName": "380d18b5-de9c-49f3-80f7-f4a95c1de161", + "foundAt": "0x06516e78" + }, + { + "Name": "Crescendo of the Blood Moon", + "ReplicationId": "49cb8993-a956-43e2-86f4-1318f279b22a", + "SceneName": "Quest_Dracula1_additive", + "foundAt": "0x065155e4" + }, + { + "Name": "Curse of the Crimson Cauldron", + "ReplicationId": "949fa41f-4347-45c0-b7ac-489129174045", + "SceneName": "Quest_Goblin2_additive", + "foundAt": "0x06514cd4" + }, + { + "Name": "CyberJunk City", + "ReplicationId": "9d6456ce-6264-48b4-808d-2d96b3d91038", + "SceneName": "Arena_Cyberjunk_City", + "foundAt": "0x0651537c" + }, + { + "Name": "CyberJunkCity", + "ReplicationId": "333f409d-f9ad-46c9-b6fb-b0bbd30cb0b2", + "SceneName": "9d6456ce-6264-48b4-808d-2d96b3d91038", + "foundAt": "0x065176a0" + }, + { + "Name": "Dodgeball", + "ReplicationId": "3d474b26-26f7-45e9-9a36-9b02847d5e6f", + "SceneName": "dodgeball", + "foundAt": "0x065145ac" + }, + { + "Name": "Dorm Room", + "ReplicationId": "76d98498-60a1-430c-ab76-b54a29b7a163", + "SceneName": "dormroom2", + "foundAt": "0x06514338" + }, + { + "Name": "DormRoom", + "ReplicationId": "68251132-5662-5c34-08b1-4a830a27955b", + "SceneName": "Your private room", + "foundAt": "0x065160dc" + }, + { + "Name": "Drive-in", + "ReplicationId": "dc185520-0fa2-46d5-bc27-a613c6679c7c", + "SceneName": "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", + "foundAt": "0x06516bd8" + }, + { + "Name": "DriveIn", + "ReplicationId": "65ddbb48-5a01-4e3e-972d-e5c7419e2bc3", + "SceneName": "DriveIn", + "foundAt": "0x06515f6c" + }, + { + "Name": "Frontier Solos", + "ReplicationId": "b010171f-4875-4e89-baba-61e878cd41e1", + "SceneName": "RecRoyale_Frontier", + "foundAt": "0x06515558" + }, + { + "Name": "Frontier Squads", + "ReplicationId": "253fa009-6e65-4c90-91a1-7137a56a267f", + "SceneName": "RecRoyale_Frontier", + "foundAt": "0x065154cc" + }, + { + "Name": "Hangar", + "ReplicationId": "8f8fcaf8-7b13-e114-880c-72ee8c9fcb78", + "SceneName": "239e676c-f12f-489f-bf3a-d4c383d692c3", + "foundAt": "0x06517624" + }, + { + "Name": "Home", + "ReplicationId": "fe66923e-4034-4ec4-4bca-11a973bf5515", + "SceneName": "f5fbd9c9-e853-4036-9d48-5f68e861af04", + "foundAt": "0x06516064" + }, + { + "Name": "Home", + "ReplicationId": "92084aee-1f44-a3b4-18f1-375601606506", + "SceneName": "76d98498-60a1-430c-ab76-b54a29b7a163", + "foundAt": "0x06516150" + }, + { + "Name": "Home", + "ReplicationId": "6016e455-652d-54d4-3838-ecc6c9aa4ca8", + "SceneName": "cbad71af-0831-44d8-b8ef-69edafa841f6", + "foundAt": "0x06516268" + }, + { + "Name": "Home", + "ReplicationId": "b7150f3d-393e-ac74-2801-8a834b13e2bc", + "SceneName": "4078dfed-24bb-4db7-863f-578ba48d726b", + "foundAt": "0x06516390" + }, + { + "Name": "Home", + "ReplicationId": "6cd09971-c9fb-8e44-9b7f-3cb9ff5f6bd0", + "SceneName": "f6f7256c-e438-4299-b99e-d20bef8cf7e0", + "foundAt": "0x065164c4" + }, + { + "Name": "Home", + "ReplicationId": "e687ebed-6502-ad24-2ae1-d4a1db441d34", + "SceneName": "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", + "foundAt": "0x065165fc" + }, + { + "Name": "Home", + "ReplicationId": "27dd5218-dbea-b444-9b7c-a64e687aae67", + "SceneName": "3d474b26-26f7-45e9-9a36-9b02847d5e6f", + "foundAt": "0x06516718" + }, + { + "Name": "Home", + "ReplicationId": "11512b3c-bf64-acc4-7bf6-139786237328", + "SceneName": "d89f74fa-d51e-477a-a425-025a891dd499", + "foundAt": "0x06516844" + }, + { + "Name": "Home", + "ReplicationId": "3518de14-8412-96f4-db0a-41abe0196bbe", + "SceneName": "91e16e35-f48f-4700-ab8a-a1b79e50e51b", + "foundAt": "0x06517034" + }, + { + "Name": "Home", + "ReplicationId": "4a6968bf-0283-2d34-ca18-29f1145e1f69", + "SceneName": "acc06e66-c2d0-4361-b0cd-46246a4c455c", + "foundAt": "0x06517170" + }, + { + "Name": "Home", + "ReplicationId": "4a169c2f-28b2-e9a4-7ab1-61c9c270b1ab", + "SceneName": "949fa41f-4347-45c0-b7ac-489129174045", + "foundAt": "0x065172b4" + }, + { + "Name": "Home", + "ReplicationId": "02d23e1d-4c1a-15b4-aba5-98458d750417", + "SceneName": "7e01cfe0-820a-406f-b1b3-0a5bf575235c", + "foundAt": "0x065173f4" + }, + { + "Name": "Home", + "ReplicationId": "9a4b4871-28dc-4b24-8b8f-e0914db002eb", + "SceneName": "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", + "foundAt": "0x06517520" + }, + { + "Name": "Home", + "ReplicationId": "d59f7e16-9f27-6924-a899-2739bfc056fe", + "SceneName": "9d6456ce-6264-48b4-808d-2d96b3d91038", + "foundAt": "0x065177e0" + }, + { + "Name": "Home", + "ReplicationId": "1d282c2d-6edc-4910-afb3-48fbb9ad74a4", + "SceneName": "253fa009-6e65-4c90-91a1-7137a56a267f", + "foundAt": "0x0651790c" + }, + { + "Name": "Home", + "ReplicationId": "85b43509-77f7-3884-3a6b-9a4e737d6d11", + "SceneName": "b010171f-4875-4e89-baba-61e878cd41e1", + "foundAt": "0x06517a28" + }, + { + "Name": "Home", + "ReplicationId": "c9e928de-ed1f-d564-38af-9bf525ac0feb", + "SceneName": "a067557f-ca32-43e6-b6e5-daaec60b4f5a", + "foundAt": "0x06517b48" + }, + { + "Name": "Home", + "ReplicationId": "b55204c1-9a48-6ea4-fbd0-69fb125a68cf", + "SceneName": "9932f88f-3929-43a0-a012-a40b5128e346", + "foundAt": "0x06517c64" + }, + { + "Name": "Home", + "ReplicationId": "aa889faf-91c8-b7d4-5945-ab7e714f7efa", + "SceneName": "a75f7547-79eb-47c6-8986-6767abcb4f92", + "foundAt": "0x06517d7c" + }, + { + "Name": "Home", + "ReplicationId": "570a59bf-b4e2-6db4-ca9a-42329563dd30", + "SceneName": "0a864c86-5a71-4e18-8041-8124e4dc9d98", + "foundAt": "0x06517e8c" + }, + { + "Name": "Home", + "ReplicationId": "33bdd61b-bd99-abe4-6943-3032c741f5e8", + "SceneName": "42699ed2-0c1b-4f3d-93a2-ce01dfce7a79", + "foundAt": "0x06517f6c" + }, + { + "Name": "Home", + "ReplicationId": "dc445d24-01cf-43f9-a7c4-33f432514735", + "SceneName": "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + "foundAt": "0x06518088" + }, + { + "Name": "Home", + "ReplicationId": "daaa8763-868d-4081-9e8d-e931c111cc90", + "SceneName": "a785267d-c579-42ea-be43-fec1992d1ca7", + "foundAt": "0x06518198" + }, + { + "Name": "Home", + "ReplicationId": "f083ff9a-98af-4d8b-b7ff-5b84a66367d5", + "SceneName": "ff4c6427-7079-4f59-b22a-69b089420827", + "foundAt": "0x065182c0" + }, + { + "Name": "Home", + "ReplicationId": "c5f45037-8c60-494d-ad62-3e5168560b36", + "SceneName": "380d18b5-de9c-49f3-80f7-f4a95c1de161", + "foundAt": "0x065183e8" + }, + { + "Name": "Home", + "ReplicationId": "d336999f-9db0-42fc-8af5-592e9043102f", + "SceneName": "58763055-2dfb-4814-80b8-16fac5c85709", + "foundAt": "0x06518508" + }, + { + "Name": "Home", + "ReplicationId": "28253579-64cb-421b-988b-9fea890fd129", + "SceneName": "f6f7256c-e438-4299-b99e-d20bef8cf7e0", + "foundAt": "0x06518608" + }, + { + "Name": "Home", + "ReplicationId": "cd82c0aa-5dbc-4db3-ade3-30576d3bc473", + "SceneName": "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", + "foundAt": "0x06518748" + }, + { + "Name": "Home", + "ReplicationId": "8cecdc5b-d17b-4eba-9dd3-7b0ca54d3fd2", + "SceneName": "3d474b26-26f7-45e9-9a36-9b02847d5e6f", + "foundAt": "0x0651884c" + }, + { + "Name": "Home", + "ReplicationId": "b4f8632d-ae8b-4c3d-a433-0687131f8507", + "SceneName": "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", + "foundAt": "0x06518958" + }, + { + "Name": "Home", + "ReplicationId": "26330026-ebc7-4d44-8c85-4f38d2ad30a1", + "SceneName": "239e676c-f12f-489f-bf3a-d4c383d692c3", + "foundAt": "0x06518a60" + }, + { + "Name": "Home", + "ReplicationId": "6e0edd15-b904-f074-3b1f-cec488eba1a3", + "SceneName": "9d6456ce-6264-48b4-808d-2d96b3d91038", + "foundAt": "0x06518b80" + }, + { + "Name": "Home", + "ReplicationId": "bcbda991-93e7-4d05-b2e2-7c9512e4d4f2", + "SceneName": "3d474b26-26f7-45e9-9a36-9b02847d5e6f", + "foundAt": "0x06518c9c" + }, + { + "Name": "Home", + "ReplicationId": "66726423-6e03-4548-9990-f1b10e8c5c2e", + "SceneName": "49cb8993-a956-43e2-86f4-1318f279b22a", + "foundAt": "0x06518dd4" + }, + { + "Name": "Home", + "ReplicationId": "d61f36d3-f702-4002-a065-e1b6a59d4036", + "SceneName": "ae929543-9a07-41d5-8ee9-dbbee8c36800", + "foundAt": "0x06518ee0" + }, + { + "Name": "Home", + "ReplicationId": "b492f0d4-5f72-4429-9ea5-decf369e7aa7", + "SceneName": "ae929543-9a07-41d5-8ee9-dbbee8c36800", + "foundAt": "0x06518fec" + }, + { + "Name": "Home", + "ReplicationId": "8fe5818c-d466-4ffb-b502-f7d5f67efc7a", + "SceneName": "a95c349c-0f96-4c2d-a4c8-4969ffa8ea44", + "foundAt": "0x065190e8" + }, + { + "Name": "Home", + "ReplicationId": "57ec15a2-34b5-42d4-a5d8-ddad6888e0e3", + "SceneName": "882e9b96-7115-4b03-86f6-c0c9d8e22e00", + "foundAt": "0x065193d4" + }, + { + "Name": "Home", + "ReplicationId": "c300e175-8420-44fd-9966-c422275993f1", + "SceneName": "cf61556d-68fd-4288-9ae5-7a512621e569", + "foundAt": "0x065194bc" + }, + { + "Name": "Home", + "ReplicationId": "9fc2886b-3508-4191-9389-ccd0a6ee8a29", + "SceneName": "bf268f5f-b55b-41af-8628-32fa4b5d70b6", + "foundAt": "0x065195a4" + }, + { + "Name": "Homestead", + "ReplicationId": "f6709dc2-af81-46fc-88ba-f88f6c5035aa", + "SceneName": "a785267d-c579-42ea-be43-fec1992d1ca7", + "foundAt": "0x065169e4" + }, + { + "Name": "Homestead", + "ReplicationId": "d686129f-fdf7-1954-a9f6-f0a80d1af234", + "SceneName": "a785267d-c579-42ea-be43-fec1992d1ca7", + "foundAt": "0x06516d7c" + }, + { + "Name": "Homestead", + "ReplicationId": "a785267d-c579-42ea-be43-fec1992d1ca7", + "SceneName": "paintball2_open", + "foundAt": "0x065147a8" + }, + { + "Name": "Lake", + "ReplicationId": "f6f7256c-e438-4299-b99e-d20bef8cf7e0", + "SceneName": "discgolf", + "foundAt": "0x065144b0" + }, + { + "Name": "Maker Room", + "ReplicationId": "a75f7547-79eb-47c6-8986-6767abcb4f92", + "SceneName": "Basement", + "foundAt": "0x06515450" + }, + { + "Name": "PSVR Room Calibration", + "ReplicationId": "f5fbd9c9-e853-4036-9d48-5f68e861af04", + "SceneName": "room_calibration", + "foundAt": "0x065151b0" + }, + { + "Name": "Paddleball", + "ReplicationId": "d89f74fa-d51e-477a-a425-025a891dd499", + "SceneName": "paddleball", + "foundAt": "0x065146ac" + }, + { + "Name": "Park", + "ReplicationId": "0a864c86-5a71-4e18-8041-8124e4dc9d98", + "SceneName": "Park", + "foundAt": "0x06515240" + }, + { + "Name": "Performance Hall", + "ReplicationId": "9932f88f-3929-43a0-a012-a40b5128e346", + "SceneName": "PerformanceHall", + "foundAt": "0x06515128" + }, + { + "Name": "PhotonServerSettings", + "ReplicationId": "", + "SceneName": "1917ff4a-b7ad-4ad6-9ea2-9642bc174b70", + "foundAt": "0x064fa5f4" + }, + { + "Name": "Propulsion", + "ReplicationId": "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", + "SceneName": "Discgolf_Propulsion", + "foundAt": "0x06514524" + }, + { + "Name": "Quarry", + "ReplicationId": "1c549cfb-455b-4f5e-b15e-467702a71240", + "SceneName": "ff4c6427-7079-4f59-b22a-69b089420827", + "foundAt": "0x06516a64" + }, + { + "Name": "Quarry", + "ReplicationId": "a5ba53f2-a7cf-d3b4-8836-80fa08e02a27", + "SceneName": "ff4c6427-7079-4f59-b22a-69b089420827", + "foundAt": "0x06516dfc" + }, + { + "Name": "Quarry", + "ReplicationId": "ff4c6427-7079-4f59-b22a-69b089420827", + "SceneName": "Paintball_Castle", + "foundAt": "0x0651482c" + }, + { + "Name": "Quest For The Golden Trophy", + "ReplicationId": "91e16e35-f48f-4700-ab8a-a1b79e50e51b", + "SceneName": "Quest_additive", + "foundAt": "0x065149b4" + }, + { + "Name": "Rec Center", + "ReplicationId": "cbad71af-0831-44d8-b8ef-69edafa841f6", + "SceneName": "reccenter", + "foundAt": "0x065143b8" + }, + { + "Name": "Registration", + "ReplicationId": "8719e5b5-4ca5-463c-af8d-30c5e286d6e4", + "SceneName": "Registration", + "foundAt": "0x0651944c" + }, + { + "Name": "Registration", + "ReplicationId": "cf61556d-68fd-4288-9ae5-7a512621e569", + "SceneName": "registration", + "foundAt": "0x06515d54" + }, + { + "Name": "River", + "ReplicationId": "84545710-2bb4-4867-ad8b-24863a16d1b2", + "SceneName": "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + "foundAt": "0x06516968" + }, + { + "Name": "River", + "ReplicationId": "a4d7afc1-ae7a-8e04-1bea-f7d71df384fd", + "SceneName": "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + "foundAt": "0x06516d00" + }, + { + "Name": "River", + "ReplicationId": "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + "SceneName": "paintball", + "foundAt": "0x0651472c" + }, + { + "Name": "Soccer", + "ReplicationId": "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", + "SceneName": "soccer", + "foundAt": "0x06515030" + }, + { + "Name": "Spillway", + "ReplicationId": "7d661e29-f036-4fe9-8e9d-4b919fee638d", + "SceneName": "58763055-2dfb-4814-80b8-16fac5c85709", + "foundAt": "0x06516b5c" + }, + { + "Name": "Spillway", + "ReplicationId": "9a5a9875-67bb-f444-e804-0e0296d3c19c", + "SceneName": "58763055-2dfb-4814-80b8-16fac5c85709", + "foundAt": "0x06516ef4" + }, + { + "Name": "Spillway", + "ReplicationId": "58763055-2dfb-4814-80b8-16fac5c85709", + "SceneName": "Paintball_Dam", + "foundAt": "0x06514934" + }, + { + "Name": "StuntRunner", + "ReplicationId": "f8a71ce1-c989-4e00-9e10-3f04c241f6f3", + "SceneName": "b7281665-a715-4051-826b-8e08e69c6172", + "foundAt": "0x06519220" + }, + { + "Name": "StuntRunner", + "ReplicationId": "b7281665-a715-4051-826b-8e08e69c6172", + "SceneName": "StuntRunner_Lobby", + "foundAt": "0x06515948" + }, + { + "Name": "StuntRunner Base Room", + "ReplicationId": "882e9b96-7115-4b03-86f6-c0c9d8e22e00", + "SceneName": "StuntRunner_Baseroom", + "foundAt": "0x06515c30" + }, + { + "Name": "StuntRunner The Main Event", + "ReplicationId": "3a636bd2-f896-424c-9225-c184522c0d87", + "SceneName": "StuntRunner_additive", + "foundAt": "0x06515a60" + }, + { + "Name": "The Isle of Lost Skulls", + "ReplicationId": "7e01cfe0-820a-406f-b1b3-0a5bf575235c", + "SceneName": "Quest_Pirate1_additive", + "foundAt": "0x06514eac" + }, + { + "Name": "The Lounge", + "ReplicationId": "a067557f-ca32-43e6-b6e5-daaec60b4f5a", + "SceneName": "eventroom", + "foundAt": "0x0651462c" + }, + { + "Name": "The Rise Of JumboTron", + "ReplicationId": "acc06e66-c2d0-4361-b0cd-46246a4c455c", + "SceneName": "Quest_Scifi_Additive", + "foundAt": "0x06514b20" + }, + { + "Name": "TheMainEvent", + "ReplicationId": "cacc6e68-644a-448d-b83f-59ea690318ec", + "SceneName": "3a636bd2-f896-424c-9225-c184522c0d87", + "foundAt": "0x065192a0" + }, + { + "Name": "Warehouse", + "ReplicationId": "239e676c-f12f-489f-bf3a-d4c383d692c3", + "SceneName": "Arena_Hangar3", + "foundAt": "0x065152b0" + } +] \ No newline at end of file diff --git a/DorkNet.Server/Data/room_scene_locations.json b/DorkNet.Server/Data/room_scene_locations.json new file mode 100644 index 0000000..16804d2 --- /dev/null +++ b/DorkNet.Server/Data/room_scene_locations.json @@ -0,0 +1,106 @@ +{ + "rooms": [ + { + "Name": "River", + "ReplicationId": "84545710-2bb4-4867-ad8b-24863a16d1b2", + "Description": "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + "Scenes": [ + { + "Name": "Homestead", + "ReplicationId": "f6709dc2-af81-46fc-88ba-f88f6c5035aa", + "RoomSceneLocationId": "a785267d-c579-42ea-be43-fec1992d1ca7" + }, + { + "Name": "", + "ReplicationId": "", + "RoomSceneLocationId": "\u0002\u0000\u0000\u0000\u0006\u0000\u0000\u0000" + } + ] + }, + { + "Name": "Clearcut", + "ReplicationId": "28363a22-f0bb-4e46-8346-d76e8ac634f7", + "Description": "380d18b5-de9c-49f3-80f7-f4a95c1de161", + "Scenes": [ + { + "Name": "Spillway", + "ReplicationId": "7d661e29-f036-4fe9-8e9d-4b919fee638d", + "RoomSceneLocationId": "58763055-2dfb-4814-80b8-16fac5c85709" + }, + { + "Name": "", + "ReplicationId": "", + "RoomSceneLocationId": "\u0002\u0000\u0000\u0000\b\u0000\u0000\u0000" + } + ] + }, + { + "Name": "a4d7afc1-ae7a-8e04-1bea-f7d71df384fd", + "ReplicationId": "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + "Description": "", + "Scenes": [ + { + "Name": "Homestead", + "ReplicationId": "d686129f-fdf7-1954-a9f6-f0a80d1af234", + "RoomSceneLocationId": "a785267d-c579-42ea-be43-fec1992d1ca7" + }, + { + "Name": "", + "ReplicationId": "", + "RoomSceneLocationId": "\u0002\u0000\u0000\u0000\u0006\u0000\u0000\u0000" + } + ] + }, + { + "Name": "1636a319-9826-bfc4-18f8-ee1b1be6f806", + "ReplicationId": "380d18b5-de9c-49f3-80f7-f4a95c1de161", + "Description": "", + "Scenes": [ + { + "Name": "Spillway", + "ReplicationId": "9a5a9875-67bb-f444-e804-0e0296d3c19c", + "RoomSceneLocationId": "58763055-2dfb-4814-80b8-16fac5c85709" + }, + { + "Name": "", + "ReplicationId": "", + "RoomSceneLocationId": "\u0002\u0000\u0000\u0000\f\u0000\u0000\u0000" + } + ] + }, + { + "Name": "Hangar", + "ReplicationId": "8f8fcaf8-7b13-e114-880c-72ee8c9fcb78", + "Description": "239e676c-f12f-489f-bf3a-d4c383d692c3", + "Scenes": [ + { + "Name": "CyberJunkCity", + "ReplicationId": "333f409d-f9ad-46c9-b6fb-b0bbd30cb0b2", + "RoomSceneLocationId": "9d6456ce-6264-48b4-808d-2d96b3d91038" + }, + { + "Name": "", + "ReplicationId": "", + "RoomSceneLocationId": "\u0002\u0000\u0000\u0000\u0011\u0000\u0000\u0000" + } + ] + }, + { + "Name": "StuntRunner", + "ReplicationId": "f8a71ce1-c989-4e00-9e10-3f04c241f6f3", + "Description": "b7281665-a715-4051-826b-8e08e69c6172", + "Scenes": [ + { + "Name": "TheMainEvent", + "ReplicationId": "cacc6e68-644a-448d-b83f-59ea690318ec", + "RoomSceneLocationId": "3a636bd2-f896-424c-9225-c184522c0d87" + }, + { + "Name": "", + "ReplicationId": "", + "RoomSceneLocationId": "\u0002\u0000\u0000\u0000" + } + ] + } + ] +} \ No newline at end of file diff --git a/DorkNet.Server/Data/url-coverage.md b/DorkNet.Server/Data/url-coverage.md new file mode 100644 index 0000000..244a82f --- /dev/null +++ b/DorkNet.Server/Data/url-coverage.md @@ -0,0 +1,428 @@ +# URL coverage — Rec Room 2020 client vs DorkNet server + +Generated: 2026-05-09. Source: client URLs from `Cpp2IL_ISIL` ISIL dump (curated list provided to this report); server routes harvested via grep of `[HttpGet]`/`[HttpPost]`/`[HttpPut]`/`[HttpDelete]`/`[Route]` attributes across `Controllers/**/*.cs` plus the namespace-wildcard fall-throughs in `ApiNamespaceStubsController` and the global `ApiCatchAllController` / `GlobalCatchAllController`. + +## How this matrix is built + +Each client URL is matched against the server's full route table. ASP.NET Core routes resolve "specific wins over wildcard", so a route literally registered as `api/foo/v1/bar` always wins over `api/foo/{*path}`. The `Status` column reflects the route that actually wins: + +- **REAL** — a specific route exists and the body does real work: hits `DorkNetDbContext`, mutates entities, looks up presence, computes results, etc. Round-trips across sessions. +- **STUB** — a specific route exists but the body just returns `Ok(new { })`, `Ok(Array.Empty())`, an `Ack()` (`{success:true,error:""}`), or a hand-rolled empty-but-shape-correct DTO so the strict 2020 deserialiser doesn't throw. No persistence; the client thinks it succeeded but nothing is actually stored. +- **WILDCARD** — no specific route exists. The request is absorbed by `ApiNamespaceStubsController`'s namespace catch-all (`api//{*path}`), which returns either `[]` (list-typed namespaces) or `{}` (object-typed namespaces) or `{success:true}` (bug/report/sanitize namespaces). +- **MISSING** — neither a specific route nor a namespace wildcard handles it. Falls through to the very last-resort `GlobalCatchAllController` (`/{*path}`), which logs the URL and returns `200 {}`. From the client's point of view this looks like an empty object response — usually triggers a deserialiser crash on the watch unless the call site happened to be the rare one that tolerates `{}`. + +The `Handler` column points at the controller method (or wildcard route attribute) that wins. File paths can be reconstructed from the namespace prefix: `Controllers/API//...`. + +## Summary + +- Total client URLs (api/*): 156 +- Total client URLs (non-api goto/heartbeat): 7 +- REAL: 88 +- STUB: 67 +- WILDCARD: 6 +- MISSING: 2 (both covered by the global `{*path}` returning `200 {}` — neither has a specific or namespace handler) + +Note: counts are approximate because several client URLs (`api/avatar/v3/saved`, the `groups/v1/{groupId}` GET vs POST pair, `playerevents/v2`) collapse to the same handler under different verbs. They're listed separately below. + +## Coverage by API prefix + +### `api/announcement/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/announcement/v1/get` | STUB | `AllEndpointsController.MiscLists` → `[]` | + +### `api/avatar/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/avatar/v2` | REAL | `AvatarV2Controller.GetAvatar` (reads `AvatarEntity` for current player) | +| GET | `api/avatar/v2/gifts` | REAL | `AvatarGiftsController.GetGifts` | +| POST | `api/avatar/v2/gifts` | REAL | `AvatarGiftsController.GenerateGifts` (`api/avatar/v2/gifts/generate` + the bare `gifts` POST verb-overlap) — actually the bare-path POST falls through to `ApiNamespaceStubsController`'s `api/avatar/{*path}` is **not** registered (`api/avatar/` is NOT in the namespace wildcard list), so this lands on the global `{*path}` returning `{}`. Treat as STUB-via-global. | +| POST | `api/avatar/v2/gifts/consume/{giftId}` | REAL | `AvatarGiftsController.ConsumeGift` (id) — `Controllers/API/Avatar/V2/AvatarGiftsController.cs` line 99 | +| POST | `api/avatar/v2/set` | REAL | `AvatarV2Controller.SetAvatar` (writes `AvatarEntity`) | +| GET | `api/avatar/v3/saved` | REAL | `AvatarSavedOutfitsController.GetSaved` (V3) — pulls saved outfit slots from DB | +| POST | `api/avatar/v3/saved/set` | REAL | `AvatarSavedOutfitsController.SetSaved` (writes the slot) | +| GET | `api/avatar/v4/items` | REAL | `AvatarItemsController.GetItems` — returns owned-item ids from inventory tables | + +### `api/bugreporting/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| POST | `api/bugreporting/v2/reportbug` | REAL | `BugReportsController.ReportBugV2` (persists `BugReportEntity`) | + +### `api/catalog/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/catalog/v1/all` | STUB | `ApiNamespaceStubsController.CatalogAll` → `[]` (explicit override of the namespace wildcard so the SKU shape matches) | + +### `api/challenge/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/challenge/v2/getCurrent` | REAL | `ProgressionController.GetChallengeV2` — pulls active challenge for the player from the progression tables | +| POST | `api/challenge/v2/updateProgress` | REAL | `ProgressionController.UpdateChallengeProgress` — writes back delta into progression rows | + +### `api/checklist/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/checklist/v1/current` | REAL | `ProgressionController.GetChecklistV1` | +| POST | `api/checklist/v1/complete` | REAL | `ProgressionController.CompleteChecklistV1` (marks checklist item done in progression) | + +### `api/communityboard/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/communityboard/v1/current` | REAL | `CommunityBoardController.Current` — reads `data/community_board.json` via `CommunityBoardService`, returns `FeaturedPlayer` / `CurrentAnnouncement` shape | + +### `api/config/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/config/v2` | REAL | `Config.V2.ConfigController.GetConfig` — returns the full `RecRoomConfig` populated by `ConfigService.GetConfig(baseUrl)` | +| GET | `api/config/v1/amplitude` | STUB | `Config.V1.ConfigController.Amplitude` (returns `{ AmplitudeKey = "" }`) — also has a duplicate stub at `AllEndpointsController.Amplitude`; the V1 ConfigController route wins via more-specific match | +| GET | `api/config/v1/cohortnux/{cohortId}` | STUB | `Config.V1.ConfigController.CohortNux` — returns the empty cohort NUX shape | + +### `api/consumables/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| POST | `api/consumables/v1/updateActive` | REAL | `InventoryController.UpdateActiveConsumable` (writes the active-consumable row) | +| POST | `api/consumables/v1/consume` | REAL | `InventoryController.ConsumeConsumable` (decrements inventory count) | +| GET | `api/consumables/v1/getUnlocked` | REAL | `InventoryController.GetUnlockedConsumables` — reads the player's consumable inventory | + +### `api/equipment/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| POST | `api/equipment/v1/update` | REAL | `InventoryController.UpdateEquipment` — persists the equip-loadout selections | +| GET | `api/equipment/v2/getUnlocked` | REAL | `InventoryController.GetUnlockedEquipment` (V2) — reads the player's owned equipment list | + +### `api/gameconfigs/` + +| 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 `[]`. | + +### `api/groups/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/groups/v1/{groupId}` | REAL | `GroupsController.GetGroup` — reads `GroupEntity` | +| GET | `api/groups/v1/memberships/{groupId}` | REAL | `GroupsController.GetMemberships` (pulls members from `GroupMembershipEntity`) | +| POST | `api/groups/v1` | REAL | `GroupsController.CreateGroup` — inserts `GroupEntity` + creator membership | +| POST | `api/groups/v1/delete/{groupId}` | REAL | `GroupsController.DeleteGroup` | +| POST | `api/groups/v1/{groupId}` | WILDCARD | No specific POST `api/groups/v1/{id}` route exists — only the GET. Falls through to `ApiNamespaceStubsController` `api/groups/{*path}` → `[]`. The 2020 client likely uses this verb to update group metadata; nothing is persisted. | +| POST | `api/groups/v1/name/{groupId}` | WILDCARD | Same as above — only the GET-by-name (`api/groups/v1/name/{name}`) is implemented. The `name/{groupId}` POST hits the namespace wildcard. | + +### `api/images/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/images/v1/listsaved` | STUB | `MissingEndpointsController.ImagesListSaved` → `[]` (the real saved-image list is served at `api/images/v2/saved`+ via `ImagesController`; the v1 surface is empty for the 2020 client) | +| POST | `api/images/v1/modifyaccessibility` | STUB | `MissingEndpointsController.ImagesAck` → `Ack()` | +| POST | `api/images/v1/deletesaved` | STUB | `MissingEndpointsController.ImagesDeleteSaved` → `Ack()` | +| POST | `api/images/v1/sendlink` | STUB | `MissingEndpointsController.ImagesAck` → `Ack()` | +| POST | `api/images/v1/cheer` | STUB | `MissingEndpointsController.ImagesAck` → `Ack()` | +| GET | `api/images/v2/named` | STUB | `MiscStubsController.NamedImages` → `[]` (overrides the wildcard's `{}` to satisfy the strict `ExpectListResponse` deserialiser) | +| GET | `api/images/v1/slideshow` | STUB | `MiscStubsController.SlideshowInfo` — returns `{ ValidTill = 9999-12-31, Images = [] }`; required keys present so `SlideshowInfoDTO.Deserialize` succeeds, but no actual slideshow images | + +### `api/inventions/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/inventions/v1/personaldetails/{playerId}` | WILDCARD | `InventionsController` only registers `api/inventions/v1/personaldetails/{id:long}` for the SAVED count by player — wait — re-reading: route IS registered (line 173 in `InventionsController.cs`). REAL. → `InventionsController.PersonalDetails` reads invention saved-count for the target player | +| GET | `api/inventions/v1?inventionId={id}` | REAL | `InventionsController.GetInvention` (`api/inventions/v1`) reads `InventionEntity` by query param | +| GET | `api/inventions/v1/update` | REAL | `InventionsController.Update` — yes, the client uses GET for "update invention metadata" (the ISIL routing tag uses `Core.Get`); writes back the invention row | +| POST | `api/inventions/v1/settags` | REAL | `InventionsController.SetTags` — replaces the `Tags` column on the invention | +| POST | `api/inventions/v1/batch` | REAL | `InventionsController.Batch` — bulk-fetch inventions by id list, returns multi-row payload | +| GET | `api/inventions/v1/details` | REAL | `InventionsController.Details` — extended invention payload with version/creator metadata | +| GET | `api/inventions/v1/versions` | REAL | `InventionsController.Versions` — list of `InventionVersionEntity` rows for an invention | +| GET | `api/inventions/v1/creatorIds` | REAL | `InventionsController.CreatorIds` — list of distinct creator player ids over the matching invention set | +| GET | `api/inventions/v1/tagfilters` | REAL | `InventionsController.TagFilters` — returns the curated tag-filter set (currently empty list, but registered as a real handler) | +| GET | `api/inventions/v1/delete` | REAL | `InventionsController.Delete` — soft-deletes an invention the caller owns | +| GET | `api/inventions/v2/publish` | REAL | `InventionsController.PublishV2` — flips `IsPublished=true`, sets `FirstPublishedAt` | +| GET | `api/inventions/v1/unpublish` | REAL | `InventionsController.Unpublish` — flips `IsPublished=false` | +| GET | `api/inventions/v1/download` | REAL | `InventionsController.Download` — streams the invention payload + bumps `NumDownloads` | +| GET | `api/inventions/v1/search` | REAL | `InventionsController.Search` — text/`@username` search over the inventions table | +| POST | `api/inventions/v1/report` | REAL | `InventionsController.Report` — persists `InventionReportEntity` | +| POST | `api/inventions/v1/cheer` | REAL | `InventionsController.Cheer` — increments `CheerCount` and inserts cheer row, fires notification | +| GET | `api/inventions/v1/mine` | REAL | `InventionsController.Mine` (delegates to the SAVED-by-creator query, returns the caller's own creations) | +| GET | `api/inventions/v3/popular` | REAL | `InventionsController.Popular` — most-cheered published inventions | +| GET | `api/inventions/v3/saved` | REAL | `InventionsController.Saved` — caller's own creations (SAVED list) | +| POST | `api/inventions/v3/save` | REAL | `InventionsController.Save` — creates a new `InventionEntity` (initial save) | +| POST | `api/inventions/v3/addversion` | REAL | `InventionsController.AddVersion` — appends `InventionVersionEntity`, bumps `CurrentVersionNumber` | + +### `api/Leaderboard/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/Leaderboard/v2/getPlayerRank` | STUB | `AllEndpointsController.MiscLists` (matched via the `api/Leaderboard/v1/{*path}` GET stub for v1; v2/getPlayerRank specifically falls into the `ApiNamespaceStubsController` `api/Leaderboard/{*path}` → `[]`). Functionally STUB-via-WILDCARD. | +| POST | `api/Leaderboard/v1/SetStats` | WILDCARD | No specific route. `ApiNamespaceStubsController` `api/Leaderboard/{*path}` → `[]` — the stat row isn't persisted. | + +### `api/messages/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/messages/v1/favoriteFriendOnlineStatus` | REAL | `Messages.V2.MessagesController.FavoriteFriendOnlineStatus` — joins relationships+presence to return a per-friend online/offline list | +| POST | `api/messages/v1/sendMultiple` | REAL | `MissingEndpointsController.MessagesSendMultiple` — fan-out: persists one `MessageEntity` per recipient | +| POST | `api/messages/v2/send` | REAL | `Messages.V2.MessagesController.Send` — inserts a `MessageEntity` and fires the SignalR notify | +| GET | `api/messages/v2/get` | REAL | `Messages.V2.MessagesController.Get` — returns the recipient inbox from `MessageEntity` rows | +| GET | `api/messages/v1/IOSGetNotificationPreferences` | REAL | `IOSNotificationPrefsController.Get` — reads per-player iOS pref settings (table-backed) | +| POST | `api/messages/v1/IOSModifyNotificationPreferences` | REAL | `IOSNotificationPrefsController.Modify` — upserts the prefs row | +| POST | `api/messages/v1/send` | STUB | No `api/messages/v1/send` POST route. Falls through to global `{*path}` → `200 {}`. The v1 send was superseded by v2; the 2020 client almost always hits v2 instead. MISSING-via-global. | + +### `api/notification/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/notification/hub/v1` | STUB | `Notification.V2.NotificationController.Connect` returns 410 Gone with a hint to connect to `notify.rec.net/hub/v1` instead — not the same path, so the bare `/hub/v1` GET is intercepted by the V2 controller's `[Route("api/[controller]/v2")]` only when the URL path is `api/notification/v2`. The literal client URL `api/notification/hub/v1` has no specific handler; it lands on the global `{*path}` → `200 {}`. The actual notification stream the client wants is the SignalR hub at `notify.rec.net/hub/v1`. | + +### `api/objectives/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/objectives/v1/myprogress` | REAL | `MiscStubsController.MyObjectiveProgress` — reads `ObjectiveProgressEntity` rows for the player and emits the strict `Objectives` + `ObjectiveGroups` shape the client's `MyProgress.Deserialize` requires | +| POST | `api/objectives/v1/updateobjective` | WILDCARD | No specific route. `ApiNamespaceStubsController` `api/objectives/{*path}` → `[]`. Per-objective progress increments aren't actually persisted — only the `cleargroup` route below writes anything. | +| POST | `api/objectives/v1/cleargroup` | REAL | `MiscStubsController.ClearGroup` — upserts an `ObjectiveProgressEntity` row keyed `group:{n}`, sets `IsCompleted=true`, returns the wire-shape the client's `ObjectiveGroupProgress.Deserialize` requires | +| POST | `api/objectives/v1/completegroup` | REAL | `ProgressionController.CompleteObjectiveGroup` — same as ClearGroup but also grants the group's reward | + +### `api/PlayerCheer/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| POST | `api/PlayerCheer/v1/create` | REAL | `MissingEndpointsController.PlayerCheerCreate` — inserts `CheerEntity` for the target player | +| POST | `api/PlayerCheer/v1/SetSelectedCheer` | REAL | `MissingEndpointsController.SetSelectedCheer` — upserts `PlayerSettings[SelectedCheer]` for the caller | + +### `api/PlayerElo/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| POST | `api/PlayerElo/v1/reportPlayerElo` | WILDCARD | `ApiNamespaceStubsController` `api/PlayerElo/{*path}` → `Object()` `{}`. Match-result Elo deltas aren't recorded. | + +### `api/playerevents/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| POST | `api/playerevents/v1/{eventId}/responses` | WILDCARD | No specific `responses` POST route — only `{eventId}/rsvp` (POST) and `respond` (POST). Falls into the global `{*path}` since `api/playerevents/` is NOT in the namespace wildcard list. Effectively MISSING-via-global. | +| GET | `api/playerevents/v2` | STUB | No specific route at the bare `api/playerevents/v2` path; only `api/playerevents/v2/delete/{id}` (DELETE) exists. Falls into the global `{*path}` → `{}`. The 2020 watch usually reads the per-player feed from `api/playerevents/v1/all` (REAL) instead. | +| GET | `api/playerevents/v2/{eventId}` | STUB | Same — no specific GET route for a single v2 event. Global `{*path}` → `{}`. | +| POST | `api/playerevents/v1/respond` | REAL | `MissingEndpointsController.PlayerEventRespond` — upserts `PlayerEventResponseEntity` (RSVP yes/no/maybe) | +| POST | `api/playerevents/v1/deleteResponse` | REAL | `MissingEndpointsController.PlayerEventDeleteResponse` — deletes the RSVP row | +| POST | `api/playerevents/v1/bulkInvite` | REAL | `PlayerEventsController.BulkInvite` — fires per-recipient invite notifications via the notification service | +| GET | `api/playerevents/v1/all` | REAL | `MiscStubsController.AllPlayerEvents` — joins `PlayerEvents` (caller-created) + `PlayerEventResponses` (caller-RSVPed) for the player's local "my events" panel | + +### `api/PlayerReporting/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/PlayerReporting/v2/detail` | WILDCARD | `ApiNamespaceStubsController` `api/PlayerReporting/{*path}` → `Ack()` `{success:true}`. The 2020 watch reads moderation detail; we ack so the UI doesn't crash, but no real moderation history is returned. | +| POST | `api/PlayerReporting/v2/ban` | WILDCARD | Same wildcard `Ack()`. Bans posted via this v2 path aren't persisted (the canonical persistence path is `api/PlayersBanned/v2/ban` REAL below). | +| POST | `api/PlayerReporting/v1/deviceId` | REAL | `InGameModerationController.ReportDeviceId` — persists the device ID -> player association for moderation lookups | +| POST | `api/PlayerReporting/v1/hile` | WILDCARD | `ApiNamespaceStubsController` `api/PlayerReporting/{*path}` → `Ack()`. Steam signature/anti-cheat ping; intentionally not stored. | +| POST | `api/PlayerReporting/v3/create` | REAL | `InGameModerationController.CreateReport` — inserts a `PlayerReportEntity` and notifies moderators | +| POST | `api/PlayerReporting/v3/voteToKick` | REAL | `InGameModerationController.VoteToKick` — records the kick vote and triggers room-instance kick if threshold met | +| POST | `api/PlayerReporting/v1/instantKick` | REAL | `InGameModerationController.InstantKick` — moderator-only; ejects target from current room | + +### `api/playersubscriptions/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/playersubscriptions/v1/my` | REAL | `PlayerSubscriptionsController.My` — returns the caller's subscription list from `SubscriptionEntity` | +| POST | `api/playersubscriptions/v1/subscribe/{playerId}` | REAL | `PlayerSubscriptionsController.Subscribe` (also covered by `MissingEndpointsController.Subscribe` — the V1 controller's specific route wins). Inserts `SubscriptionEntity`. | +| POST | `api/playersubscriptions/v1/unsubscribe/{playerId}` | REAL | `PlayerSubscriptionsController.Unsubscribe` — removes the row | + +### `api/PlayersBanned/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| POST | `api/PlayersBanned/v2/ban` | REAL | `InGameModerationController.PlayersBannedV2Ban` — persists `PlayerBanEntity` (room/global ban depending on payload) | + +### `api/quickPlay/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/quickPlay/v1/getandclear` | REAL | `QuickPlayController.GetAndClear` — reads + drains the caller's pending quick-play invite row | +| POST | `api/quickPlay/v1/set` | REAL | `QuickPlayController.Set` — writes a quick-play invite for the target player | + +### `api/relationships/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/relationships/v2/get` | REAL | `Relationships.V2.RelationshipsController.Get` — reads the friends/blocked list from `RelationshipEntity` | +| GET | `api/relationships/v2/personaldetails/{playerId}` | WILDCARD | No specific `personaldetails` route in the v2 controller. Falls into the global `{*path}` → `{}` (the namespace `api/relationships/` is NOT in the wildcard list, so the global one is the only fallback). | +| POST | `api/relationships/v2/addfriend` | REAL | `Relationships.V2.RelationshipsController.AddFriend` (POST + GET both registered) — upserts the relationship row | +| POST | `api/relationships/v2/removefriend` | REAL | `Relationships.V2.RelationshipsController.RemoveFriend` | +| POST | `api/relationships/v2/sendfriendrequest` | REAL | `Relationships.V2.RelationshipsController.SendFriendRequest` — fires a friend-request notification | +| POST | `api/relationships/v2/acceptfriendrequest` | REAL | `Relationships.V2.RelationshipsController.AcceptFriendRequest` — flips the relationship to mutual | +| POST | `api/relationships/v1/favorite` | REAL | `Relationships.V2.RelationshipsController.Favorite` — sets the IsFavorite flag | +| POST | `api/relationships/v1/unfavorite` | REAL | `Relationships.V2.RelationshipsController.Unfavorite` | +| POST | `api/relationships/v1/bulkignoreplatformusers` | STUB | `MissingEndpointsController.BulkIgnore` → `Ack()` (platform-block import is a no-op on a private server) | + +### `api/role/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/role/developer/{accountId}` | REAL | `RoleController.IsDeveloper` — reads the developer-flag from the player's roles | + +### `api/rooms/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/rooms/v1/featuredRoomGroup` | REAL | `MissingEndpointsController.FeaturedRooms` — returns the top-12 hot AG rooms wrapped in the `{Name, FeaturedRooms}` shape | +| GET | `api/rooms/v3/featured` | REAL | Same handler — `MissingEndpointsController.FeaturedRooms` (route also registered for v3) | +| GET | `api/rooms/v2/myRecent` | REAL | `Rooms.V2.RoomsController.MyRecent` (`api/rooms/v2/myrecent`) — reads the caller's recent-room history | +| GET | `api/rooms/v2/mySubscriptions` | REAL | `Rooms.V2.RoomsController.MySubscribed` (`api/rooms/v2/mysubscribed`) — rooms the caller has subscribed to | +| GET | `api/rooms/v2/baserooms` | REAL | `Rooms.V2.RoomsController.BaseRooms` — list of "base" world templates / Rec Center rooms | +| GET | `api/rooms/v1/filters` | REAL | `Rooms.V2.RoomsController.Filters` — discovery filter chips | +| GET | `api/rooms/v2/search` | WILDCARD | No `api/rooms/v2/search` route — only `api/rooms/v1/search` is registered. Lands on the global `{*path}` → `{}`. The 2020 client probably falls back gracefully or the watch uses v1/search. STUB-via-global. | +| GET | `api/rooms/v2/live` | WILDCARD | No specific route. Global `{*path}` → `{}`. The "live rooms" panel will be empty. | +| GET | `api/rooms/v1/hot` | REAL | `Rooms.V2.RoomsController.Hot` — top rooms by `HotScore` | +| GET | `api/rooms/v2/name/{roomId}` | REAL | `Rooms.V2.RoomsController.ByName` — lookup by room name (NOT id, despite the variable token) | +| GET | `api/rooms/v2/{roomId}` | REAL | `Rooms.V2.RoomsController.ById` | +| GET | `api/rooms/v4/details/{roomId}` | REAL | `Rooms.V2.RoomsController.Details` — full room blob with scenes/permissions/etc. | +| GET | `api/rooms/v2/personaldetails/{creatorId}` | REAL | `Rooms.V2.RoomsController.PersonalDetails` — caller-relative bookmark/cheer flags for the room | +| POST | `api/rooms/v1/clone` | REAL | `Rooms.V2.RoomsController.Clone` — clones the room row + scenes for the caller | +| POST | `api/rooms/v1/modify/sceneParent` | REAL | `RoomsModerationController.ModifyParentScene` — re-parents a scene tree node | +| POST | `api/rooms/v1/modify/tags` | REAL | `RoomsModerationController.ModifyTags` — replaces the room's tag CSV | +| POST | `api/rooms/v2/modify` | REAL | `RoomsModerationController.Modify` — patches mutable room metadata (name, description, image) | +| POST | `api/rooms/v2/modifyPermissions` | REAL | `RoomsModerationController.ModifyPermissions` — updates per-role permission masks | +| POST | `api/rooms/v1/roombans/{roomId}` | WILDCARD | Only the GET `api/rooms/v1/roombans/{roomId}` is implemented; the POST falls into `ApiNamespaceStubsController.List()` (because `api/rooms/` is intentionally NOT in the namespace wildcard list per the comment in that file — actually re-reading: `api/rooms/` is excluded, so the global `{*path}` is what catches this. STUB-via-global.) | +| POST | `api/rooms/v2/banfromroom` | REAL | `RoomsModerationController.BanFromRoom` — inserts `RoomBanEntity` | +| POST | `api/rooms/v1/importroombans` | REAL | `RoomsModerationController.ImportRoomBans` — bulk insert | +| POST | `api/rooms/v1/cheer` | REAL | `MissingEndpointsController.CheerRoom` — bumps `Rooms.CheerCount` (also `PlayerStateController.CheerRoom` at the per-id route `api/rooms/v1/cheer/{roomId}`) | +| POST | `api/rooms/v1/bookmark` | REAL | `MissingEndpointsController.BookmarkRoom` — upserts/removes a `RoomBookmarkEntity` based on the `Bookmark` form field | +| GET | `api/rooms/v1/datahistory/{roomId}` | REAL | `Rooms.V2.RoomsController.DataHistory` — list of historical scene snapshots | +| POST | `api/rooms/v1/datahistory/restore` | REAL | `Rooms.V2.RoomsController.RestoreDataHistory` — rolls a room's scenes back to a snapshot | +| POST | `api/rooms/v2/report` | REAL | `RoomsModerationController.Report` — files a room report | +| GET | `api/rooms/v1/modrooms` | REAL | `Rooms.V2.RoomsController.MyModerated` (route alias `api/rooms/v1/modrooms`) — rooms the caller moderates | +| GET | `api/rooms/v2/myrooms` | REAL | `Rooms.V2.RoomsController.MyRooms` | +| GET | `api/rooms/v2/mybookmarkedrooms` | REAL | `Rooms.V2.RoomsController.MyBookmarks` (also aliased at `api/rooms/v2/mybookmarks`) | +| POST | `api/rooms/v1/roomRolePermissions` | STUB | `MiscStubsController.ValidateRoomRolePermissions` → `{success:true,error:""}` (the watch's local-role assumption is what gets used; no server-side recompute) | + +### `api/royale/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/royale/v1/current` | REAL | `RoyaleController.GetCurrent` — current Rec Royale season metadata + leaderboard summary | +| POST | `api/royale/v2/matchcomplete` | REAL | `RoyaleController.MatchComplete` — records the match result row + grants rewards | + +### `api/purchase/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| POST | `api/purchase/v1/initiatepurchase` | REAL | `PurchaseController.Initiate` — creates a pending `PurchaseEntity`, returns the purchase id for the next step | +| POST | `api/purchase/v1/completepurchase` | REAL | `PurchaseController.Complete` — finalises the row, grants the inventory/currency | +| POST | `api/purchase/v1/processpurchase` | REAL | `PurchaseController.Process` — middle-step confirmation that the platform-side payment cleared (no real platform integration; just flips state) | +| POST | `api/purchase/v1/cancelpurchase` | REAL | `PurchaseController.Cancel` — marks the pending row cancelled | +| POST | `api/purchase/v1/cleanuppending` | REAL | `PurchaseController.CleanupPending` — sweeps dangling pending rows for the caller | + +### `api/settings/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/settings/v2/get` | REAL | The route `api/settings/v2/get` doesn't exist as a literal path — the controller registers the bare GET at `api/settings/v2`. The 2020 client appends `/get` and lands on the global `{*path}` → `{}`. STUB-via-global for the literal `/get` suffix. (`api/settings/v2` bare GET is REAL via `Settings.V2.SettingsController.GetSettings` reading `PlayerSettingEntity` rows.) | + +### `api/storefronts/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/storefronts/v1/current` | WILDCARD | No specific route. `ApiNamespaceStubsController` `api/storefronts/{*path}` → `[]`. The "current storefront" panel is empty. | +| POST | `api/storefronts/v2/buyItem` | REAL | `StorefrontsBuyController.BuyItem` — debits the caller's currency, grants the item to inventory | +| POST | `api/storefronts/v2/buyTier` | REAL | `StorefrontsBuyController.BuyTier` — purchases a season-pass tier | +| POST | `api/storefronts/v2/buyElite` | REAL | `StorefrontsBuyController.BuyElite` — Elite (premium) purchase | +| GET | `api/storefronts/v4/balance/{playerId}` | REAL | `StorefrontsController.BalanceByCurrency` — actually only `api/storefronts/v4/balance/{currencyType:int}` is registered, and `api/storefronts/v4/balance` (no segment) at line 388. The `/balance/{playerId}` shape lands on whichever of those route templates matches the int constraint. Returns the player's currency balance. | +| POST | `api/storefronts/v1/balanceAddType/{type}/{playerId}` | STUB | Only the GET form is registered (`AllEndpointsController.StorefrontBalanceAddType` → `[]`). The POST falls into `ApiNamespaceStubsController` `api/storefronts/{*path}` → `[]`. Currency-add-tier purchases via the v1 path aren't credited. | +| GET | `api/storefronts/v2/balance` | REAL | `MissingEndpointsController.StorefrontBalanceV2` — returns a single-row balance list `[{CurrencyType=2, Balance=0, Platform=0}]` (genuine zero balance, but no actual ledger lookup) | +| GET | `api/storefronts/v3/giftdropstore/{storefront}` | STUB | `ApiNamespaceStubsController.GiftDropStorefront` — returns the BaseStorefrontDTO + `StoreItems=[]` shape with required keys present so the strict deserialiser passes | +| GET | `api/storefronts/v1/season/{seasonId}` | STUB | `MissingEndpointsController.StorefrontSeason` — returns `{Tiers=[], Active=false, SeasonType=type}` | +| GET | `api/storefronts/v1/objectives` | STUB | `MissingEndpointsController.StorefrontObjectives` → `[]` | + +### `api/sanitize/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| POST | `api/sanitize/v1/purifyString` | STUB | `AllEndpointsController.Sanitize` (registered at `api/sanitize/v1` and `api/sanitize/v1/text`) doesn't catch this exact suffix; falls into `ApiNamespaceStubsController.Sanitize` which echoes back `{Text=text}`. Functionally a passthrough — text is NOT actually filtered. | +| POST | `api/sanitize/v1/requestIsStringPure` | STUB | Same — `ApiNamespaceStubsController.Sanitize` returns `{Text=text}`. The "is this string pure" boolean isn't surfaced; the client likely falls back to "treat as clean". | + +### `api/testcasemanagement/` + +| Verb | URL | Status | Handler | +|---|---|---|---| +| GET | `api/testcasemanagement/v1/testpasssummary` | REAL | `TestCaseManagementController.TestPassSummary` — reads the test-pass summary table (dev/QA tooling) | +| GET | `api/testcasemanagement/v1/testpass/{passId}` | REAL | `TestCaseManagementController.GetTestPass` | +| GET | `api/testcasemanagement/v1/testcase/{caseId}` | REAL | `TestCaseManagementController.GetTestCase` | +| POST | `api/testcasemanagement/v1/testcase/{caseId}/claim` | REAL | `TestCaseManagementController.ClaimTestCase` — assigns the case to the caller | +| POST | `api/testcasemanagement/v1/testcase/{caseId}/unclaim` | REAL | `TestCaseManagementController.UnclaimTestCase` | +| POST | `api/testcasemanagement/v1/testcase/{caseId}/status` | REAL | `TestCaseManagementController.SetTestCaseStatus` — pass/fail/skip update | + +### Non-API URLs (other subdomains) + +These all live on `match.rec.net` (locally: `match.localhost`). + +| Verb | URL | Status | Handler | +|---|---|---|---| +| POST | `goto/room/{roomName}` | REAL | `Match.GoToController.GoToRoom` — looks up the room by name, picks/creates a session, returns the connect blob | +| POST | `goto/room/{room}/{subRoom}` | REAL | `Match.GoToController.GoToSubRoom` — same but for nested rooms (Rec Center subrooms) | +| POST | `goto/event/{eventId}` | REAL | `Match.GoToController.GoToEvent` — resolves the event's room + start time, routes the player | +| POST | `goto/player/{playerId}` | REAL | `Match.GoToController.GoToPlayer` — uses `PlayerPresenceService` to find where the target is and routes the caller there | +| POST | `goto/instance/{instanceId}` | REAL | `Match.GoToController.GoToInstance` — direct join to a specific room instance | +| POST | `goto/invite/{inviteId}` | REAL | `Match.GoToController.GoToInvite` — resolves an invite to its target room/instance | +| POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case | + +## Catch-all wildcards (informational) + +Routes registered as `[Route("api//{*path}")]` in `ApiNamespaceStubsController` (`Controllers/API/Stubs/ApiNamespaceStubsController.cs`): + +List-typed namespaces (return `[]`): +- `api/announcement/{*path}` +- `api/challenge/{*path}` +- `api/checklist/{*path}` +- `api/PlayerCheer/{*path}` +- `api/catalog/{*path}` +- `api/communityboard/{*path}` +- `api/consumables/{*path}` +- `api/equipment/{*path}` +- `api/groups/{*path}` +- `api/inventions/{*path}` +- `api/Leaderboard/{*path}` +- `api/offlineinvite/{*path}` +- `api/PlayersBanned/{*path}` +- `api/objectives/{*path}` +- `api/playersubscriptions/{*path}` +- `api/storefronts/{*path}` + +Object-typed namespaces (return `{}`): +- `api/PlayerElo/{*path}` +- `api/quickPlay/{*path}` +- `api/royale/{*path}` + +Ack namespaces (return `{success:true}`): +- `api/bugreporting/{*path}` +- `api/PlayerReporting/{*path}` + +Sanitize passthrough (returns `{Text=}`): +- `api/sanitize/{*path}` (GET + POST) + +Notable namespaces NOT in the wildcard list (so unmatched URLs fall through to the global `/{*path}` returning `200 {}`): +- `api/avatar/` +- `api/messages/` +- `api/notification/` +- `api/playerevents/` +- `api/relationships/` +- `api/rooms/` — explicit comment in the source: "intentionally NOT in this list — RoomsController owns it" +- `api/settings/` +- `api/testcasemanagement/` +- `api/account/`, `api/auth/`, `api/config/`, `api/players/`, `api/role/`, `api/version/`, `api/photos/`, `api/images/`, `api/gameconfigs/`, `api/playerReputation/`, `api/role/`, `api/cards/`, etc. (these all have specific controllers covering the URLs the 2020 client actually hits) + +## Final-fallback catch-alls + +If neither a specific route nor a namespace wildcard matches, the request hits one of these last-resort handlers: + +- `Controllers/ApiCatchAllController.cs` — `[Route("/{*path}")]` constrained to API hosts. Returns `200 {}` with a log line. Mostly a defensive net so the watch never sees a 404. +- `Controllers/GlobalCatchAllController.cs` — same shape, applies to non-API hosts. +- `Controllers/Stub/StubController.cs` — `[Route("/{*path}")]` last-resort across all hosts; logs the URL+verb+host so we can see what the client tried but we never wired up. + +These are the "MISSING" status entries in the matrix above. The body returned (`200 {}`) is enough for many client call-sites that tolerate `null`, but tends to crash strict `Util.Deserialize` paths the next time the player exercises that feature. diff --git a/DorkNet.Server/Data/wardrobe_items.json b/DorkNet.Server/Data/wardrobe_items.json new file mode 100644 index 0000000..f6a3866 --- /dev/null +++ b/DorkNet.Server/Data/wardrobe_items.json @@ -0,0 +1,2092 @@ +[ + { + "name": "Hat_Knight_Helmet_Mask", + "guid": "0dd6596c-b4ea-4d55-a219-e1f44e4a376d", + "category": "head" + }, + { + "name": "TShirt_SquadGoals_Swatch", + "guid": "941c046e-4e95-49f8-a7d7-19071fcc3c94", + "category": "torso" + }, + { + "name": "ZebraWhite_Swatch", + "guid": "48abd952-214f-48b2-a8f1-1146f6f69aa2", + "category": "accessory" + }, + { + "name": "FootballHelmet_Mask", + "guid": "7660e11a-7ef0-43d3-bc02-76b3a4aab064", + "category": "head" + }, + { + "name": "SpaceMarine_Red_Swatch", + "guid": "d78643e4-c0d2-4836-bf22-b5a0e2d65b28", + "category": "accessory" + }, + { + "name": "Hibiscus_Yellow_Swatch", + "guid": "596097a8-d545-4256-959d-1d3cd96fc64c", + "category": "accessory" + }, + { + "name": "Polkadot_Mask", + "guid": "b292eb4b-07e3-4a48-99b5-3c6587a1e02e", + "category": "face" + }, + { + "name": "Artist_Hat_Mask", + "guid": "3485a30d-27f8-450b-b5aa-1ff3cd227d19", + "category": "head" + }, + { + "name": "Boxer_Red_Swatch", + "guid": "8c7b09f2-6213-4ef9-87ab-a2df72d07a22", + "category": "accessory" + }, + { + "name": "Baseball_Torso_Mask", + "guid": "ca37823a-b8f3-4414-b38b-de0dc7fa4295", + "category": "face" + }, + { + "name": "TShirt_Male_Mask", + "guid": "0440f08f-ef1d-49d8-942b-523056e8bb45", + "category": "torso" + }, + { + "name": "Aviator_Shirt_Mask", + "guid": "9009bcdf-b6f7-4dae-b2ec-895511d118ff", + "category": "torso" + }, + { + "name": "PoofyDress_Purple_Swatch", + "guid": "31ac9d4d-3d6b-48f2-85d5-dc4b8165a17a", + "category": "accessory" + }, + { + "name": "Baseball_Red_Swatch", + "guid": "de0a5a3a-8aff-4799-8269-9efa3aeb383a", + "category": "accessory" + }, + { + "name": "ButtonTop_Orange_Swatch", + "guid": "e0397982-c2c2-4733-9a40-46e18675b5af", + "category": "accessory" + }, + { + "name": "CowboyCherry_Swatch", + "guid": "624c85e5-095a-48b6-85fb-c9bbade19a8d", + "category": "accessory" + }, + { + "name": "Mobster_HerringBone_Tan_Swatch", + "guid": "531b4297-b38b-4362-9854-4f2139ea43be", + "category": "accessory" + }, + { + "name": "Punk_Pink_Swatch", + "guid": "4169bfed-8403-4590-a29d-4941275ac0b6", + "category": "accessory" + }, + { + "name": "Golfer_Shirt_Mask", + "guid": "6ce21c65-f4e9-41ac-be94-8eb1a08d5c76", + "category": "torso" + }, + { + "name": "EquestrianJacket_Maroon_Swatch", + "guid": "d6823e01-69f0-4f85-b94a-74894356a2cf", + "category": "torso" + }, + { + "name": "FighterPilot_Gloves_Orange_Swatch", + "guid": "18b52db5-0261-46a0-9214-ff510d455ed9", + "category": "accessory" + }, + { + "name": "Aviator_Gloves_Tan_Swatch", + "guid": "7fb8496f-0e6c-4e48-b8fa-5709ebbf4820", + "category": "accessory" + }, + { + "name": "Plaid_LightBlue_Swatch", + "guid": "d6edbc00-3c1d-4f49-8412-3ef8c7c5f4c2", + "category": "accessory" + }, + { + "name": "Fatigue_Swatch", + "guid": "6d703981-2734-4c45-8983-cdd5f328902f", + "category": "accessory" + }, + { + "name": "Hat_Safari_Mask", + "guid": "0dabda0d-842c-4d39-a6b9-a8ca3ec20825", + "category": "head" + }, + { + "name": "Cream_Swatch", + "guid": "7d8e55fe-3c34-4b4b-9753-0021f6cc6454", + "category": "accessory" + }, + { + "name": "ArtistHat_Red_Swatch", + "guid": "5c8f36c5-07da-49f4-af02-d23207a43216", + "category": "head" + }, + { + "name": "Dodgeball_Mask", + "guid": "944d934d-5e21-42d7-8196-fff9a81ee469", + "category": "face" + }, + { + "name": "ArcherBlack_Quiver_Swatch", + "guid": "74b009f4-e128-4c59-8a6a-4c0ba6119bde", + "category": "accessory" + }, + { + "name": "ArcherYellow_Swatch", + "guid": "6e152cc7-e93a-4408-8092-236db420b680", + "category": "accessory" + }, + { + "name": "Torso_YellowPoofyDress_Mask", + "guid": "d30e3afe-414c-4b60-8be9-6fa4156a4b65", + "category": "face" + }, + { + "name": "PurpleJacket_Red_Swatch", + "guid": "657f28d5-672a-4f27-86c6-3cb00f25bb84", + "category": "torso" + }, + { + "name": "FighterPilot_Orange_Swatch", + "guid": "cea53c7f-24a9-4e3b-80b9-fbf95738f973", + "category": "accessory" + }, + { + "name": "BabydollDress_Green_Swatch", + "guid": "c9589974-c261-485e-b571-cb2f34fb178f", + "category": "accessory" + }, + { + "name": "TennisSkirt_Mask", + "guid": "b75ef67d-00c3-4ac1-9b72-212032460294", + "category": "legs" + }, + { + "name": "Eye_MirroredGlasses_Silver_swatch", + "guid": "f841cdfa-45ba-4f09-a117-503eef34cb4b", + "category": "face" + }, + { + "name": "Beekeeper_Glove_Mask", + "guid": "1f18670d-df52-4d2b-8009-a32cf7c67b40", + "category": "face" + }, + { + "name": "2017_Member_Decal", + "guid": "75162f12-44e7-499f-bc6c-9b0612e9164b", + "category": "accessory" + }, + { + "name": "HeadFlowers_Mask", + "guid": "7d040391-03f7-4b3d-bcfd-a1278ff459c9", + "category": "face" + }, + { + "name": "Tshirt_Squad_Goals_Decal", + "guid": "6c395d04-2aa6-4a3e-8c2c-695932bc9736", + "category": "torso" + }, + { + "name": "Hat_SeaCaptainDarkDarkGold_Swatch", + "guid": "70112887-dc8d-49d5-96ed-0845204e0b58", + "category": "head" + }, + { + "name": "ScoutSkirt_Shirt_Mask", + "guid": "e65530f7-543c-43e9-bf12-b6cda2bc755c", + "category": "torso" + }, + { + "name": "PolkaBlue_Swatch", + "guid": "ad61c418-6d77-4a99-8ac5-9f10f5a3d42f", + "category": "accessory" + }, + { + "name": "Wrist_CowboyCherry_Swatch", + "guid": "17e44296-1cd9-4f8e-9aa7-3837dc69279a", + "category": "accessory" + }, + { + "name": "Yellow_Swatch", + "guid": "d2b40639-5d34-45b6-904e-6dd29030ff44", + "category": "accessory" + }, + { + "name": "WitchHunter_Purple_Swatch", + "guid": "71e5a0fe-fc8d-420b-af3b-e74160e0d1f3", + "category": "accessory" + }, + { + "name": "Plaid_Mask", + "guid": "cf119781-5bd9-4b85-9a0b-12e82e988c23", + "category": "face" + }, + { + "name": "Smuggler_Green_Swatch", + "guid": "076c96ae-9f9b-45bf-9dc7-ce73d5ddf422", + "category": "accessory" + }, + { + "name": "Scallywag_Torso_Swatch_Red", + "guid": "39f04eee-3a58-4e53-9f5e-7e995d46ed71", + "category": "accessory" + }, + { + "name": "Red_Swatch", + "guid": "8377ab96-c908-457f-9fee-b784c9a759f3", + "category": "accessory" + }, + { + "name": "TShirt_Male_LaserTag_Alpha_Mask", + "guid": "3259238f-4a9e-4e06-99de-6e7324fa0894", + "category": "torso" + }, + { + "name": "PirateCaptain_Hat_Black_Swatch", + "guid": "f9aaa7c5-59bc-486c-9a6e-27a8ac618f84", + "category": "head" + }, + { + "name": "Grey_Swatch", + "guid": "0ecb8a2a-cffc-47db-aeda-fb0684aef1e5", + "category": "accessory" + }, + { + "name": "BaseballHelmet_Red_Swatch", + "guid": "a1c51d1e-1d5f-4f8a-bc0f-6a20eddfe58a", + "category": "head" + }, + { + "name": "PirateCaptain_Black_Swatch", + "guid": "d81d0b62-e052-46c2-9252-4bc5fb219f53", + "category": "head" + }, + { + "name": "BlazerGreyDark_Swatch", + "guid": "9374bf66-2ee5-493b-8439-efce4b201904", + "category": "accessory" + }, + { + "name": "Tshirt_LaserTag_Alpha_Decal", + "guid": "e38294cf-6279-4863-9aea-1a19aeaf8476", + "category": "torso" + }, + { + "name": "PirateCaptain_Belt_Black_Swatch", + "guid": "f4ad4b9e-dd42-4711-9437-5beae51966f6", + "category": "head" + }, + { + "name": "Firefighter_Hat_Mask", + "guid": "c8ccb85e-52cd-4b3b-aadf-289819067125", + "category": "head" + }, + { + "name": "Barbershop_Torso_Mask", + "guid": "15edb8d7-2ff6-4170-a7e6-acd9e965cab5", + "category": "face" + }, + { + "name": "BabydollDress_Black_Swatch", + "guid": "e13cf33d-7588-4e2e-955e-f0a29d6380b7", + "category": "accessory" + }, + { + "name": "Barber_Torso_Formal_Swatch", + "guid": "a5925738-a451-4f25-b622-ccbfa12bd88c", + "category": "accessory" + }, + { + "name": "FlowerSwimsuit_Mask", + "guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622", + "category": "face" + }, + { + "name": "FlapperGlove_Black_Swatch", + "guid": "30c61e63-5e09-4a6f-8915-1e1fe4d55cf0", + "category": "accessory" + }, + { + "name": "ButtonTop_Tan_Swatch", + "guid": "49f5864f-9d40-497c-88c8-e87f64d41d74", + "category": "accessory" + }, + { + "name": "Tshirt_SciFi_Face_Decal", + "guid": "4ca3347a-dc87-412f-9eb1-799372e6fdd1", + "category": "torso" + }, + { + "name": "Scifi_Carpet_Mask", + "guid": "3290f148-1bd1-4ffd-a947-61fd0529af05", + "category": "face" + }, + { + "name": "Aviator_Goggles_Gray_Swatch", + "guid": "23428ca9-be75-4146-b400-df01d1dadac9", + "category": "face" + }, + { + "name": "Knight_Blue_Swatch", + "guid": "ed134bee-d199-43eb-98bd-206571603f40", + "category": "accessory" + }, + { + "name": "SafariTorso_Green_Swatch", + "guid": "49bbd11a-e5c9-47ff-b04d-72eb321e8a57", + "category": "accessory" + }, + { + "name": "Scallywag_Hat_Blue_Swatch", + "guid": "53dd9e44-7096-438e-a7c3-11d7c307426f", + "category": "head" + }, + { + "name": "Wizard_Wrist_Mask", + "guid": "47514b9b-0d17-4727-97ed-dab67e7fe031", + "category": "face" + }, + { + "name": "ArcherTunic_Mask", + "guid": "f88178b8-b204-4fa5-9dfd-6e438bf1e247", + "category": "face" + }, + { + "name": "Ranger_Hat_Mask", + "guid": "0272eb19-fc0e-4ad6-8db9-cc0cda2528b0", + "category": "head" + }, + { + "name": "SolidRed_Mask", + "guid": "0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd", + "category": "face" + }, + { + "name": "TShirt_Laser_Unicorn_Purple_Swatch", + "guid": "befa2f11-b9fd-457c-b1d5-9d82e90b70ce", + "category": "torso" + }, + { + "name": "Hat_SpaceMarine_Mask", + "guid": "b94411f6-5b71-48d0-ace1-ac6f63167065", + "category": "head" + }, + { + "name": "EquestrianJacket_Gray_Swatch", + "guid": "4828b50c-95b6-466a-bb25-514891d78202", + "category": "torso" + }, + { + "name": "Tshirt_Laser_Unicorn_Decal", + "guid": "770e7674-6f9b-41f8-b023-ae79738db0d3", + "category": "torso" + }, + { + "name": "AnglerHat_Blue_Swatch", + "guid": "d7f0ffd3-ccf9-465e-8afe-4d7efb78dd2c", + "category": "head" + }, + { + "name": "Lacrosse_Hat_Mask", + "guid": "7e688b30-ed13-4790-9ad8-c49885d1b7e8", + "category": "head" + }, + { + "name": "FurCoat_Black_Swatch", + "guid": "43501b0c-eaaa-4ab3-9d03-2ca61d2e8fc9", + "category": "accessory" + }, + { + "name": "Angler_Torso_Mask", + "guid": "cdbe2cc8-4fd7-451f-9b66-0df0e0e7a85e", + "category": "face" + }, + { + "name": "FlapperHeadband_Gold_Swatch", + "guid": "b6c25a3b-125b-4aab-bed0-e6e5ced2c21c", + "category": "accessory" + }, + { + "name": "Golfer_Shirt_Blue_Swatch", + "guid": "96e2b29e-e553-40f1-9eb6-8a9f7e64da39", + "category": "torso" + }, + { + "name": "Hibiscus_White_Swatch", + "guid": "ce465d53-3807-423f-baa0-bdba2a75ceb3", + "category": "accessory" + }, + { + "name": "FlapperHeadband_Black_Swatch", + "guid": "565c17e0-496d-45bd-a2c3-564e118c06cc", + "category": "accessory" + }, + { + "name": "Argyle_Mask", + "guid": "d015cae7-a905-49e4-8823-6dec069689a6", + "category": "face" + }, + { + "name": "Scout_RaccoonHat_Mask", + "guid": "f1cd10fb-3d0b-436f-a173-65bc0d88d8bf", + "category": "head" + }, + { + "name": "Blue_Swatch", + "guid": "dee70c38-7a99-4c2b-9181-665f1bf75aca", + "category": "accessory" + }, + { + "name": "Shirt_Knight_Armor_Mask", + "guid": "51a1809d-becd-42e0-8dfb-188d6f07ba1c", + "category": "torso" + }, + { + "name": "Barber_Hat_Formal_Swatch", + "guid": "631c9015-2a00-47ea-bb35-465a2ddbca5d", + "category": "head" + }, + { + "name": "DrumMajorHat_Mask", + "guid": "a8a91216-0fcc-461e-a682-95ee82509cce", + "category": "head" + }, + { + "name": "Torso_Flapper_Mask", + "guid": "a240d44f-963d-4d35-b22f-980938c7788d", + "category": "face" + }, + { + "name": "Artist_Torso_Mask", + "guid": "5a6db6ba-2a0f-4399-b537-dd78db94bf90", + "category": "face" + }, + { + "name": "Blazer_Mask", + "guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54", + "category": "face" + }, + { + "name": "TShirt_Female_Mask", + "guid": "05f0ee6e-c824-470e-9178-5ed576c6fe0c", + "category": "torso" + }, + { + "name": "Shirt_Knight_Cape_Mask", + "guid": "15099be7-38b7-4b0b-ac13-1bacedbf5fb2", + "category": "head" + }, + { + "name": "Aviator_Hat_Mask", + "guid": "ff5ae41e-e446-4a96-8c11-49d85556fa81", + "category": "head" + }, + { + "name": "FighterPilot_Glove_Mask", + "guid": "7869b2a5-cd89-4033-b9f7-4f1451ecb47b", + "category": "face" + }, + { + "name": "Torso_OrangeDress_Mask", + "guid": "2c8924aa-68f8-4912-9759-18992f72f08a", + "category": "face" + }, + { + "name": "TShirt_HangInThere_Swatch", + "guid": "815138e6-b0dc-459c-9538-20f5bbd37d6b", + "category": "torso" + }, + { + "name": "Knight_Red_Swatch", + "guid": "59866578-d4e0-417b-9483-233ab108b1ac", + "category": "accessory" + }, + { + "name": "Hat_CatEars_Gray_Swatch", + "guid": "96e42c7a-2881-4299-a2fe-80c0d4d5ca26", + "category": "head" + }, + { + "name": "Boxer_Torso_Mask", + "guid": "0ec6628c-a106-4452-af82-105a3e3a9f2e", + "category": "face" + }, + { + "name": "PirateCaptain_Hat_Red_Mask", + "guid": "71fd06e3-cac8-4923-a09c-ef91183712d2", + "category": "head" + }, + { + "name": "Fatigue_Mask", + "guid": "a0271cd0-e172-4d3f-aa2f-9806f21a82d2", + "category": "face" + }, + { + "name": "Hat_CatEars_Brown_Swatch", + "guid": "713781e5-ce47-41c6-a24f-94d42a565e30", + "category": "head" + }, + { + "name": "FurCoat_Mask", + "guid": "2c0d912d-c184-4eab-8ee4-73b90ba7b0a6", + "category": "face" + }, + { + "name": "WitchHunter_Green_Swatch", + "guid": "64545dcc-a8eb-4c69-a539-78ee73a2d327", + "category": "accessory" + }, + { + "name": "Tshirt_Alpaca_Decal", + "guid": "703ff56b-560d-4ff4-8c63-d195e879a328", + "category": "torso" + }, + { + "name": "Scout_Sash_Blue_Swatch", + "guid": "67490e3b-0f9d-4bf8-84fb-54957b981c8c", + "category": "accessory" + }, + { + "name": "Hat_CatEars_Mask", + "guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69", + "category": "head" + }, + { + "name": "TShirt_Orange_Swatch", + "guid": "95e4cc30-cb68-473d-a395-feadf5b51512", + "category": "torso" + }, + { + "name": "SpaceMarine_Pink_Swatch", + "guid": "a36d252b-16ac-4a82-bece-de145147e7d9", + "category": "accessory" + }, + { + "name": "Torso_PinkStollDress_Mask", + "guid": "91a451c1-b285-4c48-b14d-59ded8cc006f", + "category": "face" + }, + { + "name": "Mobster_Hat_Brown_Swatch", + "guid": "1421caa9-9a0d-42c2-b847-1500ba2e483a", + "category": "head" + }, + { + "name": "ElectricianTorso_Gray_Swatch", + "guid": "b995b419-1897-4c1d-98be-83d551f7ba71", + "category": "accessory" + }, + { + "name": "White_Swatch", + "guid": "724c79a3-822c-422f-9462-a5374ee0211c", + "category": "accessory" + }, + { + "name": "2016_Member_swatch", + "guid": "5864d23d-1a86-40a6-a52c-46ee670e4508", + "category": "accessory" + }, + { + "name": "BeltDress_Green_Swatch", + "guid": "5b58d879-2bc4-47ed-a996-1af2ace16425", + "category": "accessory" + }, + { + "name": "SmallBrimHat_Mask", + "guid": "65ed43ff-dfc4-4cab-b0b9-77dc94165e24", + "category": "head" + }, + { + "name": "FootballHelmet_Orange_Swatch", + "guid": "d8d490aa-7088-4163-9305-1441367f490c", + "category": "head" + }, + { + "name": "Flower_3_Mask", + "guid": "27a5df72-4095-4837-bf24-cdd3d95a43e9", + "category": "face" + }, + { + "name": "RedFlowerDress_Gray_Swatch", + "guid": "1acb1f56-4509-449f-9c6a-065bdeaa9ee3", + "category": "accessory" + }, + { + "name": "Aviator_Jacket_Tan_Swatch", + "guid": "bea795cc-aabd-4229-84dd-49ab4f91a445", + "category": "torso" + }, + { + "name": "FootballPads_Mask", + "guid": "e17481fc-b2b6-47a5-91e5-7205f45a3247", + "category": "feet" + }, + { + "name": "Tennis_Mask", + "guid": "90e0da8b-1941-4ca8-b7e5-b4e31921873c", + "category": "face" + }, + { + "name": "Punk_WristPink_Swatch", + "guid": "f3b2330a-b981-4c7e-902f-1f57382f2388", + "category": "accessory" + }, + { + "name": "BeltDress_Black_Swatch", + "guid": "d320ad9b-ba5c-45ea-b2b8-0d3e409a9db1", + "category": "accessory" + }, + { + "name": "FighterPilot_Black_Swatch", + "guid": "8c74ee27-3d56-401b-8a8c-7868a80770e8", + "category": "accessory" + }, + { + "name": "TShirt_KO_Skull_Swatch", + "guid": "381a9f13-5b44-48e6-8a49-221422a963f5", + "category": "torso" + }, + { + "name": "TShirt_Scifi_Face_BLue_Swatch", + "guid": "bda7a57e-5227-49f4-a119-e3f166604738", + "category": "torso" + }, + { + "name": "YellowBlueDress_White_Swatch", + "guid": "bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a", + "category": "accessory" + }, + { + "name": "Green_Swatch", + "guid": "f8b0cfe8-e129-4578-8bb5-f60af5d38599", + "category": "accessory" + }, + { + "name": "TennisSkirt_Yellow_Swatch", + "guid": "c5deba2a-6e35-4b13-8e94-8ba5457f39df", + "category": "legs" + }, + { + "name": "Smuggler_Grey_Swatch", + "guid": "90869e86-cbc5-4024-9d08-fcdc41c64eb5", + "category": "accessory" + }, + { + "name": "ArcherBlack_Swatch", + "guid": "9ad37173-e640-4b9e-a474-716d482b73d2", + "category": "accessory" + }, + { + "name": "Beekeeper_Shirt_Mask", + "guid": "afd88a79-7f03-40f6-bcb4-c247dd86edc4", + "category": "torso" + }, + { + "name": "LaserTag_Cyan_Swatch", + "guid": "08b7c12d-3317-4d27-9b40-34737a709454", + "category": "accessory" + }, + { + "name": "BlazerDark_Swatch", + "guid": "05ac07e1-67f0-486c-abf5-a62866475abb", + "category": "accessory" + }, + { + "name": "DrumMajorTorso_Mask", + "guid": "b0298c45-0dd1-453e-bad4-41e4656bb38e", + "category": "face" + }, + { + "name": "TrackSuit_Pink_Swatch", + "guid": "026ca50e-3a81-43cf-87f5-950687b7bbdd", + "category": "accessory" + }, + { + "name": "Golfer_Glove_Mask", + "guid": "f4cea991-fc3d-4715-bf82-8a8fb60e8799", + "category": "face" + }, + { + "name": "Aviator_Hat_Tan_Swatch", + "guid": "c6cf096c-f0cb-4500-80a0-bc2916b5a426", + "category": "head" + }, + { + "name": "MobsterWrist_HerringBone_Mask", + "guid": "c2a9c007-2748-4fa2-ab9b-0081eef792f1", + "category": "face" + }, + { + "name": "Baseball_Green_Swatch", + "guid": "e5de2e45-207c-46af-9a5e-eb01a0c621fe", + "category": "accessory" + }, + { + "name": "GreenFlower_Swatch", + "guid": "d66aa400-aa5a-4539-a25d-5f8ce94dc281", + "category": "accessory" + }, + { + "name": "Ranger_Torso_Mask", + "guid": "d21e1b6f-9c9e-42b4-bad5-201ec43161e9", + "category": "face" + }, + { + "name": "Dodgeball_Gray_Swatch", + "guid": "0ba92db2-690a-44ce-92f4-130e9503b6c1", + "category": "accessory" + }, + { + "name": "Aviator_Gloves_Black_Swatch", + "guid": "97bbb384-b37e-4ea1-902c-88fbd6854ab5", + "category": "accessory" + }, + { + "name": "MobsterTorso_HerringBone_Mask", + "guid": "33ee376a-586e-4aff-a7e4-4213445a3e92", + "category": "face" + }, + { + "name": "Birthday01_Swatch", + "guid": "922f229c-124f-4f07-b4e3-19138420ca19", + "category": "accessory" + }, + { + "name": "Wizard_White_Swatch", + "guid": "d3a9b576-5433-4305-bd96-b08820c09212", + "category": "accessory" + }, + { + "name": "PurpleJacket_Mask", + "guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c", + "category": "torso" + }, + { + "name": "Quest_Crown_Decal", + "guid": "f5270130-6634-4052-aa3e-9849ddf5314e", + "category": "head" + }, + { + "name": "Lacross_Stripe_Mask", + "guid": "da36bbcc-2a9a-4ba2-9e4a-6f52c9b1ef74", + "category": "face" + }, + { + "name": "Happy_Tree_Decal", + "guid": "d461ca71-45c9-415e-8e09-ba93e8d73450", + "category": "accessory" + }, + { + "name": "Ranger_Shirt_Green_Swatch", + "guid": "b415fe60-3e9d-4f4d-a53a-ca15eac6af7d", + "category": "torso" + }, + { + "name": "Helixx_swatch", + "guid": "f8635fe2-90bc-42c8-9bbe-3387b5157e18", + "category": "accessory" + }, + { + "name": "Scifi_Carpet_Swatch", + "guid": "0809a5cf-a0f3-4614-9f98-71518524518a", + "category": "accessory" + }, + { + "name": "Golfer_Hat_Mask", + "guid": "ddf429fd-a883-48a5-869b-bc12e44d30bf", + "category": "head" + }, + { + "name": "Teal_Swatch", + "guid": "90bc2d31-1715-4d1a-813a-7c57e66cb017", + "category": "accessory" + }, + { + "name": "ElectricianTorso_Yellow_Swatch", + "guid": "b539777f-48b2-44a2-9d63-17ac703e4d57", + "category": "accessory" + }, + { + "name": "WitchHunterShirt_Mask", + "guid": "0f49ac81-63bd-4845-8bdc-1944181a8aa3", + "category": "torso" + }, + { + "name": "Scallywag_Torso_Swatch_Blue", + "guid": "85eb7f47-bb31-4c66-a8b3-562d35028e6c", + "category": "accessory" + }, + { + "name": "Charcoal_Swatch", + "guid": "1b1d08f2-12ca-43dd-a44f-ea2820b919b4", + "category": "accessory" + }, + { + "name": "Paintball_Red_Swatch", + "guid": "2913bd93-bf35-4bdf-b83a-d40a10213adc", + "category": "accessory" + }, + { + "name": "Jazzercise_Black_Swatch", + "guid": "3b2cfc85-4e2a-4e0d-b738-c296ae8692f8", + "category": "accessory" + }, + { + "name": "FieldHockey_Blue_Swatch", + "guid": "7b0d6661-e856-4ec1-ab90-d89c28a70826", + "category": "accessory" + }, + { + "name": "ArcherTan_Glove_Swatch", + "guid": "d5987fee-e3cc-4336-853a-5df57f73df05", + "category": "accessory" + }, + { + "name": "BicycleJacket_Black_Swatch", + "guid": "83522c12-9549-4d74-8c87-bae210bcd2bf", + "category": "torso" + }, + { + "name": "Hat_FlapperHeadband_Mask", + "guid": "d9be3a98-9da9-419e-9dce-fe58a3ad9c4e", + "category": "head" + }, + { + "name": "TShirt_Scifi_Face_Pink_Swatch", + "guid": "4d52bbd6-ef74-45ae-a6c2-bde10ab8eb16", + "category": "torso" + }, + { + "name": "FlowerSwimsuit_Green_Swatch", + "guid": "cf515c87-3c0a-4443-9f63-5ea1bdbaa61a", + "category": "accessory" + }, + { + "name": "Beekeeper_Shirt_Gold_Swatch", + "guid": "1919d319-6cce-4500-8766-15fed6a13fa8", + "category": "torso" + }, + { + "name": "Argyle_Brown_Swatch", + "guid": "bf82f2f6-9af8-431e-a296-0890dea48ba7", + "category": "accessory" + }, + { + "name": "Paintball_Blue_Swatch", + "guid": "178c6beb-c737-434c-a53b-d14f438f6a98", + "category": "accessory" + }, + { + "name": "Tshirt_KO_Skull_Decal", + "guid": "9e782751-71c6-4208-87da-3a3cffa6a4f0", + "category": "torso" + }, + { + "name": "Torso_SeaCaptain_Mask", + "guid": "8ec5de40-73fc-4f09-9bf3-ddf4701107bc", + "category": "head" + }, + { + "name": "BasketballShirt_Purple_Swatch", + "guid": "4effd67c-a52f-4b41-a5b0-04287f91a7bc", + "category": "torso" + }, + { + "name": "Purple_Swatch", + "guid": "c72911d0-453f-4732-b1bd-dad520220a06", + "category": "accessory" + }, + { + "name": "Scout_Shirt_Mask", + "guid": "5814a331-6155-4df4-b921-3ffc6059e44c", + "category": "torso" + }, + { + "name": "Angler_Blue_Swatch", + "guid": "6e0f3af8-297f-4ea7-8f9e-4a1bc57d3e35", + "category": "accessory" + }, + { + "name": "Hat_CatEars_White_Swatch", + "guid": "c1e430c5-fd8d-4032-9e3f-8d87cc8784da", + "category": "head" + }, + { + "name": "Smuggler_Dark_Swatch", + "guid": "e821aa74-2d47-4388-bcfb-1893b8a83a52", + "category": "accessory" + }, + { + "name": "ScoutSkirt_Shirt_Red_Swatch", + "guid": "2bb65868-ee12-4871-b50d-64bbc9066f0b", + "category": "torso" + }, + { + "name": "Artist_Blue_Swatch", + "guid": "8c94e45c-6605-4b4f-9ba4-2c6c6e557228", + "category": "accessory" + }, + { + "name": "Knight_Black_Swatch", + "guid": "e1f49b74-b071-4bd1-9c4b-af36d24d45c5", + "category": "accessory" + }, + { + "name": "Hat_CatEars_Gold_Swatch", + "guid": "37f8378b-b98c-477a-b8c9-9340772800b5", + "category": "head" + }, + { + "name": "TShirt_KO_Skull_Pink_Swatch", + "guid": "fe304452-b57b-45bc-9c1c-b85b70bcec12", + "category": "torso" + }, + { + "name": "BeltDress_Yellow_Swatch", + "guid": "d61711fd-589a-4669-b991-9408a58fffd9", + "category": "accessory" + }, + { + "name": "ArcherYellow_Glove_Swatch", + "guid": "47da86b9-4e6e-423c-b8c4-ea6bf8472509", + "category": "accessory" + }, + { + "name": "PirateCaptain_Belt_Mask", + "guid": "9b2102f0-e16b-464f-9f1c-f068de52bb89", + "category": "head" + }, + { + "name": "Luchador_Torso_Mask", + "guid": "7ef7d0cf-99f1-4872-9d3d-8230cc9fa368", + "category": "face" + }, + { + "name": "HockeyShirt_Red_Swatch", + "guid": "b1ea158d-1663-4db1-bb09-9847f6e9d1e6", + "category": "torso" + }, + { + "name": "YellowBlueDress_Purple_Swatch", + "guid": "830be2fa-60a5-48cc-931f-34b670eae4bd", + "category": "accessory" + }, + { + "name": "Electrician_Torso_Mask", + "guid": "daa38a31-fb7b-4e26-a2f5-728d6dcec81a", + "category": "face" + }, + { + "name": "Torso_Cowboy_Mask", + "guid": "98553538-cafd-4df9-8f37-bb0a4bb478a5", + "category": "face" + }, + { + "name": "Firefighter_Hat_Yellow_Swatch", + "guid": "4c26d1e7-2382-4478-b9c4-a068ed01a607", + "category": "head" + }, + { + "name": "Aviator_Goggles_Mask", + "guid": "556f876a-70da-4104-b792-fca8ad67be0c", + "category": "face" + }, + { + "name": "Scallywag_Bracelet_Red_Swatch", + "guid": "62bd5dda-08da-4528-83c1-f0ca3812dc6e", + "category": "accessory" + }, + { + "name": "Hat_Hockey_Mask", + "guid": "7c4d447c-6594-4bd8-9510-12ad683ab116", + "category": "head" + }, + { + "name": "DrumMajorGlove_Mask", + "guid": "6b38ab79-142b-446e-978c-ad829ade8823", + "category": "face" + }, + { + "name": "Hat_Cowboy_Mask", + "guid": "54778d93-2cff-48db-b25b-65c08b0e5be8", + "category": "head" + }, + { + "name": "Scout_RaccoonHat_Gray_Swatch", + "guid": "926ae943-76cb-46dd-9c66-a6ee44ea9194", + "category": "head" + }, + { + "name": "Scallywag_Hat_Mask", + "guid": "a4903da3-249d-4f0a-a746-b547fda371ff", + "category": "head" + }, + { + "name": "Denim_Mask", + "guid": "f600037d-c9c0-43fa-b45b-02f456f9dd5f", + "category": "face" + }, + { + "name": "DrumMajor_Orange_Swatch", + "guid": "82de1ca3-67d6-454d-84fb-0eeece71fcbc", + "category": "accessory" + }, + { + "name": "LaserTag_Wrist_Mask", + "guid": "3b825377-d2eb-41e4-a736-b107f88f62d6", + "category": "face" + }, + { + "name": "PurpleJacket_Blue_Swatch", + "guid": "b42d903b-b393-4f49-af5e-38d0fb6c42b0", + "category": "torso" + }, + { + "name": "Angler_Tan_Swatch", + "guid": "cac27853-e2df-4d77-b3fd-af3bc0813f01", + "category": "accessory" + }, + { + "name": "MobsterWrist_Fashion_Gold_Mask", + "guid": "05cff8b8-d437-485a-b4b8-41d35becd022", + "category": "face" + }, + { + "name": "Scout_Sash_Brown_Swatch", + "guid": "639f5e2c-6fbd-4ab4-aa10-7772e0932798", + "category": "accessory" + }, + { + "name": "Torso_SeaCaptainBlueWhite_Swatch", + "guid": "857fdcd8-704f-43d0-92b9-bca26619b153", + "category": "head" + }, + { + "name": "Scallywag_Torso_Stripped_Mask", + "guid": "9f1dd474-34a3-47eb-bb72-dbfc8d2d6ecf", + "category": "face" + }, + { + "name": "Zebra_Mask", + "guid": "b78008e8-abbd-4ece-be34-9a911f721fcc", + "category": "face" + }, + { + "name": "Smuggler_Cherry_Swatch", + "guid": "fed760b2-15e2-46b2-b1e3-cc3c8d780d5d", + "category": "accessory" + }, + { + "name": "Beekeeper_Hat_Mask", + "guid": "e4b53a9a-ac95-49ae-bf68-615bb23fa075", + "category": "head" + }, + { + "name": "HeadBand_Royal_Mask", + "guid": "56a92c8d-af53-413e-929e-4a9a3cfad780", + "category": "face" + }, + { + "name": "AnglerHat_Gray_Swatch", + "guid": "225209dc-3afc-40cf-858e-89f7afa00c0d", + "category": "head" + }, + { + "name": "BaseballHelmet_Green_Swatch", + "guid": "9967ce54-c2b7-437a-946e-11f8ee6d6fdd", + "category": "head" + }, + { + "name": "Glasses_Smuggler_Mask", + "guid": "fe01b6ed-3c36-47b6-bcfa-47864ef9d610", + "category": "face" + }, + { + "name": "Scallywag_Bracelet_Blue_Swatch", + "guid": "cdca593a-8840-4140-9145-fe962732040c", + "category": "accessory" + }, + { + "name": "Wrist_Smuggler_Mask", + "guid": "8ffce0d6-d03e-4097-bc46-09b8894bc1d3", + "category": "face" + }, + { + "name": "CowboyDark_Swatch", + "guid": "192ba73d-6990-42da-8fe0-914c2a7181eb", + "category": "accessory" + }, + { + "name": "BikeHelmet_Mask", + "guid": "5fd9e83a-cfaf-4c98-bd31-91a7484c26dd", + "category": "head" + }, + { + "name": "Torso_ButtonTop_Mask", + "guid": "dafa658e-753b-46cb-bd85-85c1de5e6ea7", + "category": "face" + }, + { + "name": "FlowerSwimsuit_Pink_Swatch", + "guid": "27680277-aec1-41be-a476-9f51cf775580", + "category": "accessory" + }, + { + "name": "Artist_Red_Swatch", + "guid": "17b16982-871c-476a-860f-f6953c89ee8c", + "category": "accessory" + }, + { + "name": "2016_Member_Decal", + "guid": "167ba0d4-4fac-4143-b5ae-1dbd8e02dc76", + "category": "accessory" + }, + { + "name": "Scout_Sash_Red_Swatch", + "guid": "b520f3bc-599a-498c-8a4f-97cc25aa741f", + "category": "accessory" + }, + { + "name": "Glove_Safari_Mask", + "guid": "fa9a3d8e-16c9-4a7e-9166-3226e0d13c39", + "category": "face" + }, + { + "name": "DrumMajor_Green_Swatch", + "guid": "fe5288bc-0e02-4ea3-b17d-dee0e576979d", + "category": "accessory" + }, + { + "name": "CheerDress_Blue_Swatch", + "guid": "faedf215-289b-40f7-90cc-f5b98517d133", + "category": "accessory" + }, + { + "name": "SpaceMarine_Cyan_Swatch", + "guid": "c8cc9589-4831-4e73-a865-ef8c139a2ba7", + "category": "accessory" + }, + { + "name": "Denim_Swatch", + "guid": "55901f12-d5b5-4fa8-b4c8-e479689ee39d", + "category": "accessory" + }, + { + "name": "FootballPads_Orange_Swatch", + "guid": "32dde6dc-e3dc-43ee-9b7c-987bc6778c17", + "category": "feet" + }, + { + "name": "SFVRCC_swatch", + "guid": "14afd295-bcd0-4595-80dc-d477618b9ab9", + "category": "accessory" + }, + { + "name": "BikeJersey_Mask", + "guid": "1695608b-e9cc-4a03-b56d-0f09d2804379", + "category": "face" + }, + { + "name": "Torso_SeaCaptainBlueDark2_Swatch", + "guid": "3df5d13b-ba77-4b51-90b2-7a946e4d0be7", + "category": "head" + }, + { + "name": "PirateCaptain_Hat_Red_Swatch", + "guid": "17040a88-87f1-4613-b439-3316e33dbd48", + "category": "head" + }, + { + "name": "CollarDress_Pink_Swatch", + "guid": "6564acf1-4d70-4f92-92ac-08e2b76dbb6b", + "category": "accessory" + }, + { + "name": "Torso_SeaCaptainDark_Swatch", + "guid": "b9f51861-ad10-4878-a9b1-d849bccf532a", + "category": "head" + }, + { + "name": "PurpleJacket_Black_Swatch", + "guid": "8cab8cc6-9a2d-434c-8b72-e66edbdd223c", + "category": "torso" + }, + { + "name": "Hat_SeaCaptainFushia_Swatch", + "guid": "2738ab5f-8c94-46b3-bac6-4a8111c41c21", + "category": "head" + }, + { + "name": "Luchador_Red_Swatch", + "guid": "916384ca-e209-42a0-bd64-c64f5a40b56f", + "category": "accessory" + }, + { + "name": "FlapperGlove_Gold_Swatch", + "guid": "2cf2b644-3fa6-449c-9fe8-d0ab917f3106", + "category": "accessory" + }, + { + "name": "BlueJacket_Mint_Swatch", + "guid": "cd5d7285-202d-42d0-b93f-04245875793e", + "category": "torso" + }, + { + "name": "FootballPads_Dark_Swatch", + "guid": "88c3ed05-ce41-4a9d-a4a7-8a428d240b3f", + "category": "feet" + }, + { + "name": "Pink_Swatch", + "guid": "cbe29e9f-f2ac-47fb-97e1-8bad16abb89d", + "category": "accessory" + }, + { + "name": "Aviator_Hat_Black_Swatch", + "guid": "a8de80d3-286a-4fdc-bed2-75d52f8c0964", + "category": "head" + }, + { + "name": "Aviator_Goggles_Black_Swatch", + "guid": "7b82c3c5-fd1f-45ec-b83f-e32668aa8dce", + "category": "face" + }, + { + "name": "Mobster_Fashion_Gold_Swatch", + "guid": "46970903-9799-4d25-b90a-7162c1f5dbef", + "category": "accessory" + }, + { + "name": "Golfer_Shirt_Grey_Swatch", + "guid": "4c845dbb-26c3-48a1-b562-e28a7416b154", + "category": "torso" + }, + { + "name": "Royal_Swatch", + "guid": "6dd95046-acf8-42fe-ab78-80a334096a9d", + "category": "accessory" + }, + { + "name": "GoldYellow_Swatch", + "guid": "67bcca75-4ab1-4964-8688-9908c464d355", + "category": "accessory" + }, + { + "name": "Hat_SeaCaptainDarkGold_Swatch", + "guid": "f5403c89-9769-479b-8a05-c71607f9b189", + "category": "head" + }, + { + "name": "LaserTag_Torso_Mask", + "guid": "959ed05b-0215-4bbe-bb3d-64e4448d676b", + "category": "face" + }, + { + "name": "DrumMajor_Purple_Swatch", + "guid": "54d3c589-30ba-44c1-a77c-8f9e16eb0b20", + "category": "accessory" + }, + { + "name": "Hat_TopHat_Mask", + "guid": "ccf1ccc1-e229-4157-bb74-f2cdef01e547", + "category": "head" + }, + { + "name": "PirateCaptain_Belt_Red_Swatch", + "guid": "0f71b9a2-dd88-435c-a571-4503d12dcf2d", + "category": "head" + }, + { + "name": "ArcherBlack_Glove_Swatch", + "guid": "2be2f040-68e4-44dd-9099-29f11110f3a4", + "category": "accessory" + }, + { + "name": "Barbershop_Hat_Mask", + "guid": "83158223-e268-4036-8a6a-218a8430eb2c", + "category": "head" + }, + { + "name": "Wizard_Torso_Mask", + "guid": "8768aa33-b014-4062-b323-b146dd7d78d2", + "category": "face" + }, + { + "name": "JerseyOutline_Mask", + "guid": "d2a692e6-e1a9-4cfe-8154-10b52be7f8c8", + "category": "face" + }, + { + "name": "Hat_HairBow_Mask", + "guid": "e0186718-4a18-434b-b96e-0c9c912f0d1f", + "category": "head" + }, + { + "name": "Basketball_Shirt_Mask", + "guid": "54a5c055-f757-4c00-a1e1-8b0b6552c608", + "category": "torso" + }, + { + "name": "Equestrian_Jacket_Mask", + "guid": "d344b8cc-85a8-4ace-9f92-38c84f396e99", + "category": "torso" + }, + { + "name": "Barber_Torso_Green_Swatch", + "guid": "c64ac7a9-7fd4-4587-8626-0413a190b706", + "category": "accessory" + }, + { + "name": "FighterPilot_Blue_Swatch", + "guid": "e37b07d1-6b9d-49b9-94ca-1c728e228cfe", + "category": "accessory" + }, + { + "name": "MobsterTorso_Fashion_Gold_Mask", + "guid": "c2052d8c-fbcd-4462-8f08-bcf5b3dfde2f", + "category": "face" + }, + { + "name": "AnglerHat_Tan_Swatch", + "guid": "3f6c8bd2-2c1d-4c8c-a9de-16600fee9645", + "category": "head" + }, + { + "name": "CheerDress_Red_Swatch", + "guid": "1773d5d8-4714-4721-b30c-97e25d6f2a5a", + "category": "accessory" + }, + { + "name": "FlatCap_Hat_Mask", + "guid": "73fc2e2a-a036-410b-bafa-3a07658245b5", + "category": "head" + }, + { + "name": "Golfer_Glove_Blue_Swatch", + "guid": "32a5e42d-2b60-40b1-a145-ed96fc91af8e", + "category": "accessory" + }, + { + "name": "TShirt_RECS_Logo_Blue_Swatch", + "guid": "bde08851-a9e4-4faa-8f04-a0a661672472", + "category": "torso" + }, + { + "name": "Scout_Shirt_Blue_Swatch", + "guid": "0d2830e7-8eb0-4fa3-99bc-7c80f01dd991", + "category": "torso" + }, + { + "name": "DrumMajorGlove_Purple_Swatch", + "guid": "8053b5e5-0f67-4329-a45d-1ee9fc830820", + "category": "accessory" + }, + { + "name": "Shirt_SpaceMarine_Mask", + "guid": "7ac81d60-05b8-4a85-982c-f69213a93818", + "category": "torso" + }, + { + "name": "SFVRCC_Decal", + "guid": "a74e6c0d-1300-4928-9141-977a711b71c4", + "category": "accessory" + }, + { + "name": "BlueJacket_Torso_Mask", + "guid": "0f36bb97-c61b-4281-929f-ff1d0d11be86", + "category": "torso" + }, + { + "name": "Wizard_Hat_Mask", + "guid": "abdf525b-b871-41be-a1c8-810f49740ad4", + "category": "head" + }, + { + "name": "Wrist_SpaceMarine_Mask", + "guid": "7634b7ef-9607-497a-b41f-c0191dc75998", + "category": "face" + }, + { + "name": "ElectricianTorso_Green_Swatch", + "guid": "67fc9269-8dd8-4d6f-b594-c77e716f2482", + "category": "accessory" + }, + { + "name": "TrackSuit_Mask", + "guid": "adae82eb-a4b9-4541-acf2-76cea839a593", + "category": "face" + }, + { + "name": "Tshirt_HangInThere_Decal", + "guid": "8f19d7f9-4f28-48ab-a1c5-d8f4f788514c", + "category": "torso" + }, + { + "name": "DrumMajorGlove_Orange_Swatch", + "guid": "b444ed0a-eab5-4bd6-af34-d080de74a149", + "category": "accessory" + }, + { + "name": "Punk_WristRed_Swatch", + "guid": "baffdefe-de21-4551-9330-b50873965bdd", + "category": "accessory" + }, + { + "name": "PirateCaptain_Wrist_Red_Mask", + "guid": "7a7bc314-4ffd-4ac8-9ed6-6ca73589c132", + "category": "head" + }, + { + "name": "Punk_Green_Swatch", + "guid": "0724b166-9ace-46ff-a191-b13f135b00a8", + "category": "accessory" + }, + { + "name": "ArcherRed_Glove_Swatch", + "guid": "0c433086-9501-48c4-8d0e-4474eb420c61", + "category": "accessory" + }, + { + "name": "Tshirt_KO_Skull_Pink_Decal", + "guid": "6a007c73-d930-408a-93df-041a715e0dd4", + "category": "torso" + }, + { + "name": "Wrist_Knight_Gauntlet_Mask", + "guid": "fd67ec40-f898-4df6-b846-8bf350f362b8", + "category": "face" + }, + { + "name": "Lacrosse_Stripe_Purple_Swatch", + "guid": "e3454bc7-c88d-4958-8b81-db3fe5472a1e", + "category": "accessory" + }, + { + "name": "SciFi_CarpetTie_Mask", + "guid": "8212812b-e7e7-4f6d-aa27-d1112af45473", + "category": "face" + }, + { + "name": "PirateCaptain_Torso_Mask", + "guid": "52053afd-9f12-4fec-a358-7eb3fe8c19bb", + "category": "head" + }, + { + "name": "Scallywag_Bracer_Mask", + "guid": "ce3782aa-56cc-40d7-9019-8ce714cb7748", + "category": "face" + }, + { + "name": "CheerDress_Mask", + "guid": "93987d32-7a6e-45df-af56-76f912969ce7", + "category": "face" + }, + { + "name": "TShirt_RECS_Logo_Green_Swatch", + "guid": "5e7a48ed-8f08-4990-a3ae-05722c9f59a8", + "category": "torso" + }, + { + "name": "Wrist_Cowboy_Mask", + "guid": "8eef8837-9141-4088-835a-b2f47f4126f8", + "category": "face" + }, + { + "name": "HorizontalStripe3_Mask", + "guid": "9c03f381-7357-4d0f-8cda-8737d4c43d25", + "category": "face" + }, + { + "name": "PirateCaptain_Torso_Striped_Mask", + "guid": "00ac4e37-6eac-4ed5-b46c-2c075038bc3f", + "category": "head" + }, + { + "name": "Wizard_Green_Swatch", + "guid": "357eb160-360b-4a63-840d-65d11cc8ffc2", + "category": "accessory" + }, + { + "name": "Angler_Gray_Swatch", + "guid": "97938f4c-52b8-4c85-8575-856654666316", + "category": "accessory" + }, + { + "name": "FootballHelmet_Dark_Swatch", + "guid": "61ff7a02-50ff-49ea-b95e-c31b95a30c8e", + "category": "head" + }, + { + "name": "Torso_BabydollDress_Mask", + "guid": "cb9e9e05-c481-4397-86e7-c2111bc0e817", + "category": "face" + }, + { + "name": "LaserTagVisor_Cyan_Swatch", + "guid": "426d0592-f8e4-4bc6-9fe1-a62b91fd57d5", + "category": "accessory" + }, + { + "name": "Torso_SeaCaptainWhiteDark_Swatch", + "guid": "b5d62931-e756-4a8a-8f3b-760beba49319", + "category": "head" + }, + { + "name": "Wrist_FlapperGlove_Mask", + "guid": "0e42108e-3b08-49b4-86c0-7984719e8380", + "category": "face" + }, + { + "name": "LightBlue_Swatch", + "guid": "484b6c13-af22-4ad5-8c43-34c0de095d49", + "category": "accessory" + }, + { + "name": "Aviator_Gloves_Mask", + "guid": "5659ce2c-9cb0-49da-8c39-4b7df0f8deec", + "category": "face" + }, + { + "name": "Hat_SeaCaptain_Mask", + "guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9", + "category": "head" + }, + { + "name": "CowboyLight_Swatch", + "guid": "5e00a832-3f25-4dd3-84d2-b6ed6a4b4c37", + "category": "accessory" + }, + { + "name": "Happy_Tree_Swatch", + "guid": "ba6b6e1a-a09a-4ba0-9523-552869f03336", + "category": "accessory" + }, + { + "name": "Smuggler_Yellow_Swatch", + "guid": "002b07bb-4256-44ce-8ca4-25510e70b6b5", + "category": "accessory" + }, + { + "name": "BlazerMidnightBlue_Swatch", + "guid": "6f2e74bf-1e95-463d-97db-d5d1a53b2c28", + "category": "accessory" + }, + { + "name": "BicycleJacket_Blue_Swatch", + "guid": "c5884778-a87f-4612-9717-86fbb65d440f", + "category": "torso" + }, + { + "name": "Mobster_WhiteHatHacker_Swatch", + "guid": "40283127-2ffc-4989-82f6-29c3381bf9e2", + "category": "head" + }, + { + "name": "Torso_SeaCaptainBlackDark_Swatch", + "guid": "6145292a-9002-41c6-8cd5-43679b6f542f", + "category": "head" + }, + { + "name": "Mobster_Hat_Gray_Swatch", + "guid": "390a476a-5545-48eb-955c-1bf1a16012bf", + "category": "head" + }, + { + "name": "SpaceMarine_Light_Swatch", + "guid": "68273c9e-96d6-4ddb-aaab-d99229e864d5", + "category": "accessory" + }, + { + "name": "Shirt_Punk_Mask", + "guid": "d738ad4c-c9b5-41e6-8979-d586c964fb6c", + "category": "torso" + }, + { + "name": "Aviator_Jacket_Black_Swatch", + "guid": "da185fbb-ad2b-4f30-87a2-491f24b78ebb", + "category": "torso" + }, + { + "name": "Scifi_Carpet_TopHat_Swatch", + "guid": "bd4a84e2-b67a-4269-a26a-17fb23ddb09e", + "category": "head" + }, + { + "name": "Torso_Jazzercise_Mask", + "guid": "f6f26e2b-657b-454a-8663-55d6b9b6e50c", + "category": "face" + }, + { + "name": "RedFlowerDress_Yellow_Swatch", + "guid": "e98c3f98-7844-4d6e-a21c-05027033c18c", + "category": "accessory" + }, + { + "name": "Eye_MirroredGlasses_Mask", + "guid": "9c55b609-b7dc-4070-a3ad-6351a0054a06", + "category": "face" + }, + { + "name": "Plaid_Red_Swatch", + "guid": "827a1538-51bb-4fef-99c1-7f1bebd9866c", + "category": "accessory" + }, + { + "name": "Scout_Sash_Mask", + "guid": "66cc536d-8cb5-4262-b7f0-49a33b97ea10", + "category": "face" + }, + { + "name": "ArcherGlove_Mask", + "guid": "7b187c06-7ba2-4f3c-bca9-fdf98146f385", + "category": "face" + }, + { + "name": "Wizard_Black_Swatch", + "guid": "7afe1bb0-5ca9-4263-8f85-49d3a18353c7", + "category": "accessory" + }, + { + "name": "Wrist_CowboyLight_Swatch", + "guid": "c0c33a35-494c-4a1a-aa53-d27d9b9ef5a4", + "category": "accessory" + }, + { + "name": "FlowerSwimsuit_Gray_Swatch", + "guid": "5a96ac4d-03c3-4214-9dcb-c2044ff28f2e", + "category": "accessory" + }, + { + "name": "SafariTorso_Mask", + "guid": "84c016ad-133f-45e3-a412-cac35612540b", + "category": "face" + }, + { + "name": "BabydollDress_Pink_Swatch", + "guid": "da6d0ced-3d43-4878-a868-925f2ed6fd5b", + "category": "accessory" + }, + { + "name": "Barber_Wrist_Formal_Swatch", + "guid": "bb01b51e-a95f-441e-8c52-abc3aeed7145", + "category": "accessory" + }, + { + "name": "PirateCaptain_Red_Swatch", + "guid": "556141f7-6fbc-4065-9faa-8d8240a93d7f", + "category": "head" + }, + { + "name": "FighterPilot_Mask", + "guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7", + "category": "face" + }, + { + "name": "Luchador_Blue_Swatch", + "guid": "0217fd2e-ff9d-49f7-9bc8-8d0d6b8bf250", + "category": "accessory" + }, + { + "name": "Jazzercise_Yellow_Swatch", + "guid": "78eb0969-f246-4cac-acd6-295df7c092e4", + "category": "accessory" + }, + { + "name": "RecRoom_FACE_DEFAULT_Decal", + "guid": "0c496f32-0011-4c4d-9778-59446f70c032", + "category": "accessory" + }, + { + "name": "HockeyShirt_Mask", + "guid": "e5635772-4e84-4b30-b77f-4a1196cb599b", + "category": "torso" + }, + { + "name": "2016Member_Mask", + "guid": "2ee3944d-e9fb-4959-973e-570a7fc6cc57", + "category": "face" + }, + { + "name": "Tshirt_RECS_Logo_Decal", + "guid": "237e0aa2-5911-4704-8d2a-d446ddd2d842", + "category": "torso" + }, + { + "name": "Barbershop_Wrist_Mask", + "guid": "f7c7d05f-5454-445a-af38-25f110bd204a", + "category": "face" + }, + { + "name": "VerticalStripe_Mask", + "guid": "c2a91a10-2f5b-4ab0-a79f-51408cf4b047", + "category": "face" + }, + { + "name": "Scallywag_Torso_Mask", + "guid": "005699fe-2c69-4bec-831f-031fe8ad7f0a", + "category": "face" + }, + { + "name": "Golfer_Glove_Grey_Swatch", + "guid": "72a010c0-bddd-4e5d-bca6-9821d82de9a6", + "category": "accessory" + }, + { + "name": "StollDress_Green_Swatch", + "guid": "a819f49b-6c7a-49d3-9e6a-d9d79ef5019f", + "category": "accessory" + }, + { + "name": "Torso_SeaCaptainBlueDark_Swatch", + "guid": "7c1aaa7e-503b-46ef-b1c5-0c61a75b916e", + "category": "head" + }, + { + "name": "BlazerSeaDark_Swatch", + "guid": "0ffad843-d6c9-425a-8686-7217009c867e", + "category": "accessory" + }, + { + "name": "Torso_SeaCaptainBlue_Swatch", + "guid": "27af51bc-8226-4911-8d47-22d28ea8e378", + "category": "head" + }, + { + "name": "Helixx_Decal", + "guid": "49934059-cc45-46ce-8277-f96959e1f6ba", + "category": "accessory" + }, + { + "name": "HibiscusFlower_Mask", + "guid": "65b89eae-347e-4eff-9ff3-7f25cace0125", + "category": "face" + }, + { + "name": "HeadBand_Trim_Mask", + "guid": "018a5c07-e956-457d-a540-a5e2cd68da09", + "category": "face" + }, + { + "name": "HockeyShirt_Teal_Swatch", + "guid": "fcbdfbfd-254a-4538-b119-43e23404b8b5", + "category": "torso" + }, + { + "name": "Orange_Swatch", + "guid": "51ef8d39-2b94-4f9e-9620-07b6b0a913a5", + "category": "accessory" + }, + { + "name": "Shirt_Smuggler_Mask", + "guid": "970a8c7e-d091-4719-bb9c-e3be8d46ee68", + "category": "torso" + }, + { + "name": "LaserTag_Head_Mask", + "guid": "4e378419-d043-4e86-ad0e-66288ac21cf4", + "category": "face" + }, + { + "name": "ArcherTan_Quiver_Swatch", + "guid": "d1eb3573-672c-4bb4-9ec5-7c81e6a38d3d", + "category": "accessory" + }, + { + "name": "Barber_Hat_Green_Swatch", + "guid": "7f9cb939-36a4-40da-8337-025e1d7bf8e5", + "category": "head" + }, + { + "name": "Punk_WristGreen_Swatch", + "guid": "a6796264-9c7e-4605-830c-06db8d0dc780", + "category": "accessory" + }, + { + "name": "FieldHockey_Mask", + "guid": "1fede91a-434e-49a5-882e-e6db107112a8", + "category": "face" + }, + { + "name": "Firefighter_Hat_White_Swatch", + "guid": "891d902e-3257-4a8c-9001-858046007997", + "category": "head" + }, + { + "name": "Scallywag_Hat_Red_Swatch", + "guid": "c3c9f89c-580c-43c4-b38b-07394044e0e1", + "category": "head" + }, + { + "name": "SpaceMarine_Dark_Swatch", + "guid": "a089bcc6-798d-4777-98e2-7afd290dc6b6", + "category": "accessory" + }, + { + "name": "PirateCaptain_Hat_Black_Mask", + "guid": "4cc0ea9f-65af-486f-8319-02b85ab5197f", + "category": "head" + }, + { + "name": "DrumMajorHat_Green_Swatch", + "guid": "5a614f97-ef7f-4b70-afff-9ed837a86407", + "category": "head" + }, + { + "name": "Golfer_Hat_Blue_Swatch", + "guid": "6584dada-499e-4d7c-958d-466955b20640", + "category": "head" + }, + { + "name": "RecRoom_Birthday01_Decal", + "guid": "ceb07e4f-50a9-4ed0-b50b-c59d92ba66b5", + "category": "accessory" + }, + { + "name": "Black_Swatch", + "guid": "e69372eb-2fd8-4e17-a448-ee0be61a2c7a", + "category": "accessory" + }, + { + "name": "ArcherRed_Swatch", + "guid": "5b5281c9-b795-4bb2-ba7b-ba52047ebe3f", + "category": "accessory" + }, + { + "name": "CollarDress_LightBlue_Swatch", + "guid": "f750de46-3758-4f7d-9709-0a84b1027009", + "category": "accessory" + }, + { + "name": "StollDress_Blue_Swatch", + "guid": "761a3193-60f0-4190-80c7-285b8192e794", + "category": "accessory" + }, + { + "name": "Torso_YellowBlueDress_Mask", + "guid": "cfabdefe-0890-436e-b2a3-b5c712e22955", + "category": "face" + }, + { + "name": "Boxer_Glove_Mask", + "guid": "f79f8a46-ef1d-4d8c-a4fc-383e3a9eb10b", + "category": "face" + }, + { + "name": "Torso_RedBeltDress_Mask", + "guid": "3d971439-41d1-43a2-8434-704f9ec8d906", + "category": "face" + }, + { + "name": "Argyle_Red_Swatch", + "guid": "071d1bf3-86dc-4a31-b54a-e6579fab8649", + "category": "accessory" + }, + { + "name": "WitchHunter_White_Swatch", + "guid": "e02838f8-4029-455a-b35c-dd40f972b05b", + "category": "accessory" + }, + { + "name": "RedFlowerDress_Mask", + "guid": "2d7361d2-31a2-404b-90da-10e89114e1f2", + "category": "face" + }, + { + "name": "TShirt_LaserTag_Alpha_Swatch", + "guid": "c0ff7719-c56a-4934-831b-506ad0f51cca", + "category": "torso" + }, + { + "name": "Wrist_Punk_Mask", + "guid": "dd7472ed-bdcb-4811-81ba-5ed682fb7675", + "category": "face" + }, + { + "name": "Angler_Hat_Mask", + "guid": "efeb07ab-6253-436d-bb66-991ddad72e58", + "category": "head" + }, + { + "name": "ArcherHat_Mask", + "guid": "1ba65dd5-9df9-4ff3-be96-f83a7abf67b3", + "category": "head" + }, + { + "name": "ArcherRed_Quiver_Swatch", + "guid": "7221132e-053a-4c58-b719-ed3c8558907d", + "category": "accessory" + }, + { + "name": "Luchador_Head_Mask", + "guid": "96b36c98-cba1-4337-91dd-c74c00316f97", + "category": "face" + }, + { + "name": "Tennis_Pink_Swatch", + "guid": "304e5cf1-5c94-481f-8e49-e6a943e6b300", + "category": "accessory" + }, + { + "name": "PoofyDress_Orange_Swatch", + "guid": "64850553-cdfe-455a-ac00-dafbe63d613e", + "category": "accessory" + }, + { + "name": "ElectricianTorso_Beige_Swatch", + "guid": "5fb2e235-8c67-4335-b432-edb10db2b139", + "category": "accessory" + }, + { + "name": "WitchHunterGlove_Mask", + "guid": "5b8d9490-e5bc-480e-91bc-65d1797b52ad", + "category": "face" + }, + { + "name": "Knight_White_Swatch", + "guid": "22dc463a-a89b-46ad-9a0d-557c742b4a80", + "category": "accessory" + }, + { + "name": "Mobster_Hat_Mask", + "guid": "d759c88d-a159-4c26-86dd-b054bb05b3ed", + "category": "head" + }, + { + "name": "BicycleHelmet_Yellow_Swatch", + "guid": "4398ce1a-e6ee-4da4-ad0d-7abfab41020c", + "category": "head" + }, + { + "name": "Golfer_Hat_Grey_Swatch", + "guid": "48c18418-01dd-40ff-9dfe-c4cf5f4e1245", + "category": "head" + }, + { + "name": "BasketballShirt_Blue_Swatch", + "guid": "59a62fa3-9e15-463e-9bd5-3aecdf604c1b", + "category": "torso" + }, + { + "name": "FurCoat_White_Swatch", + "guid": "643b5f2d-0666-4869-b699-03742de34d16", + "category": "accessory" + }, + { + "name": "Wrist_CowboyDark_Swatch", + "guid": "c4ebfd1a-fcda-4b98-a151-a941b1801867", + "category": "accessory" + }, + { + "name": "Hat_SeaCaptainDarkSilver_Swatch", + "guid": "36e63797-5a42-4779-a5cc-ef5c2e7d17d4", + "category": "head" + }, + { + "name": "HockeyShirt_Purple_Swatch", + "guid": "d5efaf80-7f34-4c9a-bc3a-b80b1ac70ace", + "category": "torso" + }, + { + "name": "ArcherYellow_Quiver_Swatch", + "guid": "e548124c-d7f3-45de-aaa0-f66130eca576", + "category": "accessory" + }, + { + "name": "ArcherTan_Swatch", + "guid": "9b35ae52-febe-486a-8557-ec7190a9756e", + "category": "accessory" + }, + { + "name": "Paintball_Torso_Mask", + "guid": "0189ec4c-0389-4ebc-b9ba-32303a6341c2", + "category": "face" + }, + { + "name": "Boxer_Helmet_Mask", + "guid": "92714eac-cc07-4f74-a6f5-cfbbadd07e06", + "category": "head" + }, + { + "name": "FighterPilot_Hat_Mask", + "guid": "9d6ac0b7-bbef-40bc-9173-effe65d7c0ff", + "category": "head" + }, + { + "name": "WitchHunter_Red_Swatch", + "guid": "909ed249-ce09-4304-8b02-56794fa7567d", + "category": "accessory" + }, + { + "name": "Dodgeball_Black_Swatch", + "guid": "4a85c181-92fe-47a5-925e-05f0ccb25006", + "category": "accessory" + }, + { + "name": "BlazerGrey_Swatch", + "guid": "272fe8eb-5061-4729-a7a8-414ff667a82f", + "category": "accessory" + }, + { + "name": "TennisSkirt_Red_Swatch", + "guid": "758752bd-db2f-43d2-b580-55b3e1efffd5", + "category": "legs" + }, + { + "name": "Hat_Equestrian_Mask", + "guid": "be6b3fa1-3388-48ac-8957-d7b8c85ff968", + "category": "head" + }, + { + "name": "Wizard_Red_Swatch", + "guid": "a38254c0-00e3-429b-9d5d-b9e6a9812e69", + "category": "accessory" + }, + { + "name": "FighterPilot_Gloves_Blue_Swatch", + "guid": "55b4e661-43e1-4708-82d5-2dd2bebd4f45", + "category": "accessory" + }, + { + "name": "WitchHunterHat_Mask", + "guid": "32d91d32-b102-4093-927a-6dc3ac2e9e86", + "category": "head" + }, + { + "name": "Stripe3_Swatch", + "guid": "5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b", + "category": "accessory" + }, + { + "name": "ArtistHat_Blue_Swatch", + "guid": "14b43bf9-5ba0-484e-ae5d-55cfeb09a70d", + "category": "head" + }, + { + "name": "ArcherQuiver_Mask", + "guid": "6094370d-5e28-473a-b422-f3f1c75d5db8", + "category": "face" + }, + { + "name": "Scout_Hat_Mask", + "guid": "464fcf20-361f-4dc9-b074-150c3bc27523", + "category": "head" + }, + { + "name": "Ranger_Hat_Green_Swatch", + "guid": "d0bb468c-b78f-4e80-916d-65fec3ec0010", + "category": "head" + } +] \ No newline at end of file diff --git a/DorkNet.Server/DorkNet.Server.csproj b/DorkNet.Server/DorkNet.Server.csproj new file mode 100644 index 0000000..4e2687e --- /dev/null +++ b/DorkNet.Server/DorkNet.Server.csproj @@ -0,0 +1,154 @@ + + + + net9.0 + enable + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + data\%(Filename)%(Extension) + PreserveNewest + + + data\images\%(Filename)%(Extension) + PreserveNewest + + + data\images\%(Filename)%(Extension) + PreserveNewest + + + + diff --git a/DorkNet.Server/DorkNet.Server.csproj.lscache b/DorkNet.Server/DorkNet.Server.csproj.lscache new file mode 100644 index 0000000..3d6d569 --- /dev/null +++ b/DorkNet.Server/DorkNet.Server.csproj.lscache @@ -0,0 +1,659 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=DorkNet.Server +CommandLineArgsForDesignTimeEvaluation=-langversion:13.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=13.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=DorkNet.Server +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../DorkNet.sln +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net9.0/DorkNet.Server.dll +TargetRefPath=obj/Debug/net9.0/ref/DorkNet.Server.dll +TemporaryDependencyNodeTargetIdentifier=net9.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:9 +/define:TRACE;DEBUG;NET;NET9_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net9.0\DorkNet.Server.dll +/refout:obj\Debug\net9.0\refint\DorkNet.Server.dll +/target:exe +/warnaserror- +/utf8output +/deterministic+ +/langversion:13.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +Auth/ + AdminOnlyAttribute.cs + BanCheckMiddleware.cs + ControllerBaseExtensions.cs + IpBanCheckMiddleware.cs +Controllers/ + Accounts/AccountsController.cs + Admin/ + AdminController.cs + AdminLoginController.cs + RoomZipImportController.cs + API/ + Account/AccountApiController.cs + AllEndpointsController.cs + Auth/PhotonAuthController.cs + Avatar/V2/ + AvatarGiftsController.cs + AvatarV2Controller.cs + Avatar/ + V3/AvatarSavedOutfitsController.cs + V4/AvatarItemsController.cs + BugReporting/BugReportsController.cs + CommunityBoard/CommunityBoardController.cs + Config/V1/ + ConfigController.cs + ServiceUrlsController.cs + Config/V2/ConfigController.cs + Events/V3/EventsController.cs + GameConfigurations/GameConfigurationsController.cs + GameSessions/V2/GameSessionsController.cs + Groups/GroupsController.cs + Images/ + ImagesController.cs + V2/ImagesController.cs + Inventions/InventionsController.cs + Inventory/InventoryController.cs + Leaderboard/LeaderboardController.cs + Messages/V2/MessagesController.cs + Misc/StubExtensionController.cs + MissingEndpointsController.cs + Moderation/InGameModerationController.cs + Notification/V2/NotificationController.cs + NotificationPrefs/IOSNotificationPrefsController.cs + Photos/PhotosController.cs + PlatformLogin/V2/PlatformLoginController.cs + PlayerElo/PlayerEloController.cs + PlayerEvents/PlayerEventsController.cs + PlayerReporting/ReportsController.cs + Players/V2/PlayersController.cs + PlayerState/ + MiscStubsController.cs + PlayerStateController.cs + PlayerSubscriptions/PlayerSubscriptionsController.cs + Progression/ProgressionController.cs + QuickPlay/QuickPlayController.cs + Relationships/V2/RelationshipsController.cs + Role/RoleController.cs + Rooms/V2/ + RoomsController.cs + RoomsModerationController.cs + Royale/RoyaleController.cs + Settings/V2/SettingsController.cs + Store/ + PurchaseController.cs + StorefrontsBuyController.cs + StorefrontsController.cs + Stubs/ApiNamespaceStubsController.cs + TestCaseManagement/TestCaseManagementController.cs + Version/VersionController.cs + ApiCatchAllController.cs + Auth/ + AuthAccountController.cs + AuthController.cs + PhotonCustomAuthController.cs + Cdn/CdnController.cs + Chat/ChatController.cs + Commerce/CommerceCatalogController.cs + Discovery/DiscoveryController.cs + Econ/EconController.cs + Geo/GeoController.cs + GlobalCatchAllController.cs + Health/HealthController.cs + Img/ImgController.cs + Match/ + GoToController.cs + MatchController.cs + MatchPlayerController.cs + PlayerPresenceController.cs + Notify/NotifyController.cs + Ns/NsController.cs + PlayerSettings/PlayerSettingsController.cs + Rooms/RoomsController.cs + RootController.cs + Storage/StorageController.cs + Strings/StringsController.cs + Stub/StubController.cs +Data/ + DorkNetDbContext.cs + Entities/ + AdminActionEntity.cs + AvatarEntity.cs + BugReportEntity.cs + ChatMessageEntity.cs + CheerEntity.cs + ClubEntity.cs + CommunityBoardEntity.cs + CouponEntities.cs + CurrencyBalanceEntity.cs + DormStateEntity.cs + GameSessionEntity.cs + GiftPackageEntity.cs + InventionEntity.cs + InventionVersionEntity.cs + IpBanEntity.cs + MessageEntity.cs + ObjectiveProgressEntity.cs + PhotoEntity.cs + PlayerEntity.cs + PlayerEventEntity.cs + PlayerInventoryEntity.cs + PlayerSettingEntity.cs + PlayerSystemsEntities.cs + PrivateInstanceEntity.cs + PrivateInstanceInviteeEntity.cs + PushTokenEntity.cs + RelationshipEntity.cs + ReportEntity.cs + RoomBanEntity.cs + RoomBookmarkEntity.cs + RoomDataBlobEntity.cs + RoomEntity.cs + RoomSceneEntity.cs + RoomVisitEntity.cs + RoyaleEntities.cs + StatsEntities.cs + StoreItemEntity.cs + SubscriptionEntity.cs + TestCaseEntities.cs +Hubs/NotifyHub.cs +Migrations/ + 20260508092637_Initial.cs + 20260508092637_Initial.Designer.cs + 20260508092846_Phase1AccountColumns.cs + 20260508092846_Phase1AccountColumns.Designer.cs + 20260508093951_Phase2RelationshipStatus.cs + 20260508093951_Phase2RelationshipStatus.Designer.cs + 20260508152627_Phase3RoomDataBlobs.cs + 20260508152627_Phase3RoomDataBlobs.Designer.cs + 20260508162136_Phase3MultiUserDormState.cs + 20260508162136_Phase3MultiUserDormState.Designer.cs + 20260508162929_Phase5AdminAudit.cs + 20260508162929_Phase5AdminAudit.Designer.cs + 20260508163135_Phase6AvatarFields.cs + 20260508163135_Phase6AvatarFields.Designer.cs + 20260508163343_Phase7Inventions.cs + 20260508163343_Phase7Inventions.Designer.cs + 20260508164300_Phase8AccountPassword.cs + 20260508164300_Phase8AccountPassword.Designer.cs + 20260508164951_Phase9ReportsAndIpBans.cs + 20260508164951_Phase9ReportsAndIpBans.Designer.cs + 20260508165257_Phase10MessagesAndChat.cs + 20260508165257_Phase10MessagesAndChat.Designer.cs + 20260508165508_Phase11Cheers.cs + 20260508165508_Phase11Cheers.Designer.cs + 20260508170106_Phase12Currency.cs + 20260508170106_Phase12Currency.Designer.cs + 20260508170400_Phase13EventsAndObjectives.cs + 20260508170400_Phase13EventsAndObjectives.Designer.cs + 20260508170534_Phase14Subscriptions.cs + 20260508170534_Phase14Subscriptions.Designer.cs + 20260508182402_Phase15Photos.cs + 20260508182402_Phase15Photos.Designer.cs + 20260508191000_Phase16Store.cs + 20260508191000_Phase16Store.Designer.cs + 20260509110540_Phase17NewSubsystems.cs + 20260509110540_Phase17NewSubsystems.Designer.cs + 20260509132303_Phase18LeaderboardElo.cs + 20260509132303_Phase18LeaderboardElo.Designer.cs + 20260509135114_Phase19TokensIgnoresCards.cs + 20260509135114_Phase19TokensIgnoresCards.Designer.cs + 20260509145611_Phase20RoomVisitsAndStats.cs + 20260509145611_Phase20RoomVisitsAndStats.Designer.cs + 20260509200000_Phase21RoomScenes.cs + 20260509200000_Phase21RoomScenes.Designer.cs + DorkNetDbContextModelSnapshot.cs +obj/Debug/net9.0/ + .NETCoreApp,Version=v9.0.AssemblyAttributes.cs + DorkNet.Server.AssemblyInfo.cs + DorkNet.Server.GlobalUsings.g.cs + Protos/ + DorknetRoomData.cs + Recroom2020.cs +Program.cs +Services/ + AuthService.cs + CommunityBoardService.cs + ConfigService.cs + GameSessionService.cs + HtrAssetMirrorService.cs + ImageSignatureService.cs + ImageTransformService.cs + LevelService.cs + NotificationService.cs + ObjectStorageService.cs + OnlinePresenceService.cs + OrphanAccountTracker.cs + PlayerLogService.cs + PlayerPresenceService.cs + PlayerService.cs + PrivateInstanceService.cs + ProfanityFilter.cs + RoomBlobNormalizerService.cs + RoomDataBlobService.cs + RoomService.cs + StoreService.cs + +[metadataReferences] +../DorkNet.Models/obj/Debug/net9.0/ref/DorkNet.Models.dll +/packs/Microsoft.AspNetCore.App.Ref/9.0.16/ref/net9.0/ + Microsoft.AspNetCore.Antiforgery.dll + Microsoft.AspNetCore.Authentication.Abstractions.dll + Microsoft.AspNetCore.Authentication.BearerToken.dll + Microsoft.AspNetCore.Authentication.Cookies.dll + Microsoft.AspNetCore.Authentication.Core.dll + Microsoft.AspNetCore.Authentication.dll + Microsoft.AspNetCore.Authentication.OAuth.dll + Microsoft.AspNetCore.Authorization.dll + Microsoft.AspNetCore.Authorization.Policy.dll + Microsoft.AspNetCore.Components.Authorization.dll + Microsoft.AspNetCore.Components.dll + Microsoft.AspNetCore.Components.Endpoints.dll + Microsoft.AspNetCore.Components.Forms.dll + Microsoft.AspNetCore.Components.Server.dll + Microsoft.AspNetCore.Components.Web.dll + Microsoft.AspNetCore.Connections.Abstractions.dll + Microsoft.AspNetCore.CookiePolicy.dll + Microsoft.AspNetCore.Cors.dll + Microsoft.AspNetCore.Cryptography.Internal.dll + Microsoft.AspNetCore.Cryptography.KeyDerivation.dll + Microsoft.AspNetCore.DataProtection.Abstractions.dll + Microsoft.AspNetCore.DataProtection.dll + Microsoft.AspNetCore.DataProtection.Extensions.dll + Microsoft.AspNetCore.Diagnostics.Abstractions.dll + Microsoft.AspNetCore.Diagnostics.dll + Microsoft.AspNetCore.Diagnostics.HealthChecks.dll + Microsoft.AspNetCore.dll + Microsoft.AspNetCore.HostFiltering.dll + Microsoft.AspNetCore.Hosting.Abstractions.dll + Microsoft.AspNetCore.Hosting.dll + Microsoft.AspNetCore.Hosting.Server.Abstractions.dll + Microsoft.AspNetCore.Html.Abstractions.dll + Microsoft.AspNetCore.Http.Abstractions.dll + Microsoft.AspNetCore.Http.Connections.Common.dll + Microsoft.AspNetCore.Http.Connections.dll + Microsoft.AspNetCore.Http.dll + Microsoft.AspNetCore.Http.Extensions.dll + Microsoft.AspNetCore.Http.Features.dll + Microsoft.AspNetCore.Http.Results.dll + Microsoft.AspNetCore.HttpLogging.dll + Microsoft.AspNetCore.HttpOverrides.dll + Microsoft.AspNetCore.HttpsPolicy.dll + Microsoft.AspNetCore.Identity.dll + Microsoft.AspNetCore.Localization.dll + Microsoft.AspNetCore.Localization.Routing.dll + Microsoft.AspNetCore.Metadata.dll + Microsoft.AspNetCore.Mvc.Abstractions.dll + Microsoft.AspNetCore.Mvc.ApiExplorer.dll + Microsoft.AspNetCore.Mvc.Core.dll + Microsoft.AspNetCore.Mvc.Cors.dll + Microsoft.AspNetCore.Mvc.DataAnnotations.dll + Microsoft.AspNetCore.Mvc.dll + Microsoft.AspNetCore.Mvc.Formatters.Json.dll + Microsoft.AspNetCore.Mvc.Formatters.Xml.dll + Microsoft.AspNetCore.Mvc.Localization.dll + Microsoft.AspNetCore.Mvc.Razor.dll + Microsoft.AspNetCore.Mvc.RazorPages.dll + Microsoft.AspNetCore.Mvc.TagHelpers.dll + Microsoft.AspNetCore.Mvc.ViewFeatures.dll + Microsoft.AspNetCore.OutputCaching.dll + Microsoft.AspNetCore.RateLimiting.dll + Microsoft.AspNetCore.Razor.dll + Microsoft.AspNetCore.Razor.Runtime.dll + Microsoft.AspNetCore.RequestDecompression.dll + Microsoft.AspNetCore.ResponseCaching.Abstractions.dll + Microsoft.AspNetCore.ResponseCaching.dll + Microsoft.AspNetCore.ResponseCompression.dll + Microsoft.AspNetCore.Rewrite.dll + Microsoft.AspNetCore.Routing.Abstractions.dll + Microsoft.AspNetCore.Routing.dll + Microsoft.AspNetCore.Server.HttpSys.dll + Microsoft.AspNetCore.Server.IIS.dll + Microsoft.AspNetCore.Server.IISIntegration.dll + Microsoft.AspNetCore.Server.Kestrel.Core.dll + Microsoft.AspNetCore.Server.Kestrel.dll + Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes.dll + Microsoft.AspNetCore.Server.Kestrel.Transport.Quic.dll + Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.dll + Microsoft.AspNetCore.Session.dll + Microsoft.AspNetCore.SignalR.Common.dll + Microsoft.AspNetCore.SignalR.Core.dll + Microsoft.AspNetCore.SignalR.dll + Microsoft.AspNetCore.SignalR.Protocols.Json.dll + Microsoft.AspNetCore.StaticAssets.dll + Microsoft.AspNetCore.StaticFiles.dll + Microsoft.AspNetCore.WebSockets.dll + Microsoft.AspNetCore.WebUtilities.dll + Microsoft.Extensions.Caching.Abstractions.dll + Microsoft.Extensions.Caching.Memory.dll + Microsoft.Extensions.Configuration.Abstractions.dll + Microsoft.Extensions.Configuration.Binder.dll + Microsoft.Extensions.Configuration.CommandLine.dll + Microsoft.Extensions.Configuration.dll + Microsoft.Extensions.Configuration.EnvironmentVariables.dll + Microsoft.Extensions.Configuration.FileExtensions.dll + Microsoft.Extensions.Configuration.Ini.dll + Microsoft.Extensions.Configuration.Json.dll + Microsoft.Extensions.Configuration.KeyPerFile.dll + Microsoft.Extensions.Configuration.UserSecrets.dll + Microsoft.Extensions.Configuration.Xml.dll + Microsoft.Extensions.DependencyInjection.Abstractions.dll + Microsoft.Extensions.DependencyInjection.dll + Microsoft.Extensions.Diagnostics.Abstractions.dll + Microsoft.Extensions.Diagnostics.dll + Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.dll + Microsoft.Extensions.Diagnostics.HealthChecks.dll + Microsoft.Extensions.Features.dll + Microsoft.Extensions.FileProviders.Abstractions.dll + Microsoft.Extensions.FileProviders.Composite.dll + Microsoft.Extensions.FileProviders.Embedded.dll + Microsoft.Extensions.FileProviders.Physical.dll + Microsoft.Extensions.FileSystemGlobbing.dll + Microsoft.Extensions.Hosting.Abstractions.dll + Microsoft.Extensions.Hosting.dll + Microsoft.Extensions.Http.dll + Microsoft.Extensions.Identity.Core.dll + Microsoft.Extensions.Identity.Stores.dll + Microsoft.Extensions.Localization.Abstractions.dll + Microsoft.Extensions.Localization.dll + Microsoft.Extensions.Logging.Abstractions.dll + Microsoft.Extensions.Logging.Configuration.dll + Microsoft.Extensions.Logging.Console.dll + Microsoft.Extensions.Logging.Debug.dll + Microsoft.Extensions.Logging.dll + Microsoft.Extensions.Logging.EventLog.dll + Microsoft.Extensions.Logging.EventSource.dll + Microsoft.Extensions.Logging.TraceSource.dll + Microsoft.Extensions.ObjectPool.dll + Microsoft.Extensions.Options.ConfigurationExtensions.dll + Microsoft.Extensions.Options.DataAnnotations.dll + Microsoft.Extensions.Options.dll + Microsoft.Extensions.Primitives.dll + Microsoft.Extensions.WebEncoders.dll + Microsoft.JSInterop.dll + Microsoft.Net.Http.Headers.dll + System.Diagnostics.EventLog.dll + System.Security.Cryptography.Xml.dll + System.Threading.RateLimiting.dll +/packs/Microsoft.NETCore.App.Ref/9.0.16/ref/net9.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +/ + awssdk.core/3.7.401.5/lib/net8.0/AWSSDK.Core.dll + awssdk.s3/3.7.412.5/lib/net8.0/AWSSDK.S3.dll + bcrypt.net-next/4.0.3/lib/net6.0/BCrypt.Net-Next.dll + google.protobuf/3.28.3/lib/net5.0/Google.Protobuf.dll + messagepack.annotations/2.5.187/lib/netstandard2.0/MessagePack.Annotations.dll + messagepack/2.5.187/lib/net6.0/MessagePack.dll + microsoft.aspnetcore.authentication.jwtbearer/9.0.4/lib/net9.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll + microsoft.aspnetcore.openapi/9.0.15/lib/net9.0/Microsoft.AspNetCore.OpenApi.dll + microsoft.aspnetcore.signalr.stackexchangeredis/9.0.4/lib/net9.0/Microsoft.AspNetCore.SignalR.StackExchangeRedis.dll + microsoft.data.sqlite.core/9.0.4/lib/net8.0/Microsoft.Data.Sqlite.dll + microsoft.entityframeworkcore.abstractions/9.0.4/lib/net8.0/Microsoft.EntityFrameworkCore.Abstractions.dll + microsoft.entityframeworkcore.relational/9.0.4/lib/net8.0/Microsoft.EntityFrameworkCore.Relational.dll + microsoft.entityframeworkcore.sqlite.core/9.0.4/lib/net8.0/Microsoft.EntityFrameworkCore.Sqlite.dll + microsoft.entityframeworkcore/9.0.4/lib/net8.0/Microsoft.EntityFrameworkCore.dll + microsoft.extensions.dependencymodel/9.0.4/lib/net9.0/Microsoft.Extensions.DependencyModel.dll + microsoft.identitymodel.abstractions/8.0.1/lib/net9.0/Microsoft.IdentityModel.Abstractions.dll + microsoft.identitymodel.jsonwebtokens/8.0.1/lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll + microsoft.identitymodel.logging/8.0.1/lib/net9.0/Microsoft.IdentityModel.Logging.dll + microsoft.identitymodel.protocols.openidconnect/8.0.1/lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll + microsoft.identitymodel.protocols/8.0.1/lib/net9.0/Microsoft.IdentityModel.Protocols.dll + microsoft.identitymodel.tokens/8.0.1/lib/net9.0/Microsoft.IdentityModel.Tokens.dll + microsoft.openapi/1.6.22/lib/netstandard2.0/Microsoft.OpenApi.dll + npgsql.entityframeworkcore.postgresql/9.0.4/lib/net8.0/Npgsql.EntityFrameworkCore.PostgreSQL.dll + npgsql/9.0.3/lib/net8.0/Npgsql.dll + pipelines.sockets.unofficial/2.2.8/lib/net5.0/Pipelines.Sockets.Unofficial.dll + serilog.aspnetcore/9.0.0/lib/net9.0/Serilog.AspNetCore.dll + serilog.extensions.hosting/9.0.0/lib/net9.0/Serilog.Extensions.Hosting.dll + serilog.extensions.logging/9.0.0/lib/net9.0/Serilog.Extensions.Logging.dll + serilog.formatting.compact/3.0.0/lib/net8.0/Serilog.Formatting.Compact.dll + serilog.settings.configuration/9.0.0/lib/net9.0/Serilog.Settings.Configuration.dll + serilog.sinks.console/6.0.0/lib/net8.0/Serilog.Sinks.Console.dll + serilog.sinks.debug/3.0.0/lib/net8.0/Serilog.Sinks.Debug.dll + serilog.sinks.file/6.0.0/lib/net8.0/Serilog.Sinks.File.dll + serilog/4.2.0/lib/net9.0/Serilog.dll + skiasharp/3.116.1/ref/net8.0/SkiaSharp.dll + sqlitepclraw.bundle_e_sqlite3/2.1.10/lib/netstandard2.0/SQLitePCLRaw.batteries_v2.dll + sqlitepclraw.core/2.1.10/lib/netstandard2.0/SQLitePCLRaw.core.dll + sqlitepclraw.provider.e_sqlite3/2.1.10/lib/net6.0/SQLitePCLRaw.provider.e_sqlite3.dll + stackexchange.redis/2.8.16/lib/net6.0/StackExchange.Redis.dll + swashbuckle.aspnetcore.swagger/7.3.1/lib/net9.0/Swashbuckle.AspNetCore.Swagger.dll + swashbuckle.aspnetcore.swaggergen/7.3.1/lib/net9.0/Swashbuckle.AspNetCore.SwaggerGen.dll + swashbuckle.aspnetcore.swaggerui/7.3.1/lib/net9.0/Swashbuckle.AspNetCore.SwaggerUI.dll + system.identitymodel.tokens.jwt/8.0.1/lib/net9.0/System.IdentityModel.Tokens.Jwt.dll + +[analyzerReferences] +/packs/Microsoft.AspNetCore.App.Ref/9.0.16/analyzers/dotnet/cs/ + Microsoft.AspNetCore.App.Analyzers.dll + Microsoft.AspNetCore.App.CodeFixes.dll + Microsoft.AspNetCore.Components.Analyzers.dll +/packs/Microsoft.AspNetCore.App.Ref/9.0.16/analyzers/dotnet/roslyn4.4/cs/ + Microsoft.Extensions.Logging.Generators.dll + Microsoft.Extensions.Options.SourceGeneration.dll +/packs/Microsoft.NETCore.App.Ref/9.0.16/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/9.0.314/Sdks/Microsoft.NET.Sdk.Razor/source-generators/ + Microsoft.AspNetCore.Razor.Utilities.Shared.dll + Microsoft.CodeAnalysis.Razor.Compiler.dll + Microsoft.Extensions.ObjectPool.dll + System.Collections.Immutable.dll +/sdk/9.0.314/Sdks/Microsoft.NET.Sdk.Web/analyzers/cs/ + Microsoft.AspNetCore.Analyzers.dll + Microsoft.AspNetCore.Mvc.Analyzers.dll +/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +/awssdk.s3/3.7.412.5/analyzers/dotnet/cs/ + AWSSDK.S3.CodeAnalysis.dll + SharedAnalysisCode.dll +/microsoft.codeanalysis.analyzers/3.3.4/analyzers/dotnet/cs/ + Microsoft.CodeAnalysis.Analyzers.dll + Microsoft.CodeAnalysis.CSharp.Analyzers.dll +/microsoft.entityframeworkcore.analyzers/9.0.4/analyzers/dotnet/cs/Microsoft.EntityFrameworkCore.Analyzers.dll + +[analyzerConfigFiles] +/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_9_default.globalconfig +obj/Debug/net9.0/DorkNet.Server.GeneratedMSBuildEditorConfig.editorconfig diff --git a/DorkNet.Server/Hubs/NotifyHub.cs b/DorkNet.Server/Hubs/NotifyHub.cs new file mode 100644 index 0000000..2c43265 --- /dev/null +++ b/DorkNet.Server/Hubs/NotifyHub.cs @@ -0,0 +1,178 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using DorkNet.Server.Services; +using System.Security.Claims; +using System.Text.Json; + +namespace DorkNet.Server.Hubs; + +/// +/// notify.rec.net/hub/v1 — SignalR Core hub used by the client's +/// BestHTTP.SignalRCore.HubConnection (RecNet.SignalRHubConnection wraps it). +/// +/// Each connection authenticates via the same JWT used for the rest of +/// the API; we extract the player id from the bearer claim and add the +/// connection to a per-player SignalR group so the rest of the app can +/// push to "player N" by group name without tracking connection ids +/// directly. +/// +/// **Per-replica vs cross-replica state**: SignalR's group fan-out is +/// driven by its registered backplane — when Redis is configured in +/// Program.cs via AddStackExchangeRedis, a notification +/// published to player:N from any replica reaches every +/// replica's matching connections. Connection-tracking for +/// "is player N online?" queries lives in +/// (also Redis-backed) so admin tools see the cluster-wide truth. +/// Pre-PR-2 used a local static ConcurrentDictionary which only +/// reflected this replica's connections. +/// +/// Hub methods invoked by the client (verified by string-literal scan +/// of metadata at addresses 0x39b2470, 0x39b24c8, 0x39b4ba8): +/// "Subscribe" - generic topic subscribe +/// "Unsubscribe" - generic topic unsubscribe +/// "SubscribeToPlayers" - subscribe to per-player events (used by +/// RecNet.NotificationManager / SubscribeToPlayers) +/// All accept JsonElement so we don't lock ourselves into a parameter +/// schema we haven't verified yet, and return Task so the client's +/// awaited Invoke completes cleanly. +/// +[Authorize] +public class NotifyHub( + OnlinePresenceService presence, + NotificationService notifications, + ILogger logger) : Hub +{ + /// SignalR group name for a given player id. Use this + /// from to push. + public static string GroupForPlayer(long playerId) => $"player:{playerId}"; + + public override async Task OnConnectedAsync() + { + var pid = TryGetPlayerId(); + if (pid is long id) + { + // Enforce single-session-per-account. When a SECOND device + // logs in with the same credentials, both the original + // session and the new one would otherwise share the + // player:N group. We can't differentiate by LoginLock + // because the watch derives that token from cached login + // state (device_id-tied) — two clients restored from the + // same install hand back the same lock GUID. + // + // Instead: at the moment the SECOND session's WebSocket + // arrives, take a snapshot of the EXISTING connection ids + // (which still belong to the original session — we haven't + // joined the group yet) and push a targeted ModerationKick + // to those ids. The watch's OnModerationKick handler at + // PlayerReporting.txt:1857 shows the standard moderation + // dialog and routes through LocalModerationKickSelf, the + // same graceful disconnect path the original Rec Room used + // for real moderator kicks. + // + // Targeting specific connection ids (not the group) means + // the new connection — about to join the group on the next + // line — never receives the kick, even with Redis backplane + // routing across replicas. + var existing = presence.GetConnections(id); + if (existing.Count > 0) + { + logger.LogWarning( + "[hub] second-session connect for player={PlayerId} (conn={NewConn}); kicking {OldCount} existing connection(s)", + id, Context.ConnectionId, existing.Count); + try + { + await notifications.KickConnectionsAsync( + existing, + "Your account was signed in on another device."); + } + catch (Exception ex) + { + logger.LogWarning(ex, "[hub] failed to kick stale connections for player={PlayerId}", id); + } + } + + await Groups.AddToGroupAsync(Context.ConnectionId, GroupForPlayer(id)); + await presence.AddConnectionAsync(id, Context.ConnectionId); + logger.LogInformation( + "[hub] connected player={PlayerId} conn={ConnectionId} group={Group}", + id, Context.ConnectionId, GroupForPlayer(id)); + } + else + { + logger.LogWarning( + "[hub] connected without player id conn={ConnectionId} user={User}", + Context.ConnectionId, Context.User?.Identity?.Name); + } + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + var pid = TryGetPlayerId(); + if (pid is long id) + { + await presence.RemoveConnectionAsync(id, Context.ConnectionId); + await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupForPlayer(id)); + logger.LogInformation( + "[hub] disconnected player={PlayerId} conn={ConnectionId} error={Error}", + id, Context.ConnectionId, exception?.Message); + } + else + { + logger.LogInformation( + "[hub] disconnected unknown-player conn={ConnectionId} error={Error}", + Context.ConnectionId, exception?.Message); + } + await base.OnDisconnectedAsync(exception); + } + + public Task SubscribeToPlayers(JsonElement payload) + { + logger.LogInformation( + "[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} payload={Payload}", + TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); + return Task.CompletedTask; + } + + public Task UnsubscribeFromPlayers(JsonElement payload) + { + logger.LogInformation( + "[hub] UnsubscribeFromPlayers player={PlayerId} conn={ConnectionId} payload={Payload}", + TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); + return Task.CompletedTask; + } + + public Task Subscribe(JsonElement payload) + { + logger.LogInformation( + "[hub] Subscribe player={PlayerId} conn={ConnectionId} payload={Payload}", + TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); + return Task.CompletedTask; + } + + public Task Unsubscribe(JsonElement payload) + { + logger.LogInformation( + "[hub] Unsubscribe player={PlayerId} conn={ConnectionId} payload={Payload}", + TryGetPlayerId(), Context.ConnectionId, RawPayload(payload)); + return Task.CompletedTask; + } + + private long? TryGetPlayerId() + { + var raw = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier); + return long.TryParse(raw, out var id) ? id : null; + } + + private static string RawPayload(JsonElement payload) + { + try + { + return payload.ValueKind == JsonValueKind.Undefined ? "" : payload.GetRawText(); + } + catch + { + return ""; + } + } +} diff --git a/DorkNet.Server/Migrations/20260508092637_Initial.Designer.cs b/DorkNet.Server/Migrations/20260508092637_Initial.Designer.cs new file mode 100644 index 0000000..d89e7e9 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508092637_Initial.Designer.cs @@ -0,0 +1,355 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using DorkNet.Server.Data; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + [DbContext(typeof(DorkNetDbContext))] + [Migration("20260508092637_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508092637_Initial.cs b/DorkNet.Server/Migrations/20260508092637_Initial.cs new file mode 100644 index 0000000..297b144 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508092637_Initial.cs @@ -0,0 +1,234 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Players", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DeviceId = table.Column(type: "TEXT", maxLength: 128, nullable: false), + LastPlatform = table.Column(type: "INTEGER", nullable: false), + LastPlatformId = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Username = table.Column(type: "TEXT", maxLength: 64, nullable: false), + DisplayName = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Bio = table.Column(type: "TEXT", maxLength: 512, nullable: false), + Level = table.Column(type: "INTEGER", nullable: false), + XP = table.Column(type: "INTEGER", nullable: false), + Reputation = table.Column(type: "INTEGER", nullable: false), + IsVerified = table.Column(type: "INTEGER", nullable: false), + IsDeveloper = table.Column(type: "INTEGER", nullable: false), + CanReceiveInvites = table.Column(type: "INTEGER", nullable: false), + IsJunior = table.Column(type: "INTEGER", nullable: false), + ProfileImageName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + LastSeenAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Players", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RoomBookmarks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + RoomId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoomBookmarks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Rooms", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false, collation: "NOCASE"), + Description = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + CreatorPlayerId = table.Column(type: "INTEGER", nullable: false), + ImageName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + State = table.Column(type: "INTEGER", nullable: false), + Accessibility = table.Column(type: "INTEGER", nullable: false), + SupportsLevelVoting = table.Column(type: "INTEGER", nullable: false), + IsAGRoom = table.Column(type: "INTEGER", nullable: false), + IsDormRoom = table.Column(type: "INTEGER", nullable: false), + CloningAllowed = table.Column(type: "INTEGER", nullable: false), + SupportsVRLow = table.Column(type: "INTEGER", nullable: false), + SupportsMobile = table.Column(type: "INTEGER", nullable: false), + SupportsScreens = table.Column(type: "INTEGER", nullable: false), + SupportsWalkVR = table.Column(type: "INTEGER", nullable: false), + SupportsTeleportVR = table.Column(type: "INTEGER", nullable: false), + AllowsJuniors = table.Column(type: "INTEGER", nullable: false), + RoomWarningMask = table.Column(type: "INTEGER", nullable: false), + CustomRoomWarning = table.Column(type: "TEXT", maxLength: 512, nullable: false), + DisableMicAutoMute = table.Column(type: "INTEGER", nullable: false), + LocationReplicationId = table.Column(type: "TEXT", maxLength: 64, nullable: false), + TagsCsv = table.Column(type: "TEXT", maxLength: 1024, nullable: false), + CheerCount = table.Column(type: "INTEGER", nullable: false), + FavoriteCount = table.Column(type: "INTEGER", nullable: false), + VisitCount = table.Column(type: "INTEGER", nullable: false), + HotScore = table.Column(type: "REAL", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Rooms", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Avatars", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + EquippedItemsJson = table.Column(type: "TEXT", nullable: false), + InventoryJson = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Avatars", x => x.Id); + table.ForeignKey( + name: "FK_Avatars_Players_PlayerId", + column: x => x.PlayerId, + principalTable: "Players", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PlayerSettings", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + Key = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Value = table.Column(type: "TEXT", maxLength: 1024, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayerSettings", x => x.Id); + table.ForeignKey( + name: "FK_PlayerSettings_Players_PlayerId", + column: x => x.PlayerId, + principalTable: "Players", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Relationships", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RequesterId = table.Column(type: "INTEGER", nullable: false), + TargetId = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Relationships", x => x.Id); + table.ForeignKey( + name: "FK_Relationships_Players_RequesterId", + column: x => x.RequesterId, + principalTable: "Players", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Avatars_PlayerId", + table: "Avatars", + column: "PlayerId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Players_Username", + table: "Players", + column: "Username", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PlayerSettings_PlayerId_Key", + table: "PlayerSettings", + columns: new[] { "PlayerId", "Key" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Relationships_RequesterId_TargetId", + table: "Relationships", + columns: new[] { "RequesterId", "TargetId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RoomBookmarks_PlayerId", + table: "RoomBookmarks", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_RoomBookmarks_PlayerId_RoomId", + table: "RoomBookmarks", + columns: new[] { "PlayerId", "RoomId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Rooms_CreatorPlayerId", + table: "Rooms", + column: "CreatorPlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_Rooms_HotScore", + table: "Rooms", + column: "HotScore"); + + migrationBuilder.CreateIndex( + name: "IX_Rooms_Name", + table: "Rooms", + column: "Name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Avatars"); + + migrationBuilder.DropTable( + name: "PlayerSettings"); + + migrationBuilder.DropTable( + name: "Relationships"); + + migrationBuilder.DropTable( + name: "RoomBookmarks"); + + migrationBuilder.DropTable( + name: "Rooms"); + + migrationBuilder.DropTable( + name: "Players"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508092846_Phase1AccountColumns.Designer.cs b/DorkNet.Server/Migrations/20260508092846_Phase1AccountColumns.Designer.cs new file mode 100644 index 0000000..f4e9a35 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508092846_Phase1AccountColumns.Designer.cs @@ -0,0 +1,377 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using DorkNet.Server.Data; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + [DbContext(typeof(DorkNetDbContext))] + [Migration("20260508092846_Phase1AccountColumns")] + partial class Phase1AccountColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508092846_Phase1AccountColumns.cs b/DorkNet.Server/Migrations/20260508092846_Phase1AccountColumns.cs new file mode 100644 index 0000000..bd8c421 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508092846_Phase1AccountColumns.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase1AccountColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BannedUntil", + table: "Players", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CreatedFromDeviceId", + table: "Players", + type: "TEXT", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "Email", + table: "Players", + type: "TEXT", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "IsAdmin", + table: "Players", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LastIp", + table: "Players", + type: "TEXT", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "Locale", + table: "Players", + type: "TEXT", + maxLength: 16, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BannedUntil", + table: "Players"); + + migrationBuilder.DropColumn( + name: "CreatedFromDeviceId", + table: "Players"); + + migrationBuilder.DropColumn( + name: "Email", + table: "Players"); + + migrationBuilder.DropColumn( + name: "IsAdmin", + table: "Players"); + + migrationBuilder.DropColumn( + name: "LastIp", + table: "Players"); + + migrationBuilder.DropColumn( + name: "Locale", + table: "Players"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508093951_Phase2RelationshipStatus.Designer.cs b/DorkNet.Server/Migrations/20260508093951_Phase2RelationshipStatus.Designer.cs new file mode 100644 index 0000000..7161c91 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508093951_Phase2RelationshipStatus.Designer.cs @@ -0,0 +1,380 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using DorkNet.Server.Data; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + [DbContext(typeof(DorkNetDbContext))] + [Migration("20260508093951_Phase2RelationshipStatus")] + partial class Phase2RelationshipStatus + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508093951_Phase2RelationshipStatus.cs b/DorkNet.Server/Migrations/20260508093951_Phase2RelationshipStatus.cs new file mode 100644 index 0000000..0de704e --- /dev/null +++ b/DorkNet.Server/Migrations/20260508093951_Phase2RelationshipStatus.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase2RelationshipStatus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "Relationships", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UpdatedAt", + table: "Relationships"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508152627_Phase3RoomDataBlobs.Designer.cs b/DorkNet.Server/Migrations/20260508152627_Phase3RoomDataBlobs.Designer.cs new file mode 100644 index 0000000..07ff8e4 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508152627_Phase3RoomDataBlobs.Designer.cs @@ -0,0 +1,424 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using DorkNet.Server.Data; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + [DbContext(typeof(DorkNetDbContext))] + [Migration("20260508152627_Phase3RoomDataBlobs")] + partial class Phase3RoomDataBlobs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508152627_Phase3RoomDataBlobs.cs b/DorkNet.Server/Migrations/20260508152627_Phase3RoomDataBlobs.cs new file mode 100644 index 0000000..7a780aa --- /dev/null +++ b/DorkNet.Server/Migrations/20260508152627_Phase3RoomDataBlobs.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase3RoomDataBlobs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CurrentDataBlobName", + table: "Rooms", + type: "TEXT", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "RoomDataBlobs", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoomId = table.Column(type: "INTEGER", nullable: false), + BlobName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UploadedByPlayerId = table.Column(type: "INTEGER", nullable: false), + UploadedAt = table.Column(type: "TEXT", nullable: false), + Bytes = table.Column(type: "BLOB", nullable: false), + ReferencedFilenamesCsv = table.Column(type: "TEXT", maxLength: 2048, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoomDataBlobs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_RoomDataBlobs_BlobName", + table: "RoomDataBlobs", + column: "BlobName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RoomDataBlobs_RoomId", + table: "RoomDataBlobs", + column: "RoomId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RoomDataBlobs"); + + migrationBuilder.DropColumn( + name: "CurrentDataBlobName", + table: "Rooms"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508162136_Phase3MultiUserDormState.Designer.cs b/DorkNet.Server/Migrations/20260508162136_Phase3MultiUserDormState.Designer.cs new file mode 100644 index 0000000..28996b4 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508162136_Phase3MultiUserDormState.Designer.cs @@ -0,0 +1,446 @@ +// +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("20260508162136_Phase3MultiUserDormState")] + partial class Phase3MultiUserDormState + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508162136_Phase3MultiUserDormState.cs b/DorkNet.Server/Migrations/20260508162136_Phase3MultiUserDormState.cs new file mode 100644 index 0000000..6adb2d3 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508162136_Phase3MultiUserDormState.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase3MultiUserDormState : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DormStates", + columns: table => new + { + PlayerId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CurrentDataBlobName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DormStates", x => x.PlayerId); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DormStates"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508162929_Phase5AdminAudit.Designer.cs b/DorkNet.Server/Migrations/20260508162929_Phase5AdminAudit.Designer.cs new file mode 100644 index 0000000..21acf44 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508162929_Phase5AdminAudit.Designer.cs @@ -0,0 +1,487 @@ +// +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("20260508162929_Phase5AdminAudit")] + partial class Phase5AdminAudit + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508162929_Phase5AdminAudit.cs b/DorkNet.Server/Migrations/20260508162929_Phase5AdminAudit.cs new file mode 100644 index 0000000..fa36ea9 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508162929_Phase5AdminAudit.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase5AdminAudit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AdminActions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AdminPlayerId = table.Column(type: "INTEGER", nullable: false), + Action = table.Column(type: "TEXT", maxLength: 64, nullable: false), + TargetType = table.Column(type: "TEXT", maxLength: 32, nullable: false), + TargetId = table.Column(type: "INTEGER", nullable: false), + Reason = table.Column(type: "TEXT", maxLength: 512, nullable: false), + Timestamp = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AdminActions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AdminActions_AdminPlayerId", + table: "AdminActions", + column: "AdminPlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_AdminActions_TargetType_TargetId", + table: "AdminActions", + columns: new[] { "TargetType", "TargetId" }); + + migrationBuilder.CreateIndex( + name: "IX_AdminActions_Timestamp", + table: "AdminActions", + column: "Timestamp"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AdminActions"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508163135_Phase6AvatarFields.Designer.cs b/DorkNet.Server/Migrations/20260508163135_Phase6AvatarFields.Designer.cs new file mode 100644 index 0000000..3541daa --- /dev/null +++ b/DorkNet.Server/Migrations/20260508163135_Phase6AvatarFields.Designer.cs @@ -0,0 +1,514 @@ +// +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("20260508163135_Phase6AvatarFields")] + partial class Phase6AvatarFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508163135_Phase6AvatarFields.cs b/DorkNet.Server/Migrations/20260508163135_Phase6AvatarFields.cs new file mode 100644 index 0000000..1e149ef --- /dev/null +++ b/DorkNet.Server/Migrations/20260508163135_Phase6AvatarFields.cs @@ -0,0 +1,89 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase6AvatarFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FaceFeatures", + table: "Avatars", + type: "TEXT", + maxLength: 8192, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "HairColor", + table: "Avatars", + type: "TEXT", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "OutfitSelections", + table: "Avatars", + type: "TEXT", + maxLength: 2048, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "SavedOutfitsJson", + table: "Avatars", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "SkinColor", + table: "Avatars", + type: "TEXT", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "Avatars", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FaceFeatures", + table: "Avatars"); + + migrationBuilder.DropColumn( + name: "HairColor", + table: "Avatars"); + + migrationBuilder.DropColumn( + name: "OutfitSelections", + table: "Avatars"); + + migrationBuilder.DropColumn( + name: "SavedOutfitsJson", + table: "Avatars"); + + migrationBuilder.DropColumn( + name: "SkinColor", + table: "Avatars"); + + migrationBuilder.DropColumn( + name: "UpdatedAt", + table: "Avatars"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508163343_Phase7Inventions.Designer.cs b/DorkNet.Server/Migrations/20260508163343_Phase7Inventions.Designer.cs new file mode 100644 index 0000000..d50b7c9 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508163343_Phase7Inventions.Designer.cs @@ -0,0 +1,572 @@ +// +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("20260508163343_Phase7Inventions")] + partial class Phase7Inventions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508163343_Phase7Inventions.cs b/DorkNet.Server/Migrations/20260508163343_Phase7Inventions.cs new file mode 100644 index 0000000..3caab4b --- /dev/null +++ b/DorkNet.Server/Migrations/20260508163343_Phase7Inventions.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase7Inventions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Inventions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CreatorPlayerId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + ImageName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + Permission = table.Column(type: "INTEGER", nullable: false), + CurrentBlobName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + TagsCsv = table.Column(type: "TEXT", maxLength: 1024, nullable: false), + CheerCount = table.Column(type: "INTEGER", nullable: false), + SpawnCount = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Inventions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Inventions_CheerCount", + table: "Inventions", + column: "CheerCount"); + + migrationBuilder.CreateIndex( + name: "IX_Inventions_CreatorPlayerId", + table: "Inventions", + column: "CreatorPlayerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Inventions"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508164300_Phase8AccountPassword.Designer.cs b/DorkNet.Server/Migrations/20260508164300_Phase8AccountPassword.Designer.cs new file mode 100644 index 0000000..e04fdcb --- /dev/null +++ b/DorkNet.Server/Migrations/20260508164300_Phase8AccountPassword.Designer.cs @@ -0,0 +1,583 @@ +// +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("20260508164300_Phase8AccountPassword")] + partial class Phase8AccountPassword + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508164300_Phase8AccountPassword.cs b/DorkNet.Server/Migrations/20260508164300_Phase8AccountPassword.cs new file mode 100644 index 0000000..b6c7a94 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508164300_Phase8AccountPassword.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase8AccountPassword : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Birthday", + table: "Players", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PasswordHash", + table: "Players", + type: "TEXT", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "Phone", + table: "Players", + type: "TEXT", + maxLength: 32, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Birthday", + table: "Players"); + + migrationBuilder.DropColumn( + name: "PasswordHash", + table: "Players"); + + migrationBuilder.DropColumn( + name: "Phone", + table: "Players"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508164951_Phase9ReportsAndIpBans.Designer.cs b/DorkNet.Server/Migrations/20260508164951_Phase9ReportsAndIpBans.Designer.cs new file mode 100644 index 0000000..dd47dbd --- /dev/null +++ b/DorkNet.Server/Migrations/20260508164951_Phase9ReportsAndIpBans.Designer.cs @@ -0,0 +1,666 @@ +// +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("20260508164951_Phase9ReportsAndIpBans")] + partial class Phase9ReportsAndIpBans + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508164951_Phase9ReportsAndIpBans.cs b/DorkNet.Server/Migrations/20260508164951_Phase9ReportsAndIpBans.cs new file mode 100644 index 0000000..d735fc8 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508164951_Phase9ReportsAndIpBans.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase9ReportsAndIpBans : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "IpBans", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Cidr = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Reason = table.Column(type: "TEXT", maxLength: 512, nullable: false), + BannedByAdminId = table.Column(type: "INTEGER", nullable: false), + BannedAt = table.Column(type: "TEXT", nullable: false), + Until = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_IpBans", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Reports", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ReporterPlayerId = table.Column(type: "INTEGER", nullable: false), + TargetPlayerId = table.Column(type: "INTEGER", nullable: false), + Category = table.Column(type: "INTEGER", nullable: false), + Message = table.Column(type: "TEXT", maxLength: 1000, nullable: false), + GameSessionId = table.Column(type: "INTEGER", nullable: false), + RoomId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ResolvedAt = table.Column(type: "TEXT", nullable: true), + ResolverAdminId = table.Column(type: "INTEGER", nullable: true), + ResolutionNote = table.Column(type: "TEXT", maxLength: 512, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Reports", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_IpBans_Cidr", + table: "IpBans", + column: "Cidr"); + + migrationBuilder.CreateIndex( + name: "IX_Reports_ReporterPlayerId", + table: "Reports", + column: "ReporterPlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_Reports_ResolvedAt", + table: "Reports", + column: "ResolvedAt"); + + migrationBuilder.CreateIndex( + name: "IX_Reports_TargetPlayerId", + table: "Reports", + column: "TargetPlayerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "IpBans"); + + migrationBuilder.DropTable( + name: "Reports"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508165257_Phase10MessagesAndChat.Designer.cs b/DorkNet.Server/Migrations/20260508165257_Phase10MessagesAndChat.Designer.cs new file mode 100644 index 0000000..3d14d73 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508165257_Phase10MessagesAndChat.Designer.cs @@ -0,0 +1,727 @@ +// +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("20260508165257_Phase10MessagesAndChat")] + partial class Phase10MessagesAndChat + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508165257_Phase10MessagesAndChat.cs b/DorkNet.Server/Migrations/20260508165257_Phase10MessagesAndChat.cs new file mode 100644 index 0000000..9b70f6e --- /dev/null +++ b/DorkNet.Server/Migrations/20260508165257_Phase10MessagesAndChat.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase10MessagesAndChat : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ChatMessages", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ThreadKey = table.Column(type: "TEXT", maxLength: 64, nullable: false), + SenderPlayerId = table.Column(type: "INTEGER", nullable: false), + Body = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + SentAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatMessages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Messages", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SenderPlayerId = table.Column(type: "INTEGER", nullable: false), + RecipientPlayerId = table.Column(type: "INTEGER", nullable: false), + Body = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + SentAt = table.Column(type: "TEXT", nullable: false), + ReadAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Messages", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChatMessages_ThreadKey_SentAt", + table: "ChatMessages", + columns: new[] { "ThreadKey", "SentAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Messages_RecipientPlayerId_SentAt", + table: "Messages", + columns: new[] { "RecipientPlayerId", "SentAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Messages_SenderPlayerId_SentAt", + table: "Messages", + columns: new[] { "SenderPlayerId", "SentAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChatMessages"); + + migrationBuilder.DropTable( + name: "Messages"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508165508_Phase11Cheers.Designer.cs b/DorkNet.Server/Migrations/20260508165508_Phase11Cheers.Designer.cs new file mode 100644 index 0000000..cd1f6f7 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508165508_Phase11Cheers.Designer.cs @@ -0,0 +1,760 @@ +// +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("20260508165508_Phase11Cheers")] + partial class Phase11Cheers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508165508_Phase11Cheers.cs b/DorkNet.Server/Migrations/20260508165508_Phase11Cheers.cs new file mode 100644 index 0000000..c79e4ad --- /dev/null +++ b/DorkNet.Server/Migrations/20260508165508_Phase11Cheers.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase11Cheers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Cheers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + FromPlayerId = table.Column(type: "INTEGER", nullable: false), + TargetPlayerId = table.Column(type: "INTEGER", nullable: false), + TargetRoomId = table.Column(type: "INTEGER", nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + CheeredAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Cheers", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Cheers_FromPlayerId_TargetPlayerId_TargetRoomId_Type", + table: "Cheers", + columns: new[] { "FromPlayerId", "TargetPlayerId", "TargetRoomId", "Type" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Cheers_TargetPlayerId", + table: "Cheers", + column: "TargetPlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_Cheers_TargetRoomId", + table: "Cheers", + column: "TargetRoomId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Cheers"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508170106_Phase12Currency.Designer.cs b/DorkNet.Server/Migrations/20260508170106_Phase12Currency.Designer.cs new file mode 100644 index 0000000..0b52d1d --- /dev/null +++ b/DorkNet.Server/Migrations/20260508170106_Phase12Currency.Designer.cs @@ -0,0 +1,786 @@ +// +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("20260508170106_Phase12Currency")] + partial class Phase12Currency + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508170106_Phase12Currency.cs b/DorkNet.Server/Migrations/20260508170106_Phase12Currency.cs new file mode 100644 index 0000000..348f3f5 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508170106_Phase12Currency.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase12Currency : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CurrencyBalances", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + CurrencyType = table.Column(type: "INTEGER", nullable: false), + Balance = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CurrencyBalances", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_CurrencyBalances_PlayerId_CurrencyType", + table: "CurrencyBalances", + columns: new[] { "PlayerId", "CurrencyType" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CurrencyBalances"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508170400_Phase13EventsAndObjectives.Designer.cs b/DorkNet.Server/Migrations/20260508170400_Phase13EventsAndObjectives.Designer.cs new file mode 100644 index 0000000..36cb63a --- /dev/null +++ b/DorkNet.Server/Migrations/20260508170400_Phase13EventsAndObjectives.Designer.cs @@ -0,0 +1,888 @@ +// +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("20260508170400_Phase13EventsAndObjectives")] + partial class Phase13EventsAndObjectives + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508170400_Phase13EventsAndObjectives.cs b/DorkNet.Server/Migrations/20260508170400_Phase13EventsAndObjectives.cs new file mode 100644 index 0000000..e3ec28e --- /dev/null +++ b/DorkNet.Server/Migrations/20260508170400_Phase13EventsAndObjectives.cs @@ -0,0 +1,108 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase13EventsAndObjectives : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ObjectiveProgress", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + Key = table.Column(type: "TEXT", maxLength: 128, nullable: false), + IsCompleted = table.Column(type: "INTEGER", nullable: false), + ClearedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ObjectiveProgress", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PlayerEventResponses", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + EventId = table.Column(type: "INTEGER", nullable: false), + PlayerId = table.Column(type: "INTEGER", nullable: false), + Response = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayerEventResponses", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PlayerEvents", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CreatorPlayerId = table.Column(type: "INTEGER", nullable: false), + RoomId = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + StartsAt = table.Column(type: "TEXT", nullable: false), + EndsAt = table.Column(type: "TEXT", nullable: false), + Capacity = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayerEvents", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ObjectiveProgress_PlayerId_Key", + table: "ObjectiveProgress", + columns: new[] { "PlayerId", "Key" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PlayerEventResponses_EventId_PlayerId", + table: "PlayerEventResponses", + columns: new[] { "EventId", "PlayerId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PlayerEventResponses_PlayerId", + table: "PlayerEventResponses", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayerEvents_CreatorPlayerId", + table: "PlayerEvents", + column: "CreatorPlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayerEvents_StartsAt", + table: "PlayerEvents", + column: "StartsAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ObjectiveProgress"); + + migrationBuilder.DropTable( + name: "PlayerEventResponses"); + + migrationBuilder.DropTable( + name: "PlayerEvents"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508170534_Phase14Subscriptions.Designer.cs b/DorkNet.Server/Migrations/20260508170534_Phase14Subscriptions.Designer.cs new file mode 100644 index 0000000..d405026 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508170534_Phase14Subscriptions.Designer.cs @@ -0,0 +1,915 @@ +// +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("20260508170534_Phase14Subscriptions")] + partial class Phase14Subscriptions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508170534_Phase14Subscriptions.cs b/DorkNet.Server/Migrations/20260508170534_Phase14Subscriptions.cs new file mode 100644 index 0000000..3f8f115 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508170534_Phase14Subscriptions.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase14Subscriptions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Subscriptions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SubscriberPlayerId = table.Column(type: "INTEGER", nullable: false), + TargetPlayerId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Subscriptions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_SubscriberPlayerId", + table: "Subscriptions", + column: "SubscriberPlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_SubscriberPlayerId_TargetPlayerId", + table: "Subscriptions", + columns: new[] { "SubscriberPlayerId", "TargetPlayerId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_TargetPlayerId", + table: "Subscriptions", + column: "TargetPlayerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Subscriptions"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508182402_Phase15Photos.Designer.cs b/DorkNet.Server/Migrations/20260508182402_Phase15Photos.Designer.cs new file mode 100644 index 0000000..4835ece --- /dev/null +++ b/DorkNet.Server/Migrations/20260508182402_Phase15Photos.Designer.cs @@ -0,0 +1,975 @@ +// +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("20260508182402_Phase15Photos")] + partial class Phase15Photos + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508182402_Phase15Photos.cs b/DorkNet.Server/Migrations/20260508182402_Phase15Photos.cs new file mode 100644 index 0000000..62b2149 --- /dev/null +++ b/DorkNet.Server/Migrations/20260508182402_Phase15Photos.cs @@ -0,0 +1,104 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase15Photos : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Cheers_FromPlayerId_TargetPlayerId_TargetRoomId_Type", + table: "Cheers"); + + migrationBuilder.AddColumn( + name: "TargetPhotoId", + table: "Cheers", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + + migrationBuilder.CreateTable( + name: "Photos", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UploaderPlayerId = table.Column(type: "INTEGER", nullable: false), + BlobName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + Caption = table.Column(type: "TEXT", maxLength: 2048, nullable: false), + RoomId = table.Column(type: "INTEGER", nullable: false), + TaggedPlayerIdsCsv = table.Column(type: "TEXT", maxLength: 2048, nullable: false), + IsPublic = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + DeletedAt = table.Column(type: "TEXT", nullable: true), + CheerCount = table.Column(type: "INTEGER", nullable: false), + ViewCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Photos", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Cheers_FromPlayerId_TargetPlayerId_TargetRoomId_TargetPhotoId_Type", + table: "Cheers", + columns: new[] { "FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Cheers_TargetPhotoId", + table: "Cheers", + column: "TargetPhotoId"); + + migrationBuilder.CreateIndex( + name: "IX_Photos_BlobName", + table: "Photos", + column: "BlobName"); + + migrationBuilder.CreateIndex( + name: "IX_Photos_CreatedAt", + table: "Photos", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_Photos_RoomId", + table: "Photos", + column: "RoomId"); + + migrationBuilder.CreateIndex( + name: "IX_Photos_UploaderPlayerId", + table: "Photos", + column: "UploaderPlayerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Photos"); + + migrationBuilder.DropIndex( + name: "IX_Cheers_FromPlayerId_TargetPlayerId_TargetRoomId_TargetPhotoId_Type", + table: "Cheers"); + + migrationBuilder.DropIndex( + name: "IX_Cheers_TargetPhotoId", + table: "Cheers"); + + migrationBuilder.DropColumn( + name: "TargetPhotoId", + table: "Cheers"); + + migrationBuilder.CreateIndex( + name: "IX_Cheers_FromPlayerId_TargetPlayerId_TargetRoomId_Type", + table: "Cheers", + columns: new[] { "FromPlayerId", "TargetPlayerId", "TargetRoomId", "Type" }, + unique: true); + } + } +} diff --git a/DorkNet.Server/Migrations/20260508191000_Phase16Store.Designer.cs b/DorkNet.Server/Migrations/20260508191000_Phase16Store.Designer.cs new file mode 100644 index 0000000..8c3480b --- /dev/null +++ b/DorkNet.Server/Migrations/20260508191000_Phase16Store.Designer.cs @@ -0,0 +1,1044 @@ +// +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("20260508191000_Phase16Store")] + partial class Phase16Store + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260508191000_Phase16Store.cs b/DorkNet.Server/Migrations/20260508191000_Phase16Store.cs new file mode 100644 index 0000000..452bf2a --- /dev/null +++ b/DorkNet.Server/Migrations/20260508191000_Phase16Store.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase16Store : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "StoreItems", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Slug = table.Column(type: "TEXT", maxLength: 64, nullable: false), + DisplayName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 1024, nullable: false), + Category = table.Column(type: "TEXT", maxLength: 32, nullable: false), + ImageName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + CurrencyType = table.Column(type: "INTEGER", nullable: false), + Price = table.Column(type: "INTEGER", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + IsLimitedTime = table.Column(type: "INTEGER", nullable: false), + AvailableUntil = table.Column(type: "TEXT", nullable: true), + Storefront = table.Column(type: "TEXT", maxLength: 32, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StoreItems", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_StoreItems_IsActive_Category", + table: "StoreItems", + columns: new[] { "IsActive", "Category" }); + + migrationBuilder.CreateIndex( + name: "IX_StoreItems_Slug", + table: "StoreItems", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_StoreItems_Storefront", + table: "StoreItems", + column: "Storefront"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "StoreItems"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260509110540_Phase17NewSubsystems.Designer.cs b/DorkNet.Server/Migrations/20260509110540_Phase17NewSubsystems.Designer.cs new file mode 100644 index 0000000..8f0cd24 --- /dev/null +++ b/DorkNet.Server/Migrations/20260509110540_Phase17NewSubsystems.Designer.cs @@ -0,0 +1,1834 @@ +// +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("20260509110540_Phase17NewSubsystems")] + partial class Phase17NewSubsystems + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260509110540_Phase17NewSubsystems.cs b/DorkNet.Server/Migrations/20260509110540_Phase17NewSubsystems.cs new file mode 100644 index 0000000..44e4828 --- /dev/null +++ b/DorkNet.Server/Migrations/20260509110540_Phase17NewSubsystems.cs @@ -0,0 +1,701 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase17NewSubsystems : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TargetEventId", + table: "Reports", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "TargetInventionId", + table: "Reports", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "TargetRoomId", + table: "Reports", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "CreationRoomId", + table: "Inventions", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "CreatorPermission", + table: "Inventions", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "CurrentVersionNumber", + table: "Inventions", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "FirstPublishedAt", + table: "Inventions", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "GeneralPermission", + table: "Inventions", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "IsAgInvention", + table: "Inventions", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "Inventions", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsPublished", + table: "Inventions", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "NumPlayersHaveUsedInRoom", + table: "Inventions", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ReplicationId", + table: "Inventions", + type: "TEXT", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "TargetInventionId", + table: "Cheers", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + + migrationBuilder.CreateTable( + name: "BugReports", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ReporterPlayerId = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Body = table.Column(type: "TEXT", maxLength: 4000, nullable: false), + GameSessionId = table.Column(type: "INTEGER", nullable: false), + ClientVersion = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Platform = table.Column(type: "TEXT", maxLength: 32, nullable: false), + Category = table.Column(type: "TEXT", maxLength: 32, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ReadAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BugReports", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ClubMemberships", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ClubId = table.Column(type: "INTEGER", nullable: false), + PlayerId = table.Column(type: "INTEGER", nullable: false), + Permissions = table.Column(type: "INTEGER", nullable: false), + JoinedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClubMemberships", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Clubs", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false, collation: "NOCASE"), + Description = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + CreatorPlayerId = table.Column(type: "INTEGER", nullable: false), + ImageName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + BanStatus = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Clubs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CohortAssignments", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + CohortKey = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Variant = table.Column(type: "TEXT", maxLength: 32, nullable: false), + AssignedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CohortAssignments", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CouponRedemptions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CouponId = table.Column(type: "INTEGER", nullable: false), + PlayerId = table.Column(type: "INTEGER", nullable: false), + RedeemedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CouponRedemptions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Coupons", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Code = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 256, nullable: false), + RewardType = table.Column(type: "INTEGER", nullable: false), + RewardCurrencyType = table.Column(type: "INTEGER", nullable: false), + RewardAmount = table.Column(type: "INTEGER", nullable: false), + RewardItemSlug = table.Column(type: "TEXT", maxLength: 64, nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + MaxRedemptions = table.Column(type: "INTEGER", nullable: false), + RedemptionCount = table.Column(type: "INTEGER", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Coupons", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GiftPackages", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RecipientPlayerId = table.Column(type: "INTEGER", nullable: false), + FromPlayerId = table.Column(type: "INTEGER", nullable: true), + ConsumableItemDesc = table.Column(type: "TEXT", maxLength: 128, nullable: false), + AvatarItemType = table.Column(type: "INTEGER", nullable: true), + AvatarItemDescOrHairDyeDesc = table.Column(type: "TEXT", maxLength: 128, nullable: false), + EquipmentPrefabName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + EquipmentModificationGuid = table.Column(type: "TEXT", maxLength: 64, nullable: false), + CurrencyType = table.Column(type: "INTEGER", nullable: false), + Currency = table.Column(type: "INTEGER", nullable: false), + Xp = table.Column(type: "INTEGER", nullable: false), + Level = table.Column(type: "INTEGER", nullable: false), + GiftContext = table.Column(type: "INTEGER", nullable: false), + GiftRarity = table.Column(type: "INTEGER", nullable: false), + Message = table.Column(type: "TEXT", maxLength: 512, nullable: false), + Platform = table.Column(type: "INTEGER", nullable: false), + PackageMaterial = table.Column(type: "TEXT", maxLength: 128, nullable: false), + PackageVariant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Consumed = table.Column(type: "INTEGER", nullable: false), + IsValid = table.Column(type: "INTEGER", nullable: false), + ErrorMessage = table.Column(type: "TEXT", maxLength: 512, nullable: false), + SupportsCurrentPlatform = table.Column(type: "INTEGER", nullable: false), + IsGifted = table.Column(type: "INTEGER", nullable: false), + ConsumedAt = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GiftPackages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "InventionVersions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + InventionId = table.Column(type: "INTEGER", nullable: false), + ReplicationId = table.Column(type: "TEXT", maxLength: 64, nullable: false), + VersionNumber = table.Column(type: "INTEGER", nullable: false), + BlobName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + InstantiationCost = table.Column(type: "INTEGER", nullable: false), + LightsCost = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InventionVersions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "NotificationPrefs", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + Platform = table.Column(type: "TEXT", maxLength: 32, nullable: false), + AllowAll = table.Column(type: "INTEGER", nullable: false), + AllowFriendRequest = table.Column(type: "INTEGER", nullable: false), + AllowMessage = table.Column(type: "INTEGER", nullable: false), + AllowEventInvite = table.Column(type: "INTEGER", nullable: false), + AllowAnnouncements = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_NotificationPrefs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PlayerDevices", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + DeviceId = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Platform = table.Column(type: "TEXT", maxLength: 32, nullable: false), + FirstSeen = table.Column(type: "TEXT", nullable: false), + LastSeen = table.Column(type: "TEXT", nullable: false), + IsBanned = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayerDevices", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PlayerInventory", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + ItemSlug = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Quantity = table.Column(type: "INTEGER", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + AcquiredAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayerInventory", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RoomBans", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoomId = table.Column(type: "INTEGER", nullable: false), + BannedPlayerId = table.Column(type: "INTEGER", nullable: false), + BannedByPlayerId = table.Column(type: "INTEGER", nullable: false), + BanType = table.Column(type: "INTEGER", nullable: false), + Until = table.Column(type: "TEXT", nullable: true), + Reason = table.Column(type: "TEXT", maxLength: 512, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoomBans", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RoyaleMatches", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CompletedAt = table.Column(type: "TEXT", nullable: false), + Rank = table.Column(type: "INTEGER", nullable: false), + NumEliminations = table.Column(type: "INTEGER", nullable: false), + SecondsAlive = table.Column(type: "INTEGER", nullable: false), + WalkGame = table.Column(type: "INTEGER", nullable: false), + CustomGame = table.Column(type: "INTEGER", nullable: false), + ChestsOpened = table.Column(type: "INTEGER", nullable: false), + ShieldPotionsConsumed = table.Column(type: "INTEGER", nullable: false), + HealthPotionsConsumed = table.Column(type: "INTEGER", nullable: false), + SecondsInAir = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoyaleMatches", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RoyaleMatchPlayers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + MatchId = table.Column(type: "INTEGER", nullable: false), + PlayerId = table.Column(type: "INTEGER", nullable: false), + Rank = table.Column(type: "INTEGER", nullable: false), + NumEliminations = table.Column(type: "INTEGER", nullable: false), + SecondsAlive = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoyaleMatchPlayers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RoyalePlayerProgress", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + TotalXP = table.Column(type: "INTEGER", nullable: false), + Level = table.Column(type: "INTEGER", nullable: false), + RankIdx = table.Column(type: "INTEGER", nullable: false), + RankName = table.Column(type: "TEXT", maxLength: 64, nullable: false), + CurrentLevelXPThreshold = table.Column(type: "INTEGER", nullable: false), + NextLevelXPThreshold = table.Column(type: "INTEGER", nullable: false), + NextLevelAcornReward = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoyalePlayerProgress", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TestCases", + columns: table => new + { + Pk = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Id = table.Column(type: "TEXT", maxLength: 64, nullable: true), + Key = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Title = table.Column(type: "TEXT", maxLength: 256, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + RoomName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + MinNumAssignedPlayers = table.Column(type: "INTEGER", nullable: false), + AssignedPlayerIdsJson = table.Column(type: "TEXT", nullable: false), + AssignedPlayerNamesJson = table.Column(type: "TEXT", nullable: false), + TagsJson = table.Column(type: "TEXT", nullable: false), + JiraUrl = table.Column(type: "TEXT", maxLength: 512, nullable: false), + JiraBugUrl = table.Column(type: "TEXT", maxLength: 512, nullable: false), + TestPassId = table.Column(type: "INTEGER", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TestCases", x => x.Pk); + }); + + migrationBuilder.CreateTable( + name: "TestPasses", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + StartDate = table.Column(type: "TEXT", nullable: false), + EndDate = table.Column(type: "TEXT", nullable: true), + WasManuallyClosed = table.Column(type: "INTEGER", nullable: false), + TagsJson = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TestPasses", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_BugReports_ReadAt", + table: "BugReports", + column: "ReadAt"); + + migrationBuilder.CreateIndex( + name: "IX_BugReports_ReporterPlayerId", + table: "BugReports", + column: "ReporterPlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_ClubMemberships_ClubId_PlayerId", + table: "ClubMemberships", + columns: new[] { "ClubId", "PlayerId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ClubMemberships_PlayerId", + table: "ClubMemberships", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_Clubs_CreatorPlayerId", + table: "Clubs", + column: "CreatorPlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_Clubs_Name", + table: "Clubs", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CohortAssignments_PlayerId_CohortKey", + table: "CohortAssignments", + columns: new[] { "PlayerId", "CohortKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CouponRedemptions_CouponId_PlayerId", + table: "CouponRedemptions", + columns: new[] { "CouponId", "PlayerId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Coupons_Code", + table: "Coupons", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GiftPackages_RecipientPlayerId_Consumed", + table: "GiftPackages", + columns: new[] { "RecipientPlayerId", "Consumed" }); + + migrationBuilder.CreateIndex( + name: "IX_InventionVersions_InventionId_VersionNumber", + table: "InventionVersions", + columns: new[] { "InventionId", "VersionNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_NotificationPrefs_PlayerId_Platform", + table: "NotificationPrefs", + columns: new[] { "PlayerId", "Platform" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PlayerDevices_DeviceId", + table: "PlayerDevices", + column: "DeviceId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayerDevices_PlayerId", + table: "PlayerDevices", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayerInventory_PlayerId", + table: "PlayerInventory", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayerInventory_PlayerId_ItemSlug", + table: "PlayerInventory", + columns: new[] { "PlayerId", "ItemSlug" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RoomBans_BannedPlayerId", + table: "RoomBans", + column: "BannedPlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_RoomBans_RoomId_BannedPlayerId", + table: "RoomBans", + columns: new[] { "RoomId", "BannedPlayerId" }); + + migrationBuilder.CreateIndex( + name: "IX_RoyaleMatches_CompletedAt", + table: "RoyaleMatches", + column: "CompletedAt"); + + migrationBuilder.CreateIndex( + name: "IX_RoyaleMatchPlayers_MatchId_PlayerId", + table: "RoyaleMatchPlayers", + columns: new[] { "MatchId", "PlayerId" }); + + migrationBuilder.CreateIndex( + name: "IX_RoyaleMatchPlayers_PlayerId", + table: "RoyaleMatchPlayers", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_RoyalePlayerProgress_PlayerId", + table: "RoyalePlayerProgress", + column: "PlayerId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TestCases_Id", + table: "TestCases", + column: "Id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TestCases_Status", + table: "TestCases", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_TestCases_TestPassId", + table: "TestCases", + column: "TestPassId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BugReports"); + + migrationBuilder.DropTable( + name: "ClubMemberships"); + + migrationBuilder.DropTable( + name: "Clubs"); + + migrationBuilder.DropTable( + name: "CohortAssignments"); + + migrationBuilder.DropTable( + name: "CouponRedemptions"); + + migrationBuilder.DropTable( + name: "Coupons"); + + migrationBuilder.DropTable( + name: "GiftPackages"); + + migrationBuilder.DropTable( + name: "InventionVersions"); + + migrationBuilder.DropTable( + name: "NotificationPrefs"); + + migrationBuilder.DropTable( + name: "PlayerDevices"); + + migrationBuilder.DropTable( + name: "PlayerInventory"); + + migrationBuilder.DropTable( + name: "RoomBans"); + + migrationBuilder.DropTable( + name: "RoyaleMatches"); + + migrationBuilder.DropTable( + name: "RoyaleMatchPlayers"); + + migrationBuilder.DropTable( + name: "RoyalePlayerProgress"); + + migrationBuilder.DropTable( + name: "TestCases"); + + migrationBuilder.DropTable( + name: "TestPasses"); + + migrationBuilder.DropColumn( + name: "TargetEventId", + table: "Reports"); + + migrationBuilder.DropColumn( + name: "TargetInventionId", + table: "Reports"); + + migrationBuilder.DropColumn( + name: "TargetRoomId", + table: "Reports"); + + migrationBuilder.DropColumn( + name: "CreationRoomId", + table: "Inventions"); + + migrationBuilder.DropColumn( + name: "CreatorPermission", + table: "Inventions"); + + migrationBuilder.DropColumn( + name: "CurrentVersionNumber", + table: "Inventions"); + + migrationBuilder.DropColumn( + name: "FirstPublishedAt", + table: "Inventions"); + + migrationBuilder.DropColumn( + name: "GeneralPermission", + table: "Inventions"); + + migrationBuilder.DropColumn( + name: "IsAgInvention", + table: "Inventions"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "Inventions"); + + migrationBuilder.DropColumn( + name: "IsPublished", + table: "Inventions"); + + migrationBuilder.DropColumn( + name: "NumPlayersHaveUsedInRoom", + table: "Inventions"); + + migrationBuilder.DropColumn( + name: "ReplicationId", + table: "Inventions"); + + migrationBuilder.DropColumn( + name: "TargetInventionId", + table: "Cheers"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260509132303_Phase18LeaderboardElo.Designer.cs b/DorkNet.Server/Migrations/20260509132303_Phase18LeaderboardElo.Designer.cs new file mode 100644 index 0000000..1cc0cb9 --- /dev/null +++ b/DorkNet.Server/Migrations/20260509132303_Phase18LeaderboardElo.Designer.cs @@ -0,0 +1,1899 @@ +// +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("20260509132303_Phase18LeaderboardElo")] + partial class Phase18LeaderboardElo + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260509132303_Phase18LeaderboardElo.cs b/DorkNet.Server/Migrations/20260509132303_Phase18LeaderboardElo.cs new file mode 100644 index 0000000..7a6f581 --- /dev/null +++ b/DorkNet.Server/Migrations/20260509132303_Phase18LeaderboardElo.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase18LeaderboardElo : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "LeaderboardStats", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + StatChannel = table.Column(type: "INTEGER", nullable: false), + Value = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LeaderboardStats", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PlayerElo", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + GameMode = table.Column(type: "INTEGER", nullable: false), + Elo = table.Column(type: "INTEGER", nullable: false), + Wins = table.Column(type: "INTEGER", nullable: false), + Losses = table.Column(type: "INTEGER", nullable: false), + MatchesPlayed = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayerElo", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_LeaderboardStats_PlayerId_StatChannel", + table: "LeaderboardStats", + columns: new[] { "PlayerId", "StatChannel" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LeaderboardStats_StatChannel_Value", + table: "LeaderboardStats", + columns: new[] { "StatChannel", "Value" }); + + migrationBuilder.CreateIndex( + name: "IX_PlayerElo_GameMode_Elo", + table: "PlayerElo", + columns: new[] { "GameMode", "Elo" }); + + migrationBuilder.CreateIndex( + name: "IX_PlayerElo_PlayerId_GameMode", + table: "PlayerElo", + columns: new[] { "PlayerId", "GameMode" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "LeaderboardStats"); + + migrationBuilder.DropTable( + name: "PlayerElo"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260509135114_Phase19TokensIgnoresCards.Designer.cs b/DorkNet.Server/Migrations/20260509135114_Phase19TokensIgnoresCards.Designer.cs new file mode 100644 index 0000000..8598288 --- /dev/null +++ b/DorkNet.Server/Migrations/20260509135114_Phase19TokensIgnoresCards.Designer.cs @@ -0,0 +1,2014 @@ +// +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("20260509135114_Phase19TokensIgnoresCards")] + partial class Phase19TokensIgnoresCards + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260509135114_Phase19TokensIgnoresCards.cs b/DorkNet.Server/Migrations/20260509135114_Phase19TokensIgnoresCards.cs new file mode 100644 index 0000000..6faec51 --- /dev/null +++ b/DorkNet.Server/Migrations/20260509135114_Phase19TokensIgnoresCards.cs @@ -0,0 +1,109 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase19TokensIgnoresCards : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Cards", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: true), + Title = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Body = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + ImageName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + ActionUrl = table.Column(type: "TEXT", maxLength: 512, nullable: false), + Category = table.Column(type: "TEXT", maxLength: 32, nullable: false), + Priority = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Cards", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PlatformIgnores", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + Platform = table.Column(type: "INTEGER", nullable: false), + PlatformUserId = table.Column(type: "TEXT", maxLength: 64, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlatformIgnores", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PushTokens", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PlayerId = table.Column(type: "INTEGER", nullable: false), + Platform = table.Column(type: "TEXT", maxLength: 16, nullable: false), + Token = table.Column(type: "TEXT", maxLength: 256, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PushTokens", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Cards_ExpiresAt", + table: "Cards", + column: "ExpiresAt"); + + migrationBuilder.CreateIndex( + name: "IX_Cards_PlayerId", + table: "Cards", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_PlatformIgnores_PlayerId_Platform_PlatformUserId", + table: "PlatformIgnores", + columns: new[] { "PlayerId", "Platform", "PlatformUserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PushTokens_PlayerId_Platform", + table: "PushTokens", + columns: new[] { "PlayerId", "Platform" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PushTokens_Token", + table: "PushTokens", + column: "Token"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Cards"); + + migrationBuilder.DropTable( + name: "PlatformIgnores"); + + migrationBuilder.DropTable( + name: "PushTokens"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260509145611_Phase20RoomVisitsAndStats.Designer.cs b/DorkNet.Server/Migrations/20260509145611_Phase20RoomVisitsAndStats.Designer.cs new file mode 100644 index 0000000..fb68393 --- /dev/null +++ b/DorkNet.Server/Migrations/20260509145611_Phase20RoomVisitsAndStats.Designer.cs @@ -0,0 +1,2050 @@ +// +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("20260509145611_Phase20RoomVisitsAndStats")] + partial class Phase20RoomVisitsAndStats + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260509145611_Phase20RoomVisitsAndStats.cs b/DorkNet.Server/Migrations/20260509145611_Phase20RoomVisitsAndStats.cs new file mode 100644 index 0000000..5b64ffe --- /dev/null +++ b/DorkNet.Server/Migrations/20260509145611_Phase20RoomVisitsAndStats.cs @@ -0,0 +1,66 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase20RoomVisitsAndStats : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "VisitorCount", + table: "Rooms", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "RoomVisits", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoomId = table.Column(type: "INTEGER", nullable: false), + PlayerId = table.Column(type: "INTEGER", nullable: false), + FirstVisitAt = table.Column(type: "TEXT", nullable: false), + LastVisitAt = table.Column(type: "TEXT", nullable: false), + VisitCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoomVisits", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_RoomVisits_PlayerId", + table: "RoomVisits", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_RoomVisits_RoomId_LastVisitAt", + table: "RoomVisits", + columns: new[] { "RoomId", "LastVisitAt" }); + + migrationBuilder.CreateIndex( + name: "IX_RoomVisits_RoomId_PlayerId", + table: "RoomVisits", + columns: new[] { "RoomId", "PlayerId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RoomVisits"); + + migrationBuilder.DropColumn( + name: "VisitorCount", + table: "Rooms"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260509200000_Phase21RoomScenes.Designer.cs b/DorkNet.Server/Migrations/20260509200000_Phase21RoomScenes.Designer.cs new file mode 100644 index 0000000..43831e8 --- /dev/null +++ b/DorkNet.Server/Migrations/20260509200000_Phase21RoomScenes.Designer.cs @@ -0,0 +1,2100 @@ +// +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("20260509200000_Phase21RoomScenes")] + partial class Phase21RoomScenes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260509200000_Phase21RoomScenes.cs b/DorkNet.Server/Migrations/20260509200000_Phase21RoomScenes.cs new file mode 100644 index 0000000..7114c9f --- /dev/null +++ b/DorkNet.Server/Migrations/20260509200000_Phase21RoomScenes.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class Phase21RoomScenes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RoomScenes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoomId = table.Column(type: "INTEGER", nullable: false), + OrderIndex = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false, collation: "NOCASE"), + RoomSceneLocationId = table.Column(type: "TEXT", maxLength: 64, nullable: false), + DataBlobName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + MaxPlayers = table.Column(type: "INTEGER", nullable: false), + IsSandbox = table.Column(type: "INTEGER", nullable: false), + CanMatchmakeInto = table.Column(type: "INTEGER", nullable: false), + DataModifiedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoomScenes", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_RoomScenes_RoomId_Name", + table: "RoomScenes", + columns: new[] { "RoomId", "Name" }); + + migrationBuilder.CreateIndex( + name: "IX_RoomScenes_RoomId_OrderIndex", + table: "RoomScenes", + columns: new[] { "RoomId", "OrderIndex" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RoomScenes"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260516205306_MakeRoomDataBlobBytesNullable.Designer.cs b/DorkNet.Server/Migrations/20260516205306_MakeRoomDataBlobBytesNullable.Designer.cs new file mode 100644 index 0000000..ceaf6cd --- /dev/null +++ b/DorkNet.Server/Migrations/20260516205306_MakeRoomDataBlobBytesNullable.Designer.cs @@ -0,0 +1,2230 @@ +// +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("20260516205306_MakeRoomDataBlobBytesNullable")] + partial class MakeRoomDataBlobBytesNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260516205306_MakeRoomDataBlobBytesNullable.cs b/DorkNet.Server/Migrations/20260516205306_MakeRoomDataBlobBytesNullable.cs new file mode 100644 index 0000000..66b9463 --- /dev/null +++ b/DorkNet.Server/Migrations/20260516205306_MakeRoomDataBlobBytesNullable.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class MakeRoomDataBlobBytesNullable : Migration + { + // CRITICAL: this migration must NOT drop or recreate the Bytes + // column. The column physically exists on every prod DB and + // holds the legacy bytes that haven't been backfilled to S3 + // yet. A DropColumn here would obliterate every pre-cutover + // room save. We only flip NOT NULL -> NULL so new inserts + // (which leave the property null because writers go S3-only + // now) don't crash on the constraint. Run the + // /api/admin/v1/storage/backfill admin endpoint to copy the + // bytes from this column into S3 row-by-row; a follow-up + // migration drops the column once every row has been moved. + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Bytes", + table: "RoomDataBlobs", + type: "BLOB", + nullable: true, + oldClrType: typeof(byte[]), + oldType: "BLOB"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Re-tighten to NOT NULL. Any rows that became NULL during + // the backfill won't pass this constraint — caller's + // responsibility to repopulate or delete those rows + // before rolling back. Defaulting to empty BLOB keeps + // the constraint satisfiable. + migrationBuilder.AlterColumn( + name: "Bytes", + table: "RoomDataBlobs", + type: "BLOB", + nullable: false, + defaultValue: new byte[0], + oldClrType: typeof(byte[]), + oldType: "BLOB", + oldNullable: true); + } + } +} diff --git a/DorkNet.Server/Migrations/20260516213931_ForceDropBytesNotNull.Designer.cs b/DorkNet.Server/Migrations/20260516213931_ForceDropBytesNotNull.Designer.cs new file mode 100644 index 0000000..b5e75d6 --- /dev/null +++ b/DorkNet.Server/Migrations/20260516213931_ForceDropBytesNotNull.Designer.cs @@ -0,0 +1,2230 @@ +// +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("20260516213931_ForceDropBytesNotNull")] + partial class ForceDropBytesNotNull + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260516213931_ForceDropBytesNotNull.cs b/DorkNet.Server/Migrations/20260516213931_ForceDropBytesNotNull.cs new file mode 100644 index 0000000..3b11156 --- /dev/null +++ b/DorkNet.Server/Migrations/20260516213931_ForceDropBytesNotNull.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class ForceDropBytesNotNull : Migration + { + // The previous migration (MakeRoomDataBlobBytesNullable) used + // EF's AlterColumn helper, which on Postgres produced a no-op + // because EF's diff saw oldType == newType and elided the + // statement — leaving the column still NOT NULL even though + // the model said it was nullable. + // + // Symptom: backfill UPDATEs failed with 23502 "null value in + // column Bytes violates not-null constraint" for every row. + // + // This migration runs the explicit DROP NOT NULL via raw SQL, + // gated on the active provider so SQLite (which doesn't speak + // that syntax and was already correctly fixed by the previous + // migration's table-rebuild dance) isn't affected. IF NOT + // EXISTS isn't supported here, but DROP NOT NULL when the + // column is already nullable is a Postgres no-op — safe to + // re-run. + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder.ActiveProvider == "Npgsql.EntityFrameworkCore.PostgreSQL") + { + migrationBuilder.Sql(@"ALTER TABLE ""RoomDataBlobs"" ALTER COLUMN ""Bytes"" DROP NOT NULL;"); + } + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder.ActiveProvider == "Npgsql.EntityFrameworkCore.PostgreSQL") + { + // Re-tightening would fail if any rows had been nulled + // by the backfill — defensive UPDATE first puts them + // back to empty BYTEA, then the constraint can be + // re-applied. Rolling back this far is unusual. + migrationBuilder.Sql(@"UPDATE ""RoomDataBlobs"" SET ""Bytes"" = ''::bytea WHERE ""Bytes"" IS NULL;"); + migrationBuilder.Sql(@"ALTER TABLE ""RoomDataBlobs"" ALTER COLUMN ""Bytes"" SET NOT NULL;"); + } + } + } +} diff --git a/DorkNet.Server/Migrations/20260517121932_AddChatThreads.Designer.cs b/DorkNet.Server/Migrations/20260517121932_AddChatThreads.Designer.cs new file mode 100644 index 0000000..69bc5bd --- /dev/null +++ b/DorkNet.Server/Migrations/20260517121932_AddChatThreads.Designer.cs @@ -0,0 +1,2295 @@ +// +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("20260517121932_AddChatThreads")] + partial class AddChatThreads + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey") + .IsUnique(); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SnoozeUntil") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ThreadKey", "PlayerId") + .IsUnique(); + + b.ToTable("ChatThreadMembers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260517121932_AddChatThreads.cs b/DorkNet.Server/Migrations/20260517121932_AddChatThreads.cs new file mode 100644 index 0000000..15539a0 --- /dev/null +++ b/DorkNet.Server/Migrations/20260517121932_AddChatThreads.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class AddChatThreads : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ChatThreadMembers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ThreadKey = table.Column(type: "TEXT", nullable: false), + PlayerId = table.Column(type: "INTEGER", nullable: false), + JoinedAt = table.Column(type: "TEXT", nullable: false), + SnoozeUntil = table.Column(type: "TEXT", nullable: true), + LastReadMessageId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatThreadMembers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ChatThreads", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ThreadKey = table.Column(type: "TEXT", maxLength: 96, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + CreatorPlayerId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatThreads", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChatThreadMembers_PlayerId", + table: "ChatThreadMembers", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_ChatThreadMembers_ThreadKey_PlayerId", + table: "ChatThreadMembers", + columns: new[] { "ThreadKey", "PlayerId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ChatThreads_ThreadKey", + table: "ChatThreads", + column: "ThreadKey", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChatThreadMembers"); + + migrationBuilder.DropTable( + name: "ChatThreads"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260517123744_RelationshipPreferences.Designer.cs b/DorkNet.Server/Migrations/20260517123744_RelationshipPreferences.Designer.cs new file mode 100644 index 0000000..afde2f3 --- /dev/null +++ b/DorkNet.Server/Migrations/20260517123744_RelationshipPreferences.Designer.cs @@ -0,0 +1,2304 @@ +// +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("20260517123744_RelationshipPreferences")] + partial class RelationshipPreferences + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey") + .IsUnique(); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SnoozeUntil") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ThreadKey", "PlayerId") + .IsUnique(); + + b.ToTable("ChatThreadMembers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260517123744_RelationshipPreferences.cs b/DorkNet.Server/Migrations/20260517123744_RelationshipPreferences.cs new file mode 100644 index 0000000..f511dad --- /dev/null +++ b/DorkNet.Server/Migrations/20260517123744_RelationshipPreferences.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class RelationshipPreferences : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Favorited", + table: "Relationships", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Ignored", + table: "Relationships", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Muted", + table: "Relationships", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Favorited", + table: "Relationships"); + + migrationBuilder.DropColumn( + name: "Ignored", + table: "Relationships"); + + migrationBuilder.DropColumn( + name: "Muted", + table: "Relationships"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260517183746_WidenStoreItemSlug.Designer.cs b/DorkNet.Server/Migrations/20260517183746_WidenStoreItemSlug.Designer.cs new file mode 100644 index 0000000..12fc5c6 --- /dev/null +++ b/DorkNet.Server/Migrations/20260517183746_WidenStoreItemSlug.Designer.cs @@ -0,0 +1,2353 @@ +// +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("20260517183746_WidenStoreItemSlug")] + partial class WidenStoreItemSlug + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey") + .IsUnique(); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SnoozeUntil") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ThreadKey", "PlayerId") + .IsUnique(); + + b.ToTable("ChatThreadMembers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PlatformMask") + .HasColumnType("INTEGER"); + + b.Property("RoomNamesCsv") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LoadingScreenTips"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260517183746_WidenStoreItemSlug.cs b/DorkNet.Server/Migrations/20260517183746_WidenStoreItemSlug.cs new file mode 100644 index 0000000..4feeebb --- /dev/null +++ b/DorkNet.Server/Migrations/20260517183746_WidenStoreItemSlug.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class WidenStoreItemSlug : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "LoadingScreenTips", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Message = table.Column(type: "TEXT", maxLength: 512, nullable: false), + ImageName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Context = table.Column(type: "INTEGER", nullable: false), + PlatformMask = table.Column(type: "INTEGER", nullable: false), + RoomNamesCsv = table.Column(type: "TEXT", maxLength: 512, nullable: false), + SortOrder = table.Column(type: "INTEGER", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LoadingScreenTips", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "LoadingScreenTips"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260519215328_AddMessageRoomId.Designer.cs b/DorkNet.Server/Migrations/20260519215328_AddMessageRoomId.Designer.cs new file mode 100644 index 0000000..5d91994 --- /dev/null +++ b/DorkNet.Server/Migrations/20260519215328_AddMessageRoomId.Designer.cs @@ -0,0 +1,2378 @@ +// +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("20260519215328_AddMessageRoomId")] + partial class AddMessageRoomId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey") + .IsUnique(); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SnoozeUntil") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ThreadKey", "PlayerId") + .IsUnique(); + + b.ToTable("ChatThreadMembers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PlatformMask") + .HasColumnType("INTEGER"); + + b.Property("RoomNamesCsv") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LoadingScreenTips"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCommunityTeam") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("SignupsDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260519215328_AddMessageRoomId.cs b/DorkNet.Server/Migrations/20260519215328_AddMessageRoomId.cs new file mode 100644 index 0000000..eb06b63 --- /dev/null +++ b/DorkNet.Server/Migrations/20260519215328_AddMessageRoomId.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class AddMessageRoomId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsCommunityTeam", + table: "Players", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "RoomId", + table: "Messages", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "Type", + table: "Messages", + type: "INTEGER", + nullable: false, + defaultValue: 30); + + migrationBuilder.CreateTable( + name: "ServerSettings", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false), + SignupsDisabled = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ServerSettings", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ServerSettings"); + + migrationBuilder.DropColumn( + name: "IsCommunityTeam", + table: "Players"); + + migrationBuilder.DropColumn( + name: "RoomId", + table: "Messages"); + + migrationBuilder.DropColumn( + name: "Type", + table: "Messages"); + } + } +} diff --git a/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs new file mode 100644 index 0000000..6a0ed49 --- /dev/null +++ b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs @@ -0,0 +1,2375 @@ +// +using System; +using DorkNet.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + [DbContext(typeof(DorkNetDbContext))] + partial class DorkNetDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey") + .IsUnique(); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SnoozeUntil") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ThreadKey", "PlayerId") + .IsUnique(); + + b.ToTable("ChatThreadMembers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PlatformMask") + .HasColumnType("INTEGER"); + + b.Property("RoomNamesCsv") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LoadingScreenTips"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCommunityTeam") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("SignupsDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Program.cs b/DorkNet.Server/Program.cs new file mode 100644 index 0000000..c094f1b --- /dev/null +++ b/DorkNet.Server/Program.cs @@ -0,0 +1,1187 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using DorkNet.Server.Data; +using DorkNet.Server.Services; +using Serilog; +using Serilog.Formatting.Compact; +using StackExchange.Redis; +using System.Text; + +var builder = WebApplication.CreateBuilder(args); + +// Personal/local-machine override layer. Loaded last so it wins over both +// appsettings.json and appsettings.{Environment}.json. Gitignored — use +// this for Photon AppId, JWT secret, and anything you don't want committed. +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +// ── Logging ────────────────────────────────────────────────────────────────── +// Production: structured JSON (one JSON object per line) so Coolify's log +// pipeline / future Loki ingest can parse fields without regex. +// Development: human-readable text so the local console stays scannable. +// The JSON output is the same shape no matter the environment, just +// rendered differently — log keys like {Tag}, {Status}, {Method} stay +// queryable when we ship to a structured log store later. +builder.Host.UseSerilog((ctx, services, cfg) => +{ + cfg.ReadFrom.Configuration(ctx.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext(); + + if (ctx.HostingEnvironment.IsDevelopment()) + cfg.WriteTo.Console(); + else + cfg.WriteTo.Console(new RenderedCompactJsonFormatter()); +}); + +// ── Database ────────────────────────────────────────────────────────────────── +// Provider switch — `Database:Provider` config key (or DATABASE__PROVIDER env var) +// chooses sqlite (default for local dev) or postgres (production). Each provider +// gets its own migrations assembly path so we can ship both schemas without +// EF Core complaining about provider-specific column types in the snapshot. +var dbProvider = (builder.Configuration["Database:Provider"] ?? "sqlite").ToLowerInvariant(); + +builder.Services.AddDbContext(opt => +{ + // EF Core 9 throws on every connection if the model drifts from + // the migrations snapshot. We track migrations + the snapshot + // by hand for data-only fixes, so this warning is noise — Coolify + // and local-dev databases still apply real migrations from disk. + opt.ConfigureWarnings(w => w.Ignore( + Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); + + if (dbProvider == "postgres") + { + // ConnectionStrings:Default is the standard ASP.NET Core key; env var + // form is ConnectionStrings__Default. Coolify sets this when you link + // the managed Postgres service. + var conn = builder.Configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException( + "Database:Provider=postgres but ConnectionStrings:Default is not set. " + + "Set the ConnectionStrings__Default env var or `ConnectionStrings:Default` " + + "in appsettings.Local.json."); + opt.UseNpgsql(conn, npg => npg.MigrationsAssembly("DorkNet.Server")); + } + else + { + var dbPath = Path.Combine(AppContext.BaseDirectory, "data", "dorknet.db"); + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + opt.UseSqlite($"Data Source={dbPath}", lite => lite.MigrationsAssembly("DorkNet.Server")); + } +}); + +// ── Domain ──────────────────────────────────────────────────────────────────── +// Single source of truth for the deployment apex. Read once at startup +// from DORKNET_DOMAIN (env-var) or Domain:Apex (config), defaulting to +// localhost so existing deploys keep working without setting the env var. +// Replaces the per-controller [Host(...)] filters that hard-coded +// rec.net/localhost pairs — the singleton DomainConfig is injected +// anywhere code needs to build outbound URLs (img.{apex}/..., +// cdn.{apex}/..., notify.{apex}/...). The HostFilteringMiddleware +// allowed-hosts list is derived from {apex} + *.{apex} so every +// subdomain under the configured apex reaches the controllers; the +// [Host] attributes previously acted as both routing keys AND host +// filters, but now they're gone and HostFiltering does the latter alone. +var apex = + builder.Configuration["Domain:Apex"] + ?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN") + ?? "localhost"; +builder.Services.AddSingleton(new DomainConfig(apex)); +builder.Services.Configure(opt => +{ + opt.AllowedHosts = new[] { apex, $"*.{apex}", "localhost", "127.0.0.1" }; + opt.AllowEmptyHosts = true; + opt.IncludeFailureMessage = true; +}); +Console.WriteLine($"[domain] apex={apex}, allowedHosts=[{apex}, *.{apex}, localhost]"); + +// ── Services ────────────────────────────────────────────────────────────────── +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +// Singleton — the protobuf blob is identical for every room and built +// once at startup; no need to rebuild per request. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +// IHttpClientFactory + the .htr asset mirror that the admin Import +// Room endpoint kicks off as fire-and-forget after a successful upload. +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +// Scoped (per-request) — these services hold DbContext references and +// must share the request's scope to participate in the request's +// transaction lifecycle. Pre-PR-3 they were Singleton with in-process +// ConcurrentDictionary state; PR 3 moved their state to Postgres and +// they now resolve from DI per-request like any other DbContext consumer. +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +// Singletons — these own connectionless state (Redis-backed or +// process-local) and don't need a per-request scope. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +// Per-player rolling request log used by the admin UI's "Player logs" +// tab. Singleton because it's stateless beyond Redis + an in-process +// fallback queue; no DbContext involved. +builder.Services.AddSingleton(); +// S3-compatible object storage (Garage in production, MinIO/disk in dev) +// — holds profile images + room-blob bytes. Stateless wrapper around +// the AWS SDK; safe as a singleton. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── Redis ───────────────────────────────────────────────────────────────────── +// IConnectionMultiplexer is registered ONLY when a connection string is +// present. The Redis-backed services check for it via DI and fall back to +// process-local state when null. Local dev can run without Redis; Coolify +// production sets ConnectionStrings__Redis on the linked service. +// +// Coolify hands you a URI like +// redis://default:PASSWORD@host:6379/0 +// but StackExchange.Redis.Connect(string) doesn't reliably handle the +// user-info segment of a URI (the "default:PASSWORD@" part) — passing +// the URI verbatim ends up with no password applied to the +// ConfigurationOptions and Connect throws a misleading +// "Error connecting right now". So we detect a URI prefix and translate +// to SE.Redis's native config string ("host:port,password=...,user=..., +// abortConnect=false") before handing off. +var redisConn = builder.Configuration.GetConnectionString("Redis"); +if (!string.IsNullOrWhiteSpace(redisConn)) +{ + var seRedisConfig = NormalizeRedisConn(redisConn); + var mux = ConnectionMultiplexer.Connect(seRedisConfig); + builder.Services.AddSingleton(mux); +} + +static string NormalizeRedisConn(string raw) +{ + if (!raw.StartsWith("redis://", StringComparison.OrdinalIgnoreCase) && + !raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase)) + return raw; // already SE.Redis native config string + var uri = new Uri(raw); + var host = uri.Host; + var port = uri.Port > 0 ? uri.Port : 6379; + string? user = null, pass = null; + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + var parts = uri.UserInfo.Split(':', 2); + user = Uri.UnescapeDataString(parts[0]); + if (parts.Length == 2) pass = Uri.UnescapeDataString(parts[1]); + } + var opts = new StackExchange.Redis.ConfigurationOptions + { + EndPoints = { { host, port } }, + AbortOnConnectFail = false, + Ssl = raw.StartsWith("rediss://", StringComparison.OrdinalIgnoreCase), + }; + if (!string.IsNullOrEmpty(user)) opts.User = user; + if (!string.IsNullOrEmpty(pass)) opts.Password = pass; + return opts.ToString(); +} + +// ── JWT Auth ────────────────────────────────────────────────────────────────── +// Resolution order: env-var first (for production / containerised deploys), +// then `Jwt:Secret` from any configuration source (appsettings.Local.json +// is the same-machine convenience path the patcher script writes to). +// Never commit a real secret to appsettings.json — the gitignored +// appsettings.Local.json or the env-var are the only legitimate sources. +// DORKNET_JWT_SECRET is the canonical env var name; RECNET_JWT_SECRET +// kept for backward compat with older Coolify configs (will warn at +// startup, can be removed once everyone has migrated). AuthService +// has the matching fallback so signing + validation use the same key. +var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET") + ?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET") + ?? builder.Configuration["Jwt:Secret"] + ?? throw new InvalidOperationException( + "JWT secret not configured. Set the DORKNET_JWT_SECRET env var or " + + "add `Jwt:Secret` to appsettings.Local.json (the install-plugin.ps1 " + + "script generates one automatically on same-machine setup)."); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(opt => + { + opt.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), + ValidateIssuer = true, + ValidIssuer = "https://api.rec.net/", + ValidateAudience = false, + ClockSkew = TimeSpan.Zero, + }; + // SignalR over WebSocket: BestHTTP.SignalRCore (the 2020 watch's + // hub client) negotiates with Authorization: Bearer, but the + // WebSocket upgrade leg isn't guaranteed to keep the header + // depending on the proxy in front of us — Cloudflare Tunnel + // passes through, but Coolify's Caddy / browsers don't. The + // hub client falls back to ?access_token= on the WS URL, + // which we extract here for any /hub/* path so the connection + // authenticates the same way negotiate did. Without this, the + // watch's RecNet.Notifications connect throws "Failed to + // connect to RecNet Notifications" mid-login. + opt.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents + { + OnMessageReceived = ctx => + { + var accessToken = ctx.Request.Query["access_token"]; + var path = ctx.HttpContext.Request.Path; + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub")) + ctx.Token = accessToken; + return Task.CompletedTask; + }, + }; + }); + +builder.Services.AddAuthorization(); +// Use C# property names verbatim — [JsonPropertyName] attributes on each +// model give the per-key casing the client's LitJson Util.GetKey calls +// expect (a mix of lowercase, camelCase and snake_case; verified by +// disassembling each Deserialize body). Don't apply a global naming policy +// or it will override the explicit per-key attributes. +builder.Services.AddControllers().AddJsonOptions(opt => + opt.JsonSerializerOptions.PropertyNamingPolicy = null); + +// Kestrel-level limits — the defaults trim long-lived sockets earlier +// than our SignalR ping interval allows. Specifically: +// * KeepAliveTimeout (HTTP keep-alive between requests, default 130s) +// applies to upgraded WebSocket connections in some hosting paths; +// bump to 10m so it never beats SignalR's own ping cadence. +// * RequestHeadersTimeout (default 30s) is for the initial upgrade +// handshake only — leave it. +// * MinRequestBodyDataRate / MinResponseDataRate enforce a minimum +// bytes/sec on streaming bodies; for chunked-upload room imports +// that's been the cause of mysterious abort-mid-write errors on +// slow links. Disabling them is safer than risking false-positive +// "client too slow" closures. +builder.WebHost.ConfigureKestrel(kopts => +{ + kopts.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10); + kopts.Limits.MinRequestBodyDataRate = null; + kopts.Limits.MinResponseDataRate = null; +}); + +// SignalR — drives notify.rec.net/hub/v1, consumed client-side by +// BestHTTP.SignalRCore.HubConnection. The framework handles the +// /negotiate POST automatically. When Redis is configured, attach +// the StackExchange.Redis backplane so groups (player:N) fan out +// across replicas — without it, player A connected to replica 1 +// would never receive a notification published from replica 2. +// +// Keepalive cadence: server pings every 10s, gives the client 60s +// to ack before declaring the connection dead. The 10s ping forces +// frame traffic well below every proxy timeout we've seen in the +// path (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik +// 180s, Kestrel 130s). 60s client timeout is double the ping +// interval so a single dropped ping doesn't kill the connection. +var signalR = builder.Services.AddSignalR(opts => +{ + opts.KeepAliveInterval = TimeSpan.FromSeconds(10); + opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60); + opts.HandshakeTimeout = TimeSpan.FromSeconds(15); + // Allow large gift / community-board payloads (default 32 KB + // truncates anything bigger and disconnects the client). + opts.MaximumReceiveMessageSize = 1024 * 1024; + // Surface transport errors so we can tell whether a disconnect + // was clean (Traefik idleTimeout) vs framing-level (cloudflared + // chunked-encoding mismatch). + opts.EnableDetailedErrors = true; +}); +if (!string.IsNullOrWhiteSpace(redisConn)) + signalR.AddStackExchangeRedis(NormalizeRedisConn(redisConn), opts => + { + // Channel prefix keeps the SignalR backplane's pub/sub channels + // namespaced from any other Redis tenants in the same instance. + opts.Configuration.ChannelPrefix = RedisChannel.Literal("dorknet-signalr"); + }); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// DomainConfig is the single source of truth for the deployment apex +// (DORKNET_DOMAIN). Resolve it once at startup; every downstream +// host-mount / redirect / hub binding below uses this instead of +// hardcoded rec.net/localhost literals. +var domainCfg = app.Services.GetRequiredService(); + +// Request/response tracing — emits one structured log per non-health request, +// with the response body included for any 4xx/5xx so we can see exactly what +// the game is asking for. This runs early enough to catch route misses, +// auth failures, storage/CDN host traffic, and controller responses. +var reqLogger = app.Services.GetRequiredService() + .CreateLogger("DorkNet.Server.HttpTrace"); +var playerLog = app.Services.GetRequiredService(); +app.Use(async (ctx, next) => +{ + if (IsHealthProbe(ctx.Request)) + { + await next(); + return; + } + + // WebSocket upgrades take over the underlying connection — wrapping the + // response body in a MemoryStream confuses the upgrade handshake and + // prevents SignalR's WebSocket transport from working. Skip tracing for + // upgrade requests and let them flow through unmodified. + if (ctx.WebSockets.IsWebSocketRequest) + { + reqLogger.LogInformation( + "[ws] {Method} {Host}{Path}{Query}", + ctx.Request.Method, ctx.Request.Host, ctx.Request.Path, + ctx.Request.QueryString.Value ?? ""); + await next(); + return; + } + + var host = ctx.Request.Host.Host; + var path = ctx.Request.Path.Value ?? ""; + var isStorageLike = host.StartsWith("storage.", StringComparison.OrdinalIgnoreCase) + || host.StartsWith("cdn.", StringComparison.OrdinalIgnoreCase) + || path.Contains("upload", StringComparison.OrdinalIgnoreCase); + + if (isStorageLike) + { + reqLogger.LogInformation( + "[req:start] {Method} {Host}{Path}{Query} contentType={ContentType} contentLength={ContentLength}", + ctx.Request.Method, ctx.Request.Host, ctx.Request.Path, + ctx.Request.QueryString.Value ?? "", ctx.Request.ContentType ?? "", + ctx.Request.ContentLength); + } + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var originalBody = ctx.Response.Body; + var captureResponse = ShouldCaptureResponse(ctx.Request); + MemoryStream? capture = null; + if (captureResponse) + { + capture = new MemoryStream(); + ctx.Response.Body = capture; + } + + string? requestBody = null; + if (ctx.Request.ContentLength is > 0 and < 4096 && IsTextLikeRequest(ctx.Request.ContentType)) + { + ctx.Request.EnableBuffering(); + using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true); + requestBody = await reader.ReadToEndAsync(); + ctx.Request.Body.Position = 0; + } + + Exception? thrown = null; + try + { + await next(); + } + catch (Exception ex) + { + thrown = ex; + throw; + } + finally + { + sw.Stop(); + var status = thrown is null ? ctx.Response.StatusCode : 500; + var responseBody = ""; + + if (capture is not null) + { + try + { + capture.Seek(0, SeekOrigin.Begin); + if (ShouldReadResponseBody(ctx.Response.ContentType, status)) + responseBody = await new StreamReader(capture, leaveOpen: true).ReadToEndAsync(); + capture.Seek(0, SeekOrigin.Begin); + await capture.CopyToAsync(originalBody); + } + finally + { + ctx.Response.Body = originalBody; + await capture.DisposeAsync(); + } + } + + var trimmedRequest = requestBody is null ? null + : (requestBody.Length > 200 ? requestBody[..200] + "..." : requestBody); + var trimmedResponse = responseBody.Length > 300 ? responseBody[..300] + "..." : responseBody; + var level = thrown is not null || status >= 500 ? LogLevel.Error + : status >= 400 ? LogLevel.Warning + : LogLevel.Information; + + reqLogger.Log(level, thrown, + "[req] {Status} {Method} {Host}{Path}{Query} {ElapsedMs}ms contentType={ContentType} contentLength={ContentLength} req={ReqBody} resp={RespBody}", + status, ctx.Request.Method, ctx.Request.Host, ctx.Request.Path, + ctx.Request.QueryString.Value ?? "", sw.ElapsedMilliseconds, + ctx.Request.ContentType ?? "", ctx.Request.ContentLength, + trimmedRequest ?? "", trimmedResponse); + + // Per-player log — only record when we know who made the call. + var pid = DorkNet.Server.Auth.ControllerBaseExtensions.CurrentPlayerId(ctx.User); + if (pid is long playerId) + { + playerLog.Record(new DorkNet.Server.Services.PlayerLogEntry + { + Timestamp = DateTime.UtcNow, + PlayerId = playerId, + Method = ctx.Request.Method, + Host = ctx.Request.Host.Host, + Path = ctx.Request.Path.Value ?? string.Empty, + Query = ctx.Request.QueryString.Value ?? string.Empty, + Status = status, + ElapsedMs = sw.ElapsedMilliseconds, + ReqBody = trimmedRequest, + RespBody = trimmedResponse, + }); + } + } +}); + +// ── Migrate DB ──────────────────────────────────────────────────────────────── +// EF Core migrations under Migrations/. On boot we apply any pending ones — +// `Migrate()` is a no-op when the DB is already up to date and applies the +// missing migrations in order otherwise. Schema evolution is now exclusively +// done via `dotnet ef migrations add `; never call `EnsureCreated` (it +// bypasses the migration history table and leaves the db in a broken +// "exists but no __EFMigrationsHistory entries" state that future Migrate +// calls can't reason about). +// +// One-time upgrade path: DBs created by the previous EnsureCreated boot +// path already contain the Initial schema but have no __EFMigrationsHistory +// entries. The baseline detector below catches that case and inserts a +// row marking Initial as already applied, so Migrate() will only apply the +// Phase1+Phase2 deltas (column adds) instead of trying to re-CREATE TABLE. +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + if (db.Database.IsSqlite()) + { + // Sqlite-only legacy baseline path — sqlite_master probes inside. + BaselineExistingSchemaIfNeeded(db); + db.Database.Migrate(); + } + else + { + // Postgres path: schema generated from the EF Core model via + // EnsureCreated. Wrapped in a transaction-scoped advisory lock + // so concurrent replicas booting against the same database + // don't race on CREATE TABLE — only the first replica to take + // the lock runs EnsureCreated, the rest see "schema already + // exists" and proceed. + // + // Lock key 0x444F524B (ascii "DORK") is hard-coded; it just + // needs to be the same int64 across replicas and not collide + // with anything else in the database. pg_advisory_xact_lock + // releases automatically on COMMIT, so a crashed replica can't + // wedge the next boot. + // + // Future schema changes will swap EnsureCreated for + // db.Database.Migrate() once we cut a proper Postgres + // migrations set; the advisory-lock wrapping stays the same. + var conn = db.Database.GetDbConnection(); + + // Retry initial Postgres connect — on container-orchestrated boots + // (Coolify, docker-compose) the Postgres service's DNS often isn't + // resolvable yet when this app starts, surfacing as SocketException + // errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore. + // Postgres itself can also be up-but-not-accepting-connections for + // a few seconds while it replays WAL. 15 attempts × ~2s backoff + // covers the typical 5-30s warmup window without looping forever + // on a permanently broken config. + var connectLogger = app.Services.GetRequiredService() + .CreateLogger("DorkNet.Server.Bootstrap"); + for (var attempt = 1; ; attempt++) + { + try { await conn.OpenAsync(); break; } + catch (Exception ex) when (attempt < 15 && + (ex is System.Net.Sockets.SocketException + || ex is Npgsql.NpgsqlException + || ex is System.Data.Common.DbException)) + { + var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5)); + connectLogger.LogWarning( + "[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s", + attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds); + await Task.Delay(delay); + } + } + await using var tx = await conn.BeginTransactionAsync(); + await using (var lockCmd = conn.CreateCommand()) + { + lockCmd.Transaction = tx; + lockCmd.CommandText = "SELECT pg_advisory_xact_lock(1146246987);"; + await lockCmd.ExecuteNonQueryAsync(); + } + db.Database.EnsureCreated(); + await tx.CommitAsync(); + + // ── Post-EnsureCreated schema patches (Postgres only) ───────── + // EnsureCreated only fires once per database — it lays down the + // initial schema and never revisits it on later deploys. Any + // model change after the first boot (column added, NOT NULL + // dropped, etc.) leaves prod stuck on the bootstrap schema. + // EF migrations in this codebase are SQLite-only; Postgres + // schema drift has to be patched explicitly here. + // + // Each patch is idempotent — Postgres treats redundant + // ALTER COLUMN statements as no-ops, so safe on every boot. + // Add new patches at the bottom of this block; never remove + // older ones (older databases still need them when they catch + // up). + var bootstrapLogger = app.Services.GetRequiredService() + .CreateLogger("DorkNet.Server.Bootstrap"); + + async Task RunPatchAsync(string label, string sql) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + try { await cmd.ExecuteNonQueryAsync(); } + catch (Exception ex) + { + bootstrapLogger.LogWarning(ex, "[schema-patch] {Label} failed", label); + } + } + + // 2026-05-16 — RoomDataBlobs.Bytes nullable. The storage + // backfill (admin → Storage panel) needs to UPDATE this + // column to NULL after streaming the bytes to S3. + await RunPatchAsync("RoomDataBlobs.Bytes nullable", + @"ALTER TABLE ""RoomDataBlobs"" ALTER COLUMN ""Bytes"" DROP NOT NULL;"); + + // 2026-05-17 — ChatThreads / ChatThreadMembers added for + // group chats + per-thread snooze/last-read pointers. Both + // tables are EnsureCreated-skipped on existing DBs; recreate + // them idempotently here. Column order + types must match + // the entity classes (see Data/Entities/ChatThread*.cs). + await RunPatchAsync("ChatThreads table", + @"CREATE TABLE IF NOT EXISTS ""ChatThreads"" ( + ""Id"" bigserial PRIMARY KEY, + ""ThreadKey"" varchar(96) NOT NULL, + ""Name"" varchar(128) NOT NULL DEFAULT '', + ""CreatorPlayerId"" bigint NOT NULL, + ""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(), + ""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now() + );"); + await RunPatchAsync("ChatThreads ThreadKey unique index", + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreads_ThreadKey"" + ON ""ChatThreads"" (""ThreadKey"");"); + await RunPatchAsync("ChatThreadMembers table", + @"CREATE TABLE IF NOT EXISTS ""ChatThreadMembers"" ( + ""Id"" bigserial PRIMARY KEY, + ""ThreadKey"" text NOT NULL, + ""PlayerId"" bigint NOT NULL, + ""JoinedAt"" timestamp with time zone NOT NULL DEFAULT now(), + ""SnoozeUntil"" timestamp with time zone NULL, + ""LastReadMessageId"" bigint NULL + );"); + await RunPatchAsync("ChatThreadMembers (ThreadKey, PlayerId) unique index", + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_ThreadKey_PlayerId"" + ON ""ChatThreadMembers"" (""ThreadKey"", ""PlayerId"");"); + await RunPatchAsync("ChatThreadMembers PlayerId index", + @"CREATE INDEX IF NOT EXISTS ""IX_ChatThreadMembers_PlayerId"" + ON ""ChatThreadMembers"" (""PlayerId"");"); + + // 2026-05-17 — Relationships favorite/mute/ignore columns + // for v1/favorite, v1/mute, v1/ignore persistence. Default + // false on every row; existing relationships keep no + // preference set. + await RunPatchAsync("Relationships.Favorited column", + @"ALTER TABLE ""Relationships"" + ADD COLUMN IF NOT EXISTS ""Favorited"" boolean NOT NULL DEFAULT false;"); + await RunPatchAsync("Relationships.Muted column", + @"ALTER TABLE ""Relationships"" + ADD COLUMN IF NOT EXISTS ""Muted"" boolean NOT NULL DEFAULT false;"); + await RunPatchAsync("Relationships.Ignored column", + @"ALTER TABLE ""Relationships"" + ADD COLUMN IF NOT EXISTS ""Ignored"" boolean NOT NULL DEFAULT false;"); + + // 2026-05-17 — Widen StoreItems.Slug to 128 chars to fit + // wardrobe-colored-{guid}-{color} SKUs. Postgres ALTER COLUMN + // is fast (metadata-only); idempotent if already 128+. + await RunPatchAsync("StoreItems.Slug widen to 128", + @"ALTER TABLE ""StoreItems"" ALTER COLUMN ""Slug"" TYPE varchar(128);"); + + // 2026-05-17 — Messages.Type column for RecNet.MessageType + // round-tripping (RequestGameInvite = 10, GameInvite = 0, + // TextMessage = 30, etc.). Before this column existed every + // message came back to the watch as Type=30, breaking the + // join-request flow. + await RunPatchAsync("Messages.Type column", + @"ALTER TABLE ""Messages"" + ADD COLUMN IF NOT EXISTS ""Type"" integer NOT NULL DEFAULT 30;"); + await RunPatchAsync("Messages.RoomId column", + @"ALTER TABLE ""Messages"" + ADD COLUMN IF NOT EXISTS ""RoomId"" bigint NULL;"); + + // 2026-05-20 — PrivateInstanceInvitees.LatestInviteMessageId. + // Lets /goto/invite/{messageId} resolve the roomInstanceId via + // the invitee row when the watch's accept flow has already + // raced through DELETE /api/messages/v3/delete and the Message + // row is gone. Pre-patch the server returned ErrorCode 40 + // ("invite expired") for accepts where DELETE landed first. + await RunPatchAsync("PrivateInstanceInvitees.LatestInviteMessageId column", + @"ALTER TABLE ""PrivateInstanceInvitees"" + ADD COLUMN IF NOT EXISTS ""LatestInviteMessageId"" bigint NULL;"); + + // 2026-05-20 — coerce every existing PrivateInstance row to + // match the current Photon:CloudRegion. Older rows still carry + // whatever region was in config when their dorm was first + // registered; without this rewrite, an invitee whose + // /goto/invite resolves to one of those rows would be sent to + // the stale region while the inviter's most recent + // /goto/room/DormRoom puts them on the current region — two + // parallel Photon rooms, players can't see each other. The + // EnsureForDormAsync update path now refreshes PhotonRegion on + // each owner /goto, so going forward this drift is impossible; + // this one-shot UPDATE just cleans up the pre-fix backlog. + // Idempotent — the WHERE skips rows already on the right + // region. + var currentRegion = (app.Configuration["Photon:CloudRegion"] ?? "us").ToLowerInvariant(); + await RunPatchAsync($"PrivateInstances.PhotonRegion → {currentRegion}", + $@"UPDATE ""PrivateInstances"" + SET ""PhotonRegion"" = '{currentRegion}' + WHERE ""PhotonRegion"" IS DISTINCT FROM '{currentRegion}';"); + + // 2026-05-18 — CommunityBoardRows table for the dorm community + // board (FeaturedPlayer/Announcement/InstagramImages/Videos). + // EnsureCreated skips this on databases created before the + // entity was added; without this patch the + // /api/communityboard/v1/current endpoint silently returns + // DefaultState because the row table doesn't exist (or the + // row was never persisted) and admins see an empty board. + await RunPatchAsync("CommunityBoardRows table", + @"CREATE TABLE IF NOT EXISTS ""CommunityBoardRows"" ( + ""Id"" integer PRIMARY KEY, + ""Json"" text NOT NULL DEFAULT '{}', + ""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now() + );"); + + // 2026-05-19 — ServerSettings single-row table backing the + // admin SPA's runtime toggles (currently just SignupsDisabled). + // Same EnsureCreated-skip story as CommunityBoardRows above: + // existing prod DBs won't get the table without this idempotent + // patch. + await RunPatchAsync("ServerSettings table", + @"CREATE TABLE IF NOT EXISTS ""ServerSettings"" ( + ""Id"" integer PRIMARY KEY, + ""SignupsDisabled"" boolean NOT NULL DEFAULT false, + ""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now() + );"); + + // 2026-05-19 — Players.IsCommunityTeam column. Backs the + // overhead-badge admin toggle alongside IsDeveloper; the + // RoleController ORs the two so either one unlocks the watch's + // in-settings developer-display slider (which renders + // "Community Team" / "Developer" above the player's head per + // Cpp2IL_ISIL/.../PlayerUI.txt:9085-9099). + await RunPatchAsync("Players.IsCommunityTeam column", + @"ALTER TABLE ""Players"" + ADD COLUMN IF NOT EXISTS ""IsCommunityTeam"" boolean NOT NULL DEFAULT false;"); + + // 2026-05-20 — HiddenFromBrowse keeps admin-utility rooms + // (MakerRoom, EventRoom) and rooms-folded-into-others + // (paintball sub-maps, LaserTag Hangar) out of room browse, + // search, and the originals feed while leaving /goto + clone + // + admin access intact. + await RunPatchAsync("Rooms.HiddenFromBrowse column", + @"ALTER TABLE ""Rooms"" + ADD COLUMN IF NOT EXISTS ""HiddenFromBrowse"" boolean NOT NULL DEFAULT false;"); + + // 2026-05-20 — RoomRoles table for per-room co-owner / moderator + // / host grants. Drives RoomDetails.CoOwners / Moderators / Hosts + // arrays (and InvitedCoOwners / InvitedModerators / InvitedHosts + // when Accepted=false). Unique on (RoomId, PlayerId, Role) so + // granting the same role twice is a no-op. + await RunPatchAsync("RoomRoles table", + @"CREATE TABLE IF NOT EXISTS ""RoomRoles"" ( + ""Id"" bigserial PRIMARY KEY, + ""RoomId"" bigint NOT NULL, + ""PlayerId"" bigint NOT NULL, + ""Role"" integer NOT NULL, + ""Accepted"" boolean NOT NULL DEFAULT true, + ""GrantedByPlayerId"" bigint NULL, + ""GrantedAt"" timestamp with time zone NOT NULL DEFAULT now() + );"); + await RunPatchAsync("RoomRoles (RoomId, PlayerId, Role) unique index", + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId_PlayerId_Role"" + ON ""RoomRoles"" (""RoomId"", ""PlayerId"", ""Role"");"); + await RunPatchAsync("RoomRoles RoomId index", + @"CREATE INDEX IF NOT EXISTS ""IX_RoomRoles_RoomId"" ON ""RoomRoles"" (""RoomId"");"); + + // 2026-05-20 — LeaderboardChannelMeta lets admins map any + // StatChannel int the watch reports to (room, name, sort + // direction). Drives the per-room Leaderboards tab in the + // admin SPA. KnownStatChannels in AdminController stays as + // hardcoded fallback for channels with no row here. + await RunPatchAsync("LeaderboardChannelMeta table", + @"CREATE TABLE IF NOT EXISTS ""LeaderboardChannelMeta"" ( + ""Channel"" integer PRIMARY KEY, + ""RoomId"" bigint NOT NULL DEFAULT 0, + ""Name"" varchar(128) NOT NULL DEFAULT '', + ""LowerIsBetter"" boolean NOT NULL DEFAULT false, + ""ValueFormat"" varchar(32) NOT NULL DEFAULT 'count', + ""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(), + ""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now() + );"); + await RunPatchAsync("LeaderboardChannelMeta RoomId index", + @"CREATE INDEX IF NOT EXISTS ""IX_LeaderboardChannelMeta_RoomId"" + ON ""LeaderboardChannelMeta"" (""RoomId"");"); + + // 2026-05-17 — LoadingScreenTips table for admin-editable + // dorm-load splash tips with optional uploaded images. + await RunPatchAsync("LoadingScreenTips table", + @"CREATE TABLE IF NOT EXISTS ""LoadingScreenTips"" ( + ""Id"" bigserial PRIMARY KEY, + ""Title"" varchar(128) NOT NULL DEFAULT '', + ""Message"" varchar(512) NOT NULL DEFAULT '', + ""ImageName"" varchar(128) NOT NULL DEFAULT '', + ""Context"" int NOT NULL DEFAULT 0, + ""PlatformMask"" int NOT NULL DEFAULT -1, + ""RoomNamesCsv"" varchar(512) NOT NULL DEFAULT '', + ""SortOrder"" int NOT NULL DEFAULT 0, + ""IsActive"" boolean NOT NULL DEFAULT true, + ""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(), + ""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now() + );"); + + await conn.CloseAsync(); + } + + // Coach system account at Player.Id=1. The RR-Original room seeder + // below assigns CreatorPlayerId=1 to every canonical room + // (RecCenter, Paintball, Dodgeball, etc.), so the FK target needs + // to exist before we insert rooms — otherwise admin tools' "rooms + // by player N" queries return rows whose CreatorPlayerId points to + // a non-existent Player. Idempotent: the call no-ops once the row + // is there. Must run BEFORE roomService.SeedAsync(). + var playerService = scope.ServiceProvider.GetRequiredService(); + await playerService.EnsureSystemAccountAsync(); + // Self-heal the AvatarEntity seed on every startup so accounts + // created with old/broken starter values get the current safe + // GUIDs without needing a one-shot EF migration. Idempotent — + // only updates rows that need it (new accounts, accounts with + // the old mask/swatch starter, and any orphan empty rows). + await playerService.EnsureAvatarSeedAsync(); + + // Grant the starter RecCenterTokens balance to any account that + // doesn't yet have a CurrencyType=2 row. Pre-existing accounts + // (created before this seed shipped) booted into a 0-token wallet + // and saw "Not enough tokens" on every store tile — this brings + // them up to the same starting state new accounts get. Idempotent: + // only inserts where the row is absent, never overwrites a row + // that's already there (so spent-down accounts aren't refilled). + await playerService.BackfillStarterWalletsAsync(); + + // Backfill the per-player dorm room + DormStateEntity for any + // legacy account that signed in via a path that didn't run + // EnsurePersonalDormAsync (early-bird signups, manual SQL + // imports). Without these rows the watch's + // /api/rooms/v4/details/{dormId} → cdn.localhost/room/{blobName} + // chain hits an empty CurrentDataBlobName which the CDN serves + // as the default-dorm bytes — but only if the dorm room itself + // exists. This call guarantees both rows for every existing + // account before traffic starts. Runs after EnsureSystemAccount + // so Coach is excluded. + var roomService = scope.ServiceProvider.GetRequiredService(); + await roomService.EnsureDormsForAllPlayersAsync(); + + // Seed canonical Rec Room Original rooms so the watch's "Trending" tab + // has content on first launch. Idempotent — RoomService.SeedAsync + // bails if any rooms already exist. + await roomService.SeedAsync(); + + // Canonical room overrides: rename BloodMoon → Crescendo, fold the + // paintball-map standalone rooms (River/Homestead/Quarry/Clearcut/ + // Spillway/Drive-In) into Paintball as sub-rooms, fold LaserTagHangar + // into LaserTag, hide MakerRoom + EventRoom from browse, and pull + // down the user-supplied thumbnails for Crescendo + Paintball. + // Idempotent — re-running on an already-overridden DB no-ops. + using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }) + { + var imagesDir = Path.Combine(AppContext.BaseDirectory, "data", "images"); + await roomService.ApplyCanonicalOverridesAsync(http, imagesDir); + } + + // Seed the default store catalog so the Shop tab is populated on + // first boot. Idempotent — only inserts items whose slug isn't + // already present. + var storeService = scope.ServiceProvider.GetRequiredService(); + await storeService.SeedAsync(); + + // Tell the /healthz probe migrations are done. Coolify's rolling + // deploy will hold traffic at the LB until this flips and the + // healthz response goes 503 → 200. + DorkNet.Server.Controllers.Health.HealthController.MigrationsComplete = true; +} + +// ── Middleware ──────────────────────────────────────────────────────────────── +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +// HTTP → HTTPS redirect — only kicks in when an HTTPS endpoint is configured +// in appsettings (Kestrel-direct mode). When fronted by nginx, both endpoints +// are effectively HTTP from Kestrel's perspective and this is a no-op. +app.UseHttpsRedirection(); + +// 2-minute WebSocket pings are too lazy for cloudflared — the tunnel +// kills the underlying TCP stream after ~90-100s of silence even +// though SignalR's app-layer pings would arrive on schedule. Send a +// ping frame every 30s so the tunnel always sees fresh traffic; the +// ping payload is tiny, and SignalR's own KeepAliveInterval (15s) +// stays under this floor. +app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) }); + +// Static admin UI hosted on admin.rec.net (its own subdomain). The +// PhysicalFileProvider is rooted at wwwroot/admin so requests like +// admin.rec.net/style.css map to wwwroot/admin/style.css — the URL +// has no /admin prefix. +// +// Mounted BEFORE the tracing wrapper because StaticFileMiddleware uses +// IHttpResponseBodyFeature.SendFileAsync, which writes directly to the +// underlying connection and ignores any Response.Body replacement done +// downstream — putting it after tracing would result in the actual +// file being sent to the socket, then the tracing middleware copying +// its (empty) MemoryStream back over the same body and clobbering it. +// +// UseWhen (NOT MapWhen) so that requests for paths the admin UI +// doesn't serve (e.g. admin.rec.net/api/admin/v1/players) fall through +// to the controller pipeline. MapWhen would fork into a parallel +// pipeline that terminates without invoking matched endpoints. +// Helper: bind a per-host static-files branch. Each subdomain gets its +// own PhysicalFileProvider rooted at wwwroot/, so requests like +// admin.rec.net/style.css map to wwwroot/admin/style.css with no path +// prefix munging required. +static void MountStaticHost(WebApplication app, string host, string subdir, bool spaFallback = false) +{ + var staticHostLogger = app.Services.GetRequiredService() + .CreateLogger("DorkNet.Server.StaticHost"); + var root = Path.Combine(app.Environment.ContentRootPath, "wwwroot", subdir); + staticHostLogger.LogInformation( + "[static-host] {Host} → {Root} (exists: {Exists}, spaFallback: {Spa})", + host, root, Directory.Exists(root), spaFallback); + if (!Directory.Exists(root)) return; + var files = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(root); + + // Use UseDefaultFiles + UseStaticFiles directly rather than + // UseFileServer because the latter's nested options initialization + // doesn't always propagate the FileProvider correctly when wrapped + // inside UseWhen — leading to the static files middleware looking + // at the default WebRoot (wwwroot/) instead of our scoped folder + // (wwwroot/admin/), which makes /index.html requests fall through + // to the API catch-all. + var defaultFilesOpts = new DefaultFilesOptions { FileProvider = files }; + var staticFilesOpts = new StaticFileOptions + { + FileProvider = files, + // Don't let the browser cache admin assets — we iterate on the + // HTML/JS/CSS often and a stale cached page silently swallows + // every change. Cache-Control: no-cache forces a conditional + // GET (If-Modified-Since) on every load; the file is small and + // the round-trip is fine for an admin UI used by 1-2 people. + OnPrepareResponse = ctx => + { + var headers = ctx.Context.Response.Headers; + headers.CacheControl = "no-cache, no-store, must-revalidate"; + headers.Pragma = "no-cache"; + headers.Expires = "0"; + }, + }; + + app.UseWhen(ctx => ctx.Request.Host.Host.Equals(host, StringComparison.OrdinalIgnoreCase), + branch => + { + // Diagnostic: confirm the branch is being entered AND the + // FileProvider can resolve the requested file. At Information + // level temporarily to debug the admin asset 404s. + // + // Endpoint-clearing rule: the auto-added UseRouting (which + // runs before our middleware in minimal hosting) may have + // matched a non-admin endpoint for paths the static-host + // should answer — typically NsController.Root for "/" on + // admin.localhost and the apex, which would serve the + // service-URL-map JSON instead of the SPA's index.html. + // StaticFileMiddleware's ValidateNoEndpoint guard skips + // serving whenever an endpoint is already matched, so we + // clear it for non-API paths to let UseStaticFiles run. + // + // BUT: /api/* paths MUST keep their matched endpoint — + // those are admin/site/feed API calls that the matching + // controller (AdminController, SiteController, etc.) + // needs to handle. Clearing the endpoint for /api/* + // would 404 every admin API call. + branch.Use(async (ctx, next) => + { + var p = ctx.Request.Path.Value ?? "/"; + var probe = files.GetFileInfo(p == "/" ? "/index.html" : p); + staticHostLogger.LogInformation( + "[static-host:{Host}] path={Path} probe={Probe} exists={Exists}", + host, p, probe.PhysicalPath ?? "", probe.Exists); + if (!p.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) + ctx.SetEndpoint(null); + await next(); + }); + branch.UseDefaultFiles(defaultFilesOpts); + branch.UseStaticFiles(staticFilesOpts); + + // Terminal handler — runs AFTER UseStaticFiles. By this point + // the request either: + // (a) was a static file UseStaticFiles served — Response + // already started, we just return. + // (b) is an API call we want to fall through to controllers + // (e.g. admin.localhost/api/admin/v1/players, + // localhost/api/site/v1/feed) — call next(). + // (c) is a SPA deep link (no extension) we want to answer + // with index.html so React Router can re-resolve. + // (d) is a missing static asset (has extension, file + // gone — e.g. /assets/index-.js after a + // redeploy). Return 404 with no body — DO NOT fall + // through to controllers. The catch-all used to + // answer these with JSON {} which a browser + + diff --git a/DorkNet.Server/admin-ui/package-lock.json b/DorkNet.Server/admin-ui/package-lock.json new file mode 100644 index 0000000..b411a98 --- /dev/null +++ b/DorkNet.Server/admin-ui/package-lock.json @@ -0,0 +1,2788 @@ +{ + "name": "dorknet-admin-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dorknet-admin-ui", + "version": "0.1.0", + "dependencies": { + "@zip.js/zip.js": "^2.7.52", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0" + }, + "devDependencies": { + "@types/node": "^22.9.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.26", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", + "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.356", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz", + "integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/DorkNet.Server/admin-ui/package.json b/DorkNet.Server/admin-ui/package.json new file mode 100644 index 0000000..450638b --- /dev/null +++ b/DorkNet.Server/admin-ui/package.json @@ -0,0 +1,29 @@ +{ + "name": "dorknet-admin-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "@zip.js/zip.js": "^2.7.52", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0" + }, + "devDependencies": { + "@types/node": "^22.9.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/DorkNet.Server/admin-ui/postcss.config.js b/DorkNet.Server/admin-ui/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/DorkNet.Server/admin-ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/DorkNet.Server/admin-ui/src/App.tsx b/DorkNet.Server/admin-ui/src/App.tsx new file mode 100644 index 0000000..88679fb --- /dev/null +++ b/DorkNet.Server/admin-ui/src/App.tsx @@ -0,0 +1,64 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { Layout } from './components/Layout'; +import { RequireAuth } from './components/RequireAuth'; +import { ToastProvider } from './components/Toast'; +import { Login } from './pages/Login'; +import { Dashboard } from './pages/Dashboard'; +import { Players } from './pages/Players'; +import { Bans } from './pages/Bans'; +import { Reports } from './pages/Reports'; +import { Audit } from './pages/Audit'; +import { PlayerLogs } from './pages/PlayerLogs'; +import { Rooms } from './pages/Rooms'; +import { Room } from './pages/Room'; +import { ImportRoom } from './pages/ImportRoom'; +import { LegacyImportRoom } from './pages/LegacyImportRoom'; +import { Store } from './pages/Store'; +import { Broadcast } from './pages/Broadcast'; +import { CommunityBoard } from './pages/CommunityBoard'; +import { LoadingTips } from './pages/LoadingTips'; +import { Gift } from './pages/Gift'; +import { Passwords } from './pages/Passwords'; +import { Grants } from './pages/Grants'; +import { Storage } from './pages/Storage'; +import { Settings } from './pages/Settings'; + +export default function App() { + return ( + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Back-compat: the merge left RR-Originals / Instances / + Leaderboards / old per-room pages folded into /rooms and + /rooms/:id (leaderboards tab inside the per-room view). + Anyone with a stale bookmark hitting those old paths + lands back on the unified rooms list. */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + ); +} diff --git a/DorkNet.Server/admin-ui/src/components/Confirm.tsx b/DorkNet.Server/admin-ui/src/components/Confirm.tsx new file mode 100644 index 0000000..350d1cc --- /dev/null +++ b/DorkNet.Server/admin-ui/src/components/Confirm.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { Modal } from './Modal'; + +interface Props { + title: string; + body: React.ReactNode; + confirmLabel?: string; + destructive?: boolean; + open: boolean; + onClose: () => void; + onConfirm: () => Promise | void; +} + +export function Confirm({ title, body, confirmLabel = 'Confirm', destructive, open, onClose, onConfirm }: Props) { + const [busy, setBusy] = useState(false); + const run = async () => { + setBusy(true); + try { await onConfirm(); onClose(); } + finally { setBusy(false); } + }; + return ( + + + + } + > +
{body}
+
+ ); +} diff --git a/DorkNet.Server/admin-ui/src/components/Empty.tsx b/DorkNet.Server/admin-ui/src/components/Empty.tsx new file mode 100644 index 0000000..c3d76af --- /dev/null +++ b/DorkNet.Server/admin-ui/src/components/Empty.tsx @@ -0,0 +1,19 @@ +// Tiny stand-in for "nothing matches your filter / nothing exists yet" +// table-empty states. Centered, muted, optional CTA. + +interface Props { + title?: string; + blurb?: string; + cta?: React.ReactNode; + className?: string; +} + +export function Empty({ title = 'Nothing here', blurb, cta, className }: Props) { + return ( +
+
{title}
+ {blurb &&
{blurb}
} + {cta &&
{cta}
} +
+ ); +} diff --git a/DorkNet.Server/admin-ui/src/components/Icons.tsx b/DorkNet.Server/admin-ui/src/components/Icons.tsx new file mode 100644 index 0000000..75940af --- /dev/null +++ b/DorkNet.Server/admin-ui/src/components/Icons.tsx @@ -0,0 +1,100 @@ +// Inline SVG icons. Keeping them inline avoids a lucide-react / heroicons +// dependency for the dozen glyphs the sidebar needs. All icons take a +// className override so the parent's text color cascades via currentColor. + +type IconProps = { className?: string }; +const base = 'size-4 shrink-0 stroke-current'; + +const Svg = ({ children, className }: IconProps & { children: React.ReactNode }) => ( + +); + +export const LayoutDashboard = (p: IconProps) => ( + +); +export const Users = (p: IconProps) => ( + +); +export const Building2 = (p: IconProps) => ( + +); +export const Ban = (p: IconProps) => ( + +); +export const Flag = (p: IconProps) => ( + +); +export const ScrollText = (p: IconProps) => ( + +); +export const Activity = (p: IconProps) => ( + +); +export const Megaphone = (p: IconProps) => ( + +); +export const Upload = (p: IconProps) => ( + +); +export const ShoppingBag = (p: IconProps) => ( + +); +export const Server = (p: IconProps) => ( + +); +export const Gift = (p: IconProps) => ( + +); +export const KeyRound = (p: IconProps) => ( + +); +export const Wrench = (p: IconProps) => ( + +); +export const MessageSquare = (p: IconProps) => ( + +); +export const LogOut = (p: IconProps) => ( + +); +export const Menu = (p: IconProps) => ( + +); +export const X = (p: IconProps) => ( + +); +export const Search = (p: IconProps) => ( + +); +export const Plus = (p: IconProps) => ( + +); +export const Trash = (p: IconProps) => ( + +); +export const RefreshCw = (p: IconProps) => ( + +); +export const HardDrive = (p: IconProps) => ( + +); +export const Settings = (p: IconProps) => ( + +); +export const ArrowLeft = (p: IconProps) => ( + +); +export const Trophy = (p: IconProps) => ( + +); diff --git a/DorkNet.Server/admin-ui/src/components/Layout.tsx b/DorkNet.Server/admin-ui/src/components/Layout.tsx new file mode 100644 index 0000000..7776b7f --- /dev/null +++ b/DorkNet.Server/admin-ui/src/components/Layout.tsx @@ -0,0 +1,63 @@ +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { useEffect, useState, useSyncExternalStore } from 'react'; +import { Sidebar } from './Sidebar'; +import { LogOut, Menu } from './Icons'; +import { clearSession, getMe, notifyAuthChange, subscribeAuth } from '../lib/auth'; + +export function Layout() { + const navigate = useNavigate(); + const location = useLocation(); + const me = useSyncExternalStore(subscribeAuth, getMe, () => null); + const [drawerOpen, setDrawerOpen] = useState(false); + + // Defensive: close the drawer on route change in case the user opens + // it then navigates via something other than the sidebar links + // (e.g. the legacy importer link in PageHeader actions). The drawer + // also closes via NavLink onClick → onNavigate, so this is a belt. + useEffect(() => { setDrawerOpen(false); }, [location.pathname]); + + const logout = () => { + clearSession(); + notifyAuthChange(); + navigate('/login', { replace: true }); + }; + + return ( +
+ setDrawerOpen(false)} + onNavigate={() => setDrawerOpen(false)} + /> +
+
+ +
+ {/* Tighter copy on small screens — the "Signed in as" prefix + eats too much width on a phone. Tablet+ shows the full + phrase. */} + Signed in as + + {me?.displayName ?? me?.username ?? `#${me?.id ?? '?'}`} + +
+ +
+
+ +
+
+
+ ); +} diff --git a/DorkNet.Server/admin-ui/src/components/Modal.tsx b/DorkNet.Server/admin-ui/src/components/Modal.tsx new file mode 100644 index 0000000..df92e6b --- /dev/null +++ b/DorkNet.Server/admin-ui/src/components/Modal.tsx @@ -0,0 +1,69 @@ +import { useEffect } from 'react'; + +interface Props { + title: string; + open: boolean; + onClose: () => void; + children: React.ReactNode; + // Wider for dense edit forms (e.g. player detail with multiple sections). + size?: 'sm' | 'md' | 'lg' | 'xl'; + // Sticky action bar at the bottom — pass buttons here so the form + // body can scroll but the actions stay visible on small screens. + footer?: React.ReactNode; +} + +export function Modal({ title, open, onClose, children, size = 'md', footer }: Props) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', onKey); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = prevOverflow; + }; + }, [open, onClose]); + + if (!open) return null; + const sizeClass = { + sm: 'max-w-sm', + md: 'max-w-lg', + lg: 'max-w-2xl', + xl: 'max-w-4xl', + }[size]; + + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+
+ {children} +
+ {footer && ( +
+ {footer} +
+ )} +
+
+ ); +} diff --git a/DorkNet.Server/admin-ui/src/components/PageHeader.tsx b/DorkNet.Server/admin-ui/src/components/PageHeader.tsx new file mode 100644 index 0000000..9b18b47 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/components/PageHeader.tsx @@ -0,0 +1,19 @@ +interface Props { + title: string; + blurb?: string; + actions?: React.ReactNode; +} + +// Consistent page header: title + optional one-line blurb + a slot +// for primary actions (refresh, new, etc.) aligned to the right. +export function PageHeader({ title, blurb, actions }: Props) { + return ( +
+
+

{title}

+ {blurb &&

{blurb}

} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/DorkNet.Server/admin-ui/src/components/PlayerAvatar.tsx b/DorkNet.Server/admin-ui/src/components/PlayerAvatar.tsx new file mode 100644 index 0000000..57ac5e0 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/components/PlayerAvatar.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { profileImageUrl } from '../lib/types'; + +// Round avatar tile that falls back to the player's display-name +// initial if there's no ProfileImageName or the request 404s. The +// img.* CDN signs every response, so we don't have to do anything +// special on this side — just point at the URL and let the browser +// render or fail to render. Size in pixels controls both the rendered +// box and the `?width=N` hint we send so the CDN can resize. +export function PlayerAvatar({ + name, + displayName, + size = 28, + className = '', +}: { + name?: string | null; + displayName?: string | null; + size?: number; + className?: string; +}) { + const [errored, setErrored] = useState(false); + const url = errored ? null : profileImageUrl(name, Math.max(size * 2, 64)); + const initial = (displayName ?? '?').charAt(0).toUpperCase(); + const dim = { width: size, height: size }; + + if (!url) { + return ( +
+ {initial} +
+ ); + } + return ( + setErrored(true)} + alt={displayName ? `${displayName}'s avatar` : 'player avatar'} + className={`shrink-0 rounded-full bg-ink-800 object-cover ${className}`} + loading="lazy" + /> + ); +} diff --git a/DorkNet.Server/admin-ui/src/components/RequireAuth.tsx b/DorkNet.Server/admin-ui/src/components/RequireAuth.tsx new file mode 100644 index 0000000..f039eba --- /dev/null +++ b/DorkNet.Server/admin-ui/src/components/RequireAuth.tsx @@ -0,0 +1,13 @@ +import { useSyncExternalStore } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { getToken, subscribeAuth } from '../lib/auth'; + +// Gate every authenticated route. We re-render on any auth state +// change so a 401 from `api()` (which clears the token and pings +// listeners) bounces the user out of the dashboard mid-session. +export function RequireAuth({ children }: { children: React.ReactNode }) { + const token = useSyncExternalStore(subscribeAuth, getToken, () => null); + const location = useLocation(); + if (!token) return ; + return <>{children}; +} diff --git a/DorkNet.Server/admin-ui/src/components/Sidebar.tsx b/DorkNet.Server/admin-ui/src/components/Sidebar.tsx new file mode 100644 index 0000000..8786244 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/components/Sidebar.tsx @@ -0,0 +1,147 @@ +import { useEffect } from 'react'; +import { NavLink } from 'react-router-dom'; +import { + LayoutDashboard, Users, Building2, Ban, Flag, ScrollText, Activity, + Megaphone, Upload, ShoppingBag, Gift, KeyRound, Wrench, MessageSquare, HardDrive, Settings, X, +} from './Icons'; + +const groups: Array<{ title: string; items: Array<{ to: string; label: string; icon: React.ReactNode; new?: boolean }>}> = [ + { + title: 'Overview', + items: [ + { to: '/', label: 'Dashboard', icon: }, + ], + }, + { + title: 'People', + items: [ + { to: '/players', label: 'Players', icon: }, + { to: '/bans', label: 'Bans', icon: }, + { to: '/reports', label: 'Reports', icon: }, + { to: '/audit', label: 'Audit log', icon: }, + { to: '/logs', label: 'Player logs', icon: }, + ], + }, + { + title: 'Content', + items: [ + // Rooms is the unified view — clicking a row opens /rooms/:id + // which carries the old RR-Originals edit modal, live Photon + // instance list, recent visitors, ownership management, and + // per-room leaderboard channels in one tabbed page. + { to: '/rooms', label: 'Rooms', icon: }, + { to: '/import-room', label: 'Import room', icon: }, + { to: '/store', label: 'Store catalog', icon: }, + { to: '/community', label: 'Community board', icon: }, + { to: '/loading-tips', label: 'Loading screen tips', icon: }, + ], + }, + { + title: 'Tools', + items: [ + { to: '/broadcast', label: 'Broadcast', icon: }, + { to: '/gift', label: 'Gift items', icon: , new: true }, + { to: '/passwords', label: 'Reset passwords', icon: , new: true }, + { to: '/grants', label: 'Grant maker pen', icon: , new: true }, + { to: '/storage', label: 'Storage backfill', icon: , new: true }, + { to: '/settings', label: 'Server settings', icon: , new: true }, + ], + }, +]; + +// Two-mode rendering: +// * Desktop (md+): always-visible static rail to the left of
. +// * Mobile: hidden by default; opens as a slide-in drawer with a +// backdrop scrim, controlled by `open` from . Tapping any +// nav link calls `onNavigate()` so the drawer closes automatically +// after the user picks a destination. Escape also closes. +export function Sidebar({ open, onClose, onNavigate }: { open: boolean; onClose: () => void; onNavigate: () => void }) { + // Lock body scroll while the mobile drawer is up so the page behind + // doesn't scroll through the backdrop. Skip on desktop where `open` + // is irrelevant (the sidebar is always there). + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', onKey); + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = prev; + }; + }, [open, onClose]); + + const inner = ( + <> +
+
+
D
+
+
DorkNet
+
admin
+
+
+ +
+ + + ); + + return ( + <> + {/* Mobile drawer + backdrop */} +
+
+
+ + {/* Desktop static rail */} + + + ); +} diff --git a/DorkNet.Server/admin-ui/src/components/Toast.tsx b/DorkNet.Server/admin-ui/src/components/Toast.tsx new file mode 100644 index 0000000..241ffd6 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/components/Toast.tsx @@ -0,0 +1,50 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; + +type Kind = 'info' | 'success' | 'error'; +interface ToastMsg { id: number; kind: Kind; text: string } + +interface Ctx { push: (text: string, kind?: Kind) => void } +const ToastCtx = createContext(null); + +export function useToast(): Ctx { + const ctx = useContext(ToastCtx); + if (!ctx) throw new Error('useToast must be used inside '); + return ctx; +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [items, setItems] = useState([]); + const push = useCallback((text: string, kind: Kind = 'info') => { + const id = Date.now() + Math.random(); + setItems(prev => [...prev, { id, kind, text }]); + setTimeout(() => setItems(prev => prev.filter(t => t.id !== id)), 4000); + }, []); + + return ( + + {children} +
+ {items.map(t => ( + + ))} +
+
+ ); +} + +function ToastItem({ msg }: { msg: ToastMsg }) { + const [open, setOpen] = useState(false); + useEffect(() => { requestAnimationFrame(() => setOpen(true)); }, []); + const colors = + msg.kind === 'success' ? 'bg-success/15 text-success border-success/30' + : msg.kind === 'error' ? 'bg-danger/15 text-danger border-danger/30' + : 'bg-ink-800 text-ink-100 border-ink-700'; + return ( +
+ {msg.text} +
+ ); +} diff --git a/DorkNet.Server/admin-ui/src/index.css b/DorkNet.Server/admin-ui/src/index.css new file mode 100644 index 0000000..0fe4169 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/index.css @@ -0,0 +1,50 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html, body, #root { height: 100%; } + body { + font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + } + ::selection { background-color: theme('colors.brand.500'); color: white; } +} + +@layer components { + .card { + @apply rounded-xl border border-ink-800 bg-ink-900 shadow-card; + } + .btn { + @apply inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2 text-sm font-medium + transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500/40 focus:ring-offset-2 focus:ring-offset-ink-950 + disabled:cursor-not-allowed disabled:opacity-50; + } + .btn-primary { @apply btn bg-brand-500 text-white hover:bg-brand-400 active:bg-brand-600; } + .btn-secondary{ @apply btn bg-ink-800 text-ink-100 hover:bg-ink-700 border border-ink-700; } + .btn-danger { @apply btn bg-danger text-white hover:bg-danger/90; } + .btn-ghost { @apply btn bg-transparent text-ink-200 hover:bg-ink-800; } + .input { + @apply w-full rounded-lg border border-ink-700 bg-ink-900 px-3 py-2 text-sm text-ink-100 + placeholder:text-ink-400 focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/30; + } + .label { + @apply text-xs font-medium uppercase tracking-wide text-ink-300; + } + .badge { + @apply inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider; + } + .badge-online { @apply badge bg-success/15 text-success; } + .badge-banned { @apply badge bg-danger/15 text-danger; } + .badge-admin { @apply badge bg-brand-500/15 text-brand-200; } + .badge-junior { @apply badge bg-warn/15 text-warn; } + .badge-neutral { @apply badge bg-ink-800 text-ink-200; } + + .table-row-hover { @apply hover:bg-ink-800/60 transition-colors; } + + /* Wrap any wide data table in
so it + scrolls horizontally on narrow viewports instead of forcing the + whole layout wider than the screen. Pair with `whitespace-nowrap` + on cells that should never wrap (usernames, timestamps). */ + .table-scroll { @apply overflow-x-auto -mx-px; } +} diff --git a/DorkNet.Server/admin-ui/src/lib/api.ts b/DorkNet.Server/admin-ui/src/lib/api.ts new file mode 100644 index 0000000..d721d56 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/lib/api.ts @@ -0,0 +1,288 @@ +import { clearSession, getToken, notifyAuthChange } from './auth'; + +const API_BASE = '/api/admin/v1'; + +export class ApiError extends Error { + constructor(public status: number, message: string, public body?: unknown) { + super(message); + this.name = 'ApiError'; + } +} + +export interface ApiOptions extends Omit { + body?: unknown; + timeoutMs?: number; + // For multipart/form-data uploads — pass the FormData and skip JSON encoding. + formData?: FormData; +} + +export async function api(path: string, opts: ApiOptions = {}): Promise { + const headers = new Headers(opts.headers ?? {}); + const token = getToken(); + if (token) headers.set('Authorization', `Bearer ${token}`); + + let body: BodyInit | undefined; + if (opts.formData) { + body = opts.formData; + } else if (opts.body !== undefined && opts.body !== null) { + if (typeof opts.body === 'string') { + body = opts.body; + } else { + headers.set('Content-Type', 'application/json'); + body = JSON.stringify(opts.body); + } + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 15000); + let res: Response; + try { + res = await fetch(API_BASE + path, { + ...opts, + headers, + body, + signal: controller.signal, + }); + } catch (err) { + if ((err as Error).name === 'AbortError') { + throw new ApiError(0, `request timed out (${opts.timeoutMs ?? 15000}ms)`); + } + throw err; + } finally { + clearTimeout(timer); + } + + if (res.status === 401) { + clearSession(); + notifyAuthChange(); + throw new ApiError(401, 'Unauthorized — sign in again'); + } + if (!res.ok) { + let message = `${res.status} ${res.statusText}`; + let bodyJson: unknown; + try { + bodyJson = await res.json(); + const err = bodyJson as { error?: string; error_description?: string; message?: string }; + if (err?.error) message = err.error + (err.error_description ? ` (${err.error_description})` : ''); + else if (err?.message) message = err.message; + } catch { /* leave message as status */ } + throw new ApiError(res.status, message, bodyJson); + } + + if (res.status === 204) return undefined as T; + const ct = res.headers.get('content-type') ?? ''; + if (ct.includes('application/json')) { + const raw = await res.json(); + return camelizeKeys(raw) as T; + } + return (await res.text()) as T; +} + +// Server responses are PascalCase because Program.cs sets +// PropertyNamingPolicy=null project-wide (the old Rec Room watch +// depends on exact wire keys). The admin SPA is the only place we +// control both ends, so we camelCase on ingress so TS code stays +// idiomatic. Outbound POST bodies don't need the inverse — ASP.NET +// Core's JSON binder has PropertyNameCaseInsensitive=true by default, +// so camelCase request fields bind to PascalCase record properties +// fine. +function camelizeKey(s: string): string { + if (s.length === 0) return s; + // Leave already-lower (OAuth-style snake_case keys like access_token) + // and single-character keys alone — only flip the leading capital. + return s[0] === s[0].toLowerCase() ? s : s[0].toLowerCase() + s.slice(1); +} + +function camelizeKeys(input: unknown): unknown { + if (Array.isArray(input)) return input.map(camelizeKeys); + if (input !== null && typeof input === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(input as Record)) { + out[camelizeKey(k)] = camelizeKeys(v); + } + return out; + } + return input; +} + +// Convenience shorthands so callers don't have to spell out the options object. +export const get = (path: string) => api(path); +export const post = (path: string, body?: unknown) => api(path, { method: 'POST', body }); +export const put = (path: string, body?: unknown) => api(path, { method: 'PUT', body }); +export const del = (path: string, body?: unknown) => api(path, { method: 'DELETE', body }); + +// Upload progress callback fires repeatedly during the request body +// send (request bytes), then once with {total, loaded: total} just +// before the server starts processing. fetch() doesn't expose request +// progress at all, so we fall back to XHR for this one helper. +export interface UploadProgress { loaded: number; total: number; percent: number } + +// Chunk size for chunked uploads. Cloudflare's free/pro plans cap +// request bodies at 100 MB end-to-end (Tunnel inherits the same +// limit), so any chunk we send has to stay below that. 50 MB leaves +// generous headroom for the multipart envelope, TLS framing, and +// HTTP/2 stream buffers. +export const ZIP_CHUNK_BYTES = 50 * 1024 * 1024; + +export async function uploadWithProgress( + path: string, + formData: FormData, + onProgress?: (p: UploadProgress) => void, + timeoutMs = 30 * 60_000, +): Promise { + const token = getToken(); + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/admin/v1' + path, true); + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.timeout = timeoutMs; + xhr.responseType = 'text'; + + xhr.upload.onprogress = (e) => { + if (!onProgress) return; + const total = e.lengthComputable ? e.total : 0; + const percent = total > 0 ? Math.round((e.loaded / total) * 100) : 0; + onProgress({ loaded: e.loaded, total, percent }); + }; + + xhr.onload = () => { + if (xhr.status === 401) { + clearSession(); + notifyAuthChange(); + reject(new ApiError(401, 'Unauthorized — sign in again')); + return; + } + if (xhr.status < 200 || xhr.status >= 300) { + let msg = `${xhr.status} ${xhr.statusText}`; + try { + const body = JSON.parse(xhr.responseText) as { error?: string; message?: string }; + if (body?.error) msg = body.error; + else if (body?.message) msg = body.message; + } catch { /* keep status text */ } + reject(new ApiError(xhr.status, msg)); + return; + } + // Parse response with the same camelize behavior as fetch path. + try { + const ct = xhr.getResponseHeader('content-type') ?? ''; + if (ct.includes('application/json') && xhr.responseText) { + const raw = JSON.parse(xhr.responseText); + resolve(camelizeKeys(raw) as T); + } else { + resolve(xhr.responseText as T); + } + } catch (e) { + reject(e); + } + }; + + xhr.onerror = () => reject(new ApiError(0, 'network error')); + xhr.ontimeout = () => reject(new ApiError(0, `request timed out (${timeoutMs}ms)`)); + + xhr.send(formData); + }); +} + +// Chunked upload for the zip importer. Splits a File into +// ZIP_CHUNK_BYTES-sized pieces with Blob.slice(), POSTs each piece's +// raw bytes to /zip-upload-chunk?offset=N (no multipart wrapper — +// keeps the bytes-on-wire identical to the in-memory slice), then +// calls /zip-upload-finalize once everything's uploaded. Progress +// callback fires per chunk-completion as well as during each chunk's +// upload phase, so the UI bar advances smoothly through a multi-GB +// archive instead of stepping in 50 MB jumps. +export interface ZipImportOptions { + /// Top-level archive folders (e.g. "Rooms/Room_3163127_YarrHarrHeist_…" + /// or "Inventions/Invention_…") to actually import. Leave undefined + /// or empty to import everything in the archive — the SPA's preview + /// builds these lists from the room/invention checkboxes. + selectedRoomFolders?: string[]; + selectedInventionFolders?: string[]; + /// Round-trip the imported .room blobs through the 2020 protobuf + /// schema before persisting. Defaults off because the re-encode + /// currently crashes the watch on load; the SPA exposes the toggle + /// in the importer's options panel for cases where the admin wants + /// to opt back in. + normalizeBlobs?: boolean; +} + +export async function uploadZipInChunks( + file: File, + creatorPlayerId: number, + onProgress: (p: UploadProgress) => void, + timeoutMs = 30 * 60_000, + options: ZipImportOptions = {}, +): Promise { + // 1. Init — server allocates a temp file and hands back an uploadId. + const init = await api<{ uploadId: string }>('/rooms/zip-upload-init', { + method: 'POST', + body: { FileName: file.name, TotalBytes: file.size }, + }); + const uploadId = init.uploadId; + + try { + let uploadedSoFar = 0; + for (let offset = 0; offset < file.size; offset += ZIP_CHUNK_BYTES) { + const end = Math.min(offset + ZIP_CHUNK_BYTES, file.size); + const chunk = file.slice(offset, end); + const chunkStart = offset; + const chunkSize = end - offset; + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const token = getToken(); + xhr.open('POST', `/api/admin/v1/rooms/zip-upload-chunk/${uploadId}?offset=${chunkStart}`, true); + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.timeout = timeoutMs; + xhr.responseType = 'text'; + + xhr.upload.onprogress = (e) => { + const loaded = uploadedSoFar + (e.lengthComputable ? e.loaded : 0); + const percent = file.size > 0 ? Math.round((loaded / file.size) * 100) : 0; + onProgress({ loaded, total: file.size, percent }); + }; + xhr.onload = () => { + if (xhr.status === 401) { + clearSession(); notifyAuthChange(); + reject(new ApiError(401, 'Unauthorized — sign in again')); + return; + } + if (xhr.status < 200 || xhr.status >= 300) { + let msg = `${xhr.status} ${xhr.statusText}`; + try { + const body = JSON.parse(xhr.responseText) as { error?: string; message?: string }; + if (body?.error) msg = body.error; + else if (body?.message) msg = body.message; + } catch { /* keep status */ } + reject(new ApiError(xhr.status, msg)); + return; + } + uploadedSoFar += chunkSize; + onProgress({ loaded: uploadedSoFar, total: file.size, percent: Math.round((uploadedSoFar / file.size) * 100) }); + resolve(); + }; + xhr.onerror = () => reject(new ApiError(0, `chunk @${chunkStart} network error`)); + xhr.ontimeout = () => reject(new ApiError(0, `chunk @${chunkStart} timed out`)); + xhr.send(chunk); + }); + } + + // 2. Finalize — server runs the existing import on the assembled file. + return await api(`/rooms/zip-upload-finalize/${uploadId}`, { + method: 'POST', + body: { + CreatorPlayerId: creatorPlayerId, + SelectedRoomFolders: options.selectedRoomFolders ?? null, + SelectedInventionFolders: options.selectedInventionFolders ?? null, + NormalizeBlobs: options.normalizeBlobs ?? false, + }, + timeoutMs, + }); + } catch (err) { + // Best-effort cleanup of the temp file on the server. The session + // also auto-expires after 2 hours, so a missed abort isn't fatal. + try { await api(`/rooms/zip-upload-abort/${uploadId}`, { method: 'DELETE' }); } catch { /* swallow */ } + throw err; + } +} diff --git a/DorkNet.Server/admin-ui/src/lib/auth.ts b/DorkNet.Server/admin-ui/src/lib/auth.ts new file mode 100644 index 0000000..3ee6671 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/lib/auth.ts @@ -0,0 +1,58 @@ +// Token + identity storage. The server's same-origin AdminLoginController +// hands back an access_token plus a small identity blob; we stash both +// in localStorage and re-hydrate on page load so a refresh doesn't kick +// the admin back to the login screen. + +const TOKEN_KEY = 'dorknet.admin.token'; +const ME_KEY = 'dorknet.admin.me'; + +export interface AdminMe { + id: number; + username: string; + displayName: string; +} + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +// getMe is read on every render by useSyncExternalStore. Re-parsing +// localStorage each call would return a fresh object reference even +// when the underlying data hasn't changed, which React detects as a +// state change and re-renders forever (React error #185). Cache by +// the raw string so consecutive identical reads return the same +// AdminMe instance and the snapshot stays stable. +let cachedMeRaw: string | null | undefined; +let cachedMe: AdminMe | null = null; +export function getMe(): AdminMe | null { + const raw = localStorage.getItem(ME_KEY); + if (raw === cachedMeRaw) return cachedMe; + cachedMeRaw = raw; + if (!raw) { cachedMe = null; return null; } + try { cachedMe = JSON.parse(raw) as AdminMe; } + catch { cachedMe = null; } + return cachedMe; +} + +export function setSession(token: string, me: AdminMe): void { + localStorage.setItem(TOKEN_KEY, token); + localStorage.setItem(ME_KEY, JSON.stringify(me)); +} + +export function clearSession(): void { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(ME_KEY); +} + +// Simple subscription so components can react to login/logout without +// pulling in a state lib. Anything that calls getToken() in render and +// wants live updates can useSyncExternalStore against this. +type Listener = () => void; +const listeners = new Set(); +export function subscribeAuth(fn: Listener): () => void { + listeners.add(fn); + return () => { listeners.delete(fn); }; +} +export function notifyAuthChange(): void { + listeners.forEach(fn => fn()); +} diff --git a/DorkNet.Server/admin-ui/src/lib/browser.ts b/DorkNet.Server/admin-ui/src/lib/browser.ts new file mode 100644 index 0000000..e1b544e --- /dev/null +++ b/DorkNet.Server/admin-ui/src/lib/browser.ts @@ -0,0 +1,27 @@ +// Chromium gate. The zip importer uploads multi-hundred-megabyte +// FormData bodies and uses File / Blob slicing patterns that are +// effectively only stable on Chromium right now: +// * Firefox's xhr.upload.progress + multipart streaming has buffer +// issues over ~500 MB. +// * Safari has a long-standing bug where the FormData clone for +// large bodies hits a memory ceiling and aborts mid-upload. +// +// Easier to gate the importer than to chase per-browser workarounds. +// Other admin pages don't have this constraint, so the warning only +// shows on the import flow. + +interface NavigatorWithUaData extends Navigator { + userAgentData?: { brands: Array<{ brand: string; version: string }> }; +} + +export function isChromium(): boolean { + if (typeof navigator === 'undefined') return true; // SSR / tests + // userAgentData is currently only implemented in Chromium-based + // browsers — Firefox/Safari haven't shipped it. If it's present, + // we're on Chromium. + if ((navigator as NavigatorWithUaData).userAgentData) return true; + // Fallback UA sniff for Chromium-derived browsers that haven't + // exposed userAgentData yet (e.g. older Edge/Brave builds). + const ua = navigator.userAgent; + return /Chrome\/|Chromium\/|Edg\//.test(ua) && !/Firefox\/|FxiOS\//.test(ua); +} diff --git a/DorkNet.Server/admin-ui/src/lib/format.ts b/DorkNet.Server/admin-ui/src/lib/format.ts new file mode 100644 index 0000000..abf4351 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/lib/format.ts @@ -0,0 +1,59 @@ +// Display helpers used across pages. Kept here so we render dates, +// durations, and numeric quantities the same way everywhere — a player +// table and a player detail page should not disagree about how to +// format "5 days ago". + +export function relativeTime(iso: string | null | undefined): string { + if (!iso) return '—'; + const t = new Date(iso).getTime(); + if (!Number.isFinite(t)) return '—'; + const diffSec = Math.round((Date.now() - t) / 1000); + const abs = Math.abs(diffSec); + if (abs < 45) return diffSec >= 0 ? 'just now' : 'in a moment'; + const units: Array<[number, string]> = [ + [60, 'sec'], + [60, 'min'], + [24, 'hr'], + [7, 'day'], + [4.345, 'wk'], + [12, 'mo'], + [Infinity, 'yr'], + ]; + let v = abs; + let label = 'sec'; + for (let i = 0; i < units.length; i++) { + const [step, name] = units[i]; + if (v < step) { label = name; break; } + v = v / step; + label = units[i + 1]?.[1] ?? 'yr'; + } + const rounded = v >= 10 ? Math.round(v) : Math.round(v * 10) / 10; + return diffSec >= 0 ? `${rounded} ${label} ago` : `in ${rounded} ${label}`; +} + +export function absoluteTime(iso: string | null | undefined): string { + if (!iso) return '—'; + const d = new Date(iso); + if (!Number.isFinite(d.getTime())) return '—'; + return d.toLocaleString(); +} + +export function num(n: number | null | undefined): string { + if (n === null || n === undefined || !Number.isFinite(n)) return '—'; + return n.toLocaleString(); +} + +export function clip(s: string | null | undefined, max = 80): string { + if (!s) return ''; + return s.length <= max ? s : s.slice(0, max - 1) + '…'; +} + +// Currency type IDs follow the canonical Rec Room enum: +// 1 = Tokens, 2 = Coins (RecRoom currency). +export function currencyName(type: number): string { + switch (type) { + case 1: return 'Tokens'; + case 2: return 'Coins'; + default: return `Currency #${type}`; + } +} diff --git a/DorkNet.Server/admin-ui/src/lib/types.ts b/DorkNet.Server/admin-ui/src/lib/types.ts new file mode 100644 index 0000000..92d003d --- /dev/null +++ b/DorkNet.Server/admin-ui/src/lib/types.ts @@ -0,0 +1,241 @@ +// Wire shapes returned by AdminController. Mirrors the anonymous-object +// projections in DorkNet.Server/Controllers/Admin/AdminController.cs. +// Loosely typed where the server returns dynamic structures; tightened +// to specific fields where we render columns. + +export interface Player { + id: number; + username: string; + displayName: string; + email: string | null; + isAdmin: boolean; + isDeveloper: boolean; + isCommunityTeam: boolean; + isVerified: boolean; + isJunior: boolean; + bannedUntil: string | null; + lastIp: string | null; + lastSeenAt: string | null; + createdAt: string; + level: number; + xp: number; + profileImageName: string | null; + online: boolean; +} + +export interface PlayerDetail extends Player { + bio: string | null; + balances: Array<{ currencyType: number; balance: number }>; + avatar: { + outfitSelections: string | null; + hairColor: string | null; + skinColor: string | null; + faceFeatures: string | null; + } | null; +} + +// img.* CDN URL for a player's stored profile image — same path the +// 2020 watch uses (cropSquare=1 produces a face-zoomed square, sig +// matches the host's signing key id). +export function profileImageUrl(name: string | null | undefined, width = 96): string | null { + if (!name) return null; + const apex = typeof window !== 'undefined' && window.location.host.endsWith('localhost') ? 'localhost' : 'rec.net'; + return `https://img.${apex}/${encodeURIComponent(name)}?width=${width}&cropSquare=1&sig=p1`; +} + +export interface Room { + id: number; + name: string; + isAGRoom: boolean; + isDormRoom: boolean; + creatorPlayerId: number; + blobCount: number; +} + +// RoomRole.Role enum (server-side RoomRoleEntity.Role): +// 0 = CoOwner, 1 = Moderator, 2 = Host. +// Accepted=true ⇒ surfaces in CoOwners / Moderators / Hosts arrays. +// Accepted=false ⇒ surfaces in the Invited* counterparts (pending grant). +export interface RoomRoleGrant { + id: number; + playerId: number; + role: 0 | 1 | 2; + accepted: boolean; + grantedByPlayerId: number | null; + grantedAt: string; + player: { + id: number; + username: string; + displayName: string; + profileImageName: string | null; + }; +} + +// Per-room live Photon instance — GET /api/admin/v1/rooms/{id}/instances. +// masterPlayerId is a best-effort proxy for the Photon master client +// (server doesn't see ActorNumbers; we use dorm-creator-if-present +// else lowest-pid). The matching participant has isMaster=true so the +// UI can tag a row without recomputing. +export interface RoomInstance { + roomInstanceId: number; + roomId: number; + subRoomId: number; + roomName: string; + photonRoomId: string; + photonRegionId: string; + location: string; + maxCapacity: number; + isPrivate: boolean; + masterPlayerId: number; + participants: Array<{ + id: number; + username: string; + displayName: string; + isMaster: boolean; + }>; +} + +// Full per-room detail returned by GET /api/admin/v1/rooms/{id}. The +// admin SPA's per-room detail page consumes this in one round trip. +export interface RoomDetail { + id: number; + name: string; + description: string; + imageName: string; + state: number; + accessibility: number; + isAGRoom: boolean; + isDormRoom: boolean; + cloningAllowed: boolean; + supportsLevelVoting: boolean; + supportsVRLow: boolean; + supportsMobile: boolean; + supportsScreens: boolean; + supportsWalkVR: boolean; + supportsTeleportVR: boolean; + allowsJuniors: boolean; + disableMicAutoMute: boolean; + roomWarningMask: number; + customRoomWarning: string; + tagsCsv: string; + cheerCount: number; + favoriteCount: number; + visitCount: number; + visitorCount: number; + hotScore: number; + locationReplicationId: string; + currentDataBlobName: string; + createdAt: string; + updatedAt: string; + sceneCount: number; + blobCount: number; + owner: { + id: number; + username: string; + displayName: string; + profileImageName: string | null; + }; + roles: RoomRoleGrant[]; +} + +export interface IpBan { + id: number; + cidr: string; + reason: string; + bannedByAdminId: number; + bannedAt: string; + until: string | null; +} + +export interface AuditEntry { + id: number; + adminPlayerId: number; + action: string; + targetType: string; + targetId: number; + reason: string; + timestamp: string; +} + +export interface Report { + id: number; + reporterPlayerId: number; + targetPlayerId: number; + reason: string; + context: string; + createdAt: string; + resolvedAt: string | null; + resolverAdminId: number | null; + resolutionNote: string | null; +} + +export interface Stats { + players: { total: number; onlineNow: number; bannedNow: number }; + rooms: { + total: number; + topByVisits: Array<{ id: number; name: string; visitCount: number; visitorCount: number; cheerCount: number }>; + }; + inventions: number; + moderation: { openReports: number; activeIpBans: number }; + recentJoins: Array<{ id: number; username: string; createdAt: string }>; + serverTime: string; +} + +export interface StoreItem { + id: number; + slug: string; + displayName: string; + description: string; + category: string; + imageName: string; + currencyType: number; + price: number; + isActive: boolean; + isLimitedTime: boolean; + availableUntil: string | null; + storefront: string; + createdAt: string; + updatedAt: string; +} + +export interface StorefrontDefinition { + key: string; + storefrontType: number | null; + displayName: string; + scope: string; +} + +export interface Instance { + roomInstanceId: number; + roomId: number; + subRoomId: number; + roomName: string; + photonRoomId: string; + photonRegionId: string; + location: string; + maxCapacity: number; + isPrivate: boolean; + participants: Array<{ id: number; username: string; displayName: string }>; +} + +export interface PlayerLogEntry { + timestamp: string; + method: string; + host: string; + path: string; + query: string; + status: number; + elapsedMs: number; +} + +// Mirrors DorkNet.Server.Services.CommunityBoardState (and the +// nested DTOs in CommunityBoardService.cs). The 2020 watch decodes +// the same shape from /api/communityboard/v1/current — admins +// edit it here via /api/admin/v1/communityboard. +export interface CommunityBoardState { + currentAnnouncement: { message: string; moreInfoUrl: string } | null; + featuredPlayer: { id: number; titleOverride: string; urlOverride: string } | null; + featuredRoomGroup: { name: string; featuredRooms: number[] } | null; + instagramImages: Array<{ imageName: string; imageUrl: string }>; + videos: Array<{ blobName: string; title: string; description: string; thumbnailBlobName: string; sourceUrl: string }>; +} diff --git a/DorkNet.Server/admin-ui/src/lib/useApi.ts b/DorkNet.Server/admin-ui/src/lib/useApi.ts new file mode 100644 index 0000000..5929c90 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/lib/useApi.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api, ApiError, type ApiOptions } from './api'; + +interface State { + data: T | null; + loading: boolean; + error: string | null; +} + +// Read-side hook for "fetch on mount, expose refresh + error state". +// Not a replacement for TanStack Query — we don't need its cache / +// dedupe / focus-refetch behavior for an admin tool used by 1-2 people. +// But every page does the same useState/useEffect/abort dance, so this +// collapses it to one line. +export function useApi(path: string, opts: ApiOptions & { manual?: boolean } = {}) { + const [state, setState] = useState>({ data: null, loading: !opts.manual, error: null }); + const abortRef = useRef(null); + const { manual, ...rest } = opts; + // Stabilise opts across renders by capturing in a ref the caller can ignore. + // Anything that varies between renders (e.g. inline objects) is fine because + // we re-run on `path` change only. + const optsRef = useRef(rest); + optsRef.current = rest; + + const refresh = useCallback(async () => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setState(s => ({ ...s, loading: true, error: null })); + try { + const data = await api(path, { ...optsRef.current, signal: ctrl.signal }); + if (!ctrl.signal.aborted) setState({ data, loading: false, error: null }); + } catch (e) { + if (ctrl.signal.aborted) return; + const msg = e instanceof ApiError ? e.message : (e as Error).message; + setState({ data: null, loading: false, error: msg }); + } + }, [path]); + + useEffect(() => { + if (manual) return; + refresh(); + return () => { abortRef.current?.abort(); }; + }, [refresh, manual]); + + return { ...state, refresh }; +} diff --git a/DorkNet.Server/admin-ui/src/main.tsx b/DorkNet.Server/admin-ui/src/main.tsx new file mode 100644 index 0000000..497c11f --- /dev/null +++ b/DorkNet.Server/admin-ui/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/DorkNet.Server/admin-ui/src/pages/Audit.tsx b/DorkNet.Server/admin-ui/src/pages/Audit.tsx new file mode 100644 index 0000000..52083d9 --- /dev/null +++ b/DorkNet.Server/admin-ui/src/pages/Audit.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import type { AuditEntry } from '../lib/types'; +import { useApi } from '../lib/useApi'; +import { PageHeader } from '../components/PageHeader'; +import { Empty } from '../components/Empty'; +import { absoluteTime, clip, relativeTime } from '../lib/format'; +import { RefreshCw } from '../components/Icons'; + +export function Audit() { + const [take, setTake] = useState(200); + const { data, loading, error, refresh } = useApi(`/audit?take=${take}`); + + return ( +
+ + + + } + /> + + {error &&
{error}
} + +
+ {data && data.length === 0 && } + {data && data.length > 0 && ( +
+ + + + + + + + + + + {data.map(e => ( + + + + + + + + ))} + +
WhenAdminActionTargetReason
+ {relativeTime(e.timestamp)} + #{e.adminPlayerId} + {e.action} + + {e.targetType} + {e.targetId > 0 && <> · #{e.targetId}} + {clip(e.reason, 100)}
+ )} +
+
+ ); +} diff --git a/DorkNet.Server/admin-ui/src/pages/Bans.tsx b/DorkNet.Server/admin-ui/src/pages/Bans.tsx new file mode 100644 index 0000000..7705daa --- /dev/null +++ b/DorkNet.Server/admin-ui/src/pages/Bans.tsx @@ -0,0 +1,190 @@ +import { useState } from 'react'; +import { api } from '../lib/api'; +import type { IpBan, Player } from '../lib/types'; +import { useApi } from '../lib/useApi'; +import { PageHeader } from '../components/PageHeader'; +import { useToast } from '../components/Toast'; +import { Empty } from '../components/Empty'; +import { absoluteTime, relativeTime } from '../lib/format'; +import { RefreshCw, Trash } from '../components/Icons'; + +export function Bans() { + const [tab, setTab] = useState<'players' | 'ip'>('players'); + return ( +
+ +
+ {(['players', 'ip'] as const).map(t => ( + + ))} +
+ {tab === 'players' && } + {tab === 'ip' && } +
+ ); +} + +function PlayerBans() { + // Pull a generous page of players and filter client-side for currently-banned. + // For a small private server this is fine; if we ever scale we can add a + // dedicated /players/banned endpoint. + const { data, loading, error, refresh } = useApi('/players?take=200'); + const now = Date.now(); + const banned = (data ?? []).filter(p => p.bannedUntil && new Date(p.bannedUntil).getTime() > now); + const toast = useToast(); + + const unban = async (id: number) => { + try { + await api(`/players/${id}/unban`, { method: 'POST', body: { Reason: 'admin-unban' } }); + toast.push('Unbanned', 'success'); + refresh(); + } catch (e) { + toast.push((e as Error).message, 'error'); + } + }; + + return ( +
+
+
{banned.length} active player ban{banned.length === 1 ? '' : 's'}
+ +
+ {error &&
{error}
} + {!error && banned.length === 0 && } + {banned.length > 0 && ( +
+ + + + + + + + + {banned.map(p => ( + + + + + + + ))} + +
PlayerExpiresLast IP +
+
{p.displayName || p.username}
+
@{p.username} · #{p.id}
+
+ {relativeTime(p.bannedUntil)} ({absoluteTime(p.bannedUntil)}) + {p.lastIp ?? '—'} + +
+ )} +
+ ); +} + +function IpBans() { + const { data, loading, error, refresh } = useApi('/ipbans'); + const toast = useToast(); + const [cidr, setCidr] = useState(''); + const [reason, setReason] = useState(''); + const [days, setDays] = useState(''); + + const add = async (e: React.FormEvent) => { + e.preventDefault(); + if (!cidr.trim()) return; + try { + await api('/ipbans', { method: 'POST', body: { Cidr: cidr.trim(), Reason: reason, DurationDays: days || null } }); + setCidr(''); setReason(''); setDays(''); + toast.push('IP ban added', 'success'); + refresh(); + } catch (err) { + toast.push((err as Error).message, 'error'); + } + }; + + const remove = async (id: number) => { + if (!confirm('Remove this IP ban?')) return; + try { + await api(`/ipbans/${id}`, { method: 'DELETE' }); + toast.push('IP ban removed', 'success'); + refresh(); + } catch (err) { + toast.push((err as Error).message, 'error'); + } + }; + + const active = (b: IpBan) => !b.until || new Date(b.until).getTime() > Date.now(); + + return ( +
+
+ setCidr(e.target.value)} placeholder="1.2.3.4 or 10.0.0.0/24" required className="input" /> + setReason(e.target.value)} placeholder="Reason (logged)" className="input" /> + setDays(e.target.value === '' ? '' : parseInt(e.target.value))} + placeholder="days (∞)" + className="input" + /> + +
+ +
+
+
{data ? `${data.length} total · ${data.filter(active).length} active` : ''}
+ +
+ {error &&
{error}
} + {data && data.length === 0 && } + {data && data.length > 0 && ( +
+ + + + + + + + + + {data.map(b => ( + + + + + + + + ))} + +
CIDRReasonIssuedExpires +
{b.cidr}{b.reason || '—'}{relativeTime(b.bannedAt)} + {b.until ? {relativeTime(b.until)} : ∞} + + +
+ )} +
+
+ ); +} diff --git a/DorkNet.Server/admin-ui/src/pages/Broadcast.tsx b/DorkNet.Server/admin-ui/src/pages/Broadcast.tsx new file mode 100644 index 0000000..4ef767d --- /dev/null +++ b/DorkNet.Server/admin-ui/src/pages/Broadcast.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { api } from '../lib/api'; +import { PageHeader } from '../components/PageHeader'; +import { useToast } from '../components/Toast'; + +export function Broadcast() { + const [message, setMessage] = useState(''); + const [busy, setBusy] = useState(false); + const toast = useToast(); + + const send = async (e: React.FormEvent) => { + e.preventDefault(); + if (!message.trim()) return; + if (!confirm(`Broadcast this to every connected player?\n\n"${message}"`)) return; + setBusy(true); + try { + await api('/broadcast', { method: 'POST', body: { Message: message } }); + toast.push('Broadcast sent', 'success'); + setMessage(''); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setBusy(false); + } + }; + + return ( +
+ + +
+ + + +
+ + + + + + + +
+ + + + + diff --git a/DorkNet.Server/wwwroot/admin-legacy/style.css b/DorkNet.Server/wwwroot/admin-legacy/style.css new file mode 100644 index 0000000..beb7a99 --- /dev/null +++ b/DorkNet.Server/wwwroot/admin-legacy/style.css @@ -0,0 +1,619 @@ +@import url('https://rsms.me/inter/inter.css'); + +* { box-sizing: border-box; } + +:root { + color-scheme: dark; + + --bg: #0a0c10; + --bg-grad: radial-gradient(circle at 20% 0%, #16204a 0%, #0a0c10 60%); + --panel: rgba(22, 26, 35, 0.85); + --panel-solid: #161a23; + --panel-2: #1d2230; + --panel-3: #252b3b; + --border: #2a3145; + --border-subtle: #1f2433; + --text: #eef0f5; + --text-dim: #b3b9c9; + --muted: #7a8195; + + --accent: #7aa9ff; + --accent-2: #b48cff; + --accent-grad: linear-gradient(135deg, #7aa9ff 0%, #b48cff 100%); + --accent-hover: #93b9ff; + + --good: #4ad28b; + --good-bg: rgba(74, 210, 139, 0.12); + --warn: #ffce5b; + --warn-bg: rgba(255, 206, 91, 0.12); + --danger: #ff6f7d; + --danger-bg: rgba(255, 111, 125, 0.12); + --info-bg: rgba(122, 169, 255, 0.12); + + --radius: 10px; + --radius-lg: 14px; + --shadow-sm: 0 1px 0 rgba(255,255,255,0.05) inset, 0 2px 6px rgba(0,0,0,0.25); + --shadow: 0 1px 0 rgba(255,255,255,0.05) inset, 0 12px 32px rgba(0,0,0,0.45); + --shadow-lg: 0 1px 0 rgba(255,255,255,0.06) inset, 0 24px 60px rgba(0,0,0,0.6); + + --t: 160ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +html, body { height: 100%; } + +body { + margin: 0; + background: var(--bg); + background-image: var(--bg-grad); + background-attachment: fixed; + color: var(--text); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-feature-settings: 'cv11', 'ss01', 'ss03'; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +@supports (font-variation-settings: normal) { + body { font-family: 'Inter var', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } +} + +h1, h2, h3, h4 { margin: 0; letter-spacing: -0.01em; } +h1 { font-size: 22px; font-weight: 700; } +h2 { font-size: 17px; font-weight: 600; } +h3 { + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + margin-bottom: 10px; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-hover); } + +.muted { color: var(--muted); } +.error { + color: var(--danger); + margin-top: 12px; + min-height: 1em; + font-size: 13px; +} + +/* ── Buttons ─────────────────────────────────────────────────────── */ + +button { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + padding: 7px 14px; + border-radius: 8px; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + transition: all var(--t); + line-height: 1.2; + white-space: nowrap; +} +button:hover { background: var(--panel-3); border-color: #38415a; transform: translateY(-1px); } +button:active { transform: translateY(0); } +button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +button[type="submit"], button.primary { + background: var(--accent-grad); + border-color: transparent; + color: #0a0c10; + font-weight: 600; + box-shadow: 0 4px 14px rgba(122, 169, 255, 0.25); +} +button[type="submit"]:hover, button.primary:hover { + box-shadow: 0 6px 20px rgba(122, 169, 255, 0.4); +} + +button.danger { + background: linear-gradient(135deg, #ff6f7d 0%, #d94d6b 100%); + border-color: transparent; + color: #fff; + box-shadow: 0 4px 14px rgba(255, 111, 125, 0.2); +} +button.danger:hover { box-shadow: 0 6px 20px rgba(255, 111, 125, 0.35); } + +button.ghost { + background: transparent; + border-color: var(--border); + color: var(--text-dim); +} +button.ghost:hover { color: var(--text); border-color: var(--accent); background: var(--info-bg); } + +button.link { + background: transparent; + border: none; + color: var(--accent); + padding: 0; + text-decoration: none; + cursor: pointer; + font-size: 13px; + transition: color var(--t); +} +button.link:hover { color: var(--accent-hover); transform: none; } + +/* ── Inputs ──────────────────────────────────────────────────────── */ + +input, textarea, select { + background: var(--panel-solid); + color: var(--text); + border: 1px solid var(--border); + padding: 8px 12px; + border-radius: 8px; + font: inherit; + font-size: 13px; + transition: all var(--t); + width: 100%; +} +input::placeholder, textarea::placeholder { color: var(--muted); } +input:hover, textarea:hover, select:hover { border-color: #3a425a; } +input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(122, 169, 255, 0.15); +} +input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + margin: 0; + cursor: pointer; +} +input[type="number"] { width: 100%; } + +label { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; + color: var(--text-dim); + font-size: 12px; + font-weight: 500; +} +label > input, +label > textarea, +label > select { + color: var(--text); + font-size: 13px; + font-weight: 400; +} + +/* ── Cards ───────────────────────────────────────────────────────── */ + +.card { + background: var(--panel); + backdrop-filter: blur(20px) saturate(140%); + -webkit-backdrop-filter: blur(20px) saturate(140%); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 28px; + box-shadow: var(--shadow); +} + +/* ── Login ───────────────────────────────────────────────────────── */ + +#login { + max-width: 380px; + margin: 14vh auto; + animation: fadeUp 320ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +#login h1 { + background: var(--accent-grad); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + margin-bottom: 4px; +} +#login p { margin: 0 0 22px; } +#login button[type="submit"] { width: 100%; padding: 10px; font-size: 14px; } + +@keyframes fadeUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── App shell ───────────────────────────────────────────────────── */ + +#app { animation: fadeIn 240ms ease-out; } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 32px; + background: rgba(13, 16, 22, 0.85); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border-subtle); + position: sticky; + top: 0; + z-index: 50; +} +header h1 { + background: var(--accent-grad); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + font-size: 18px; +} +header .who { + color: var(--muted); + font-size: 13px; + display: flex; + align-items: center; + gap: 12px; +} +header #meName { + color: var(--text); + font-weight: 600; + margin: 0 4px; +} + +nav { + display: flex; + gap: 4px; + padding: 12px 32px; + background: rgba(10, 12, 16, 0.6); + border-bottom: 1px solid var(--border-subtle); + overflow-x: auto; + scrollbar-width: none; +} +nav::-webkit-scrollbar { display: none; } +nav .tab { + background: transparent; + border: 1px solid transparent; + color: var(--muted); + border-radius: 8px; + font-weight: 500; + transition: all var(--t); +} +nav .tab:hover { color: var(--text-dim); transform: none; background: var(--panel-2); } +nav .tab.active { + background: var(--info-bg); + color: var(--accent); + border-color: rgba(122, 169, 255, 0.25); +} + +.tab-panel { + padding: 28px 32px 80px; + max-width: 1280px; + margin: 0 auto; + animation: fadeIn 200ms ease-out; +} +.tab-panel > h2:first-child { margin-bottom: 20px; } + +.row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + margin: 12px 0; +} +.row > input, +.row > select { flex: 1; min-width: 140px; width: auto; } + +/* ── Dashboard stats ─────────────────────────────────────────────── */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; + margin: 0 0 32px; +} +.stat { + background: var(--panel); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + padding: 18px 20px; + transition: all var(--t); + position: relative; + overflow: hidden; +} +.stat:hover { transform: translateY(-2px); border-color: var(--border); box-shadow: var(--shadow-sm); } +.stat::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: var(--accent-grad); + opacity: 0; + transition: opacity var(--t); +} +.stat:hover::before { opacity: 1; } +.stat .label { + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; +} +.stat .value { + font-size: 28px; + font-weight: 700; + margin-top: 6px; + letter-spacing: -0.02em; + font-feature-settings: 'tnum'; +} +.stat .value.good { color: var(--good); } +.stat .value.warn { color: var(--warn); } +.stat .value.danger { color: var(--danger); } + +/* ── Tables ──────────────────────────────────────────────────────── */ + +.table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + margin: 12px 0 24px; + background: var(--panel); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + overflow: hidden; +} +.table th, .table td { + padding: 11px 16px; + text-align: left; + border-bottom: 1px solid var(--border-subtle); + font-size: 13px; + vertical-align: middle; +} +.table th { + background: rgba(13, 16, 22, 0.6); + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + position: sticky; + top: 0; +} +.table tr:last-child td { border-bottom: none; } +.table tr { transition: background var(--t); } +.table tbody tr:hover td { background: rgba(122, 169, 255, 0.04); } + +.table .actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} +.table .actions button { + padding: 4px 10px; + font-size: 12px; +} + +/* ── Badges ──────────────────────────────────────────────────────── */ + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1.4; + border: 1px solid transparent; +} +.badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.7; +} +.badge.no-dot::before { display: none; } +.badge.online { background: var(--good-bg); color: var(--good); border-color: rgba(74, 210, 139, 0.2); } +.badge.offline { background: rgba(139, 145, 158, 0.08); color: var(--muted); border-color: rgba(139, 145, 158, 0.18); } +.badge.admin { background: var(--info-bg); color: var(--accent); border-color: rgba(122, 169, 255, 0.2); } +.badge.banned { background: var(--danger-bg); color: var(--danger); border-color: rgba(255, 111, 125, 0.2); } + +.badge-row { display: inline-flex; gap: 4px; flex-wrap: wrap; } + +/* ── Modal ───────────────────────────────────────────────────────── */ + +.modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 40px 20px; + overflow-y: auto; + z-index: 100; + animation: fadeIn 160ms ease-out; +} +/* The class-level `display: flex` above out-specifies the browser + default `[hidden] { display: none }` (both are specificity 0,0,1,0 + but the class rule loads later), so without this override the + modal stays visible and shows an empty "Store item" / "Player" + shell on every page load. Force the hidden attribute to win. */ +.modal[hidden] { display: none !important; } +.modal-content { + width: 100%; + max-width: 820px; + position: relative; + margin: auto; + box-shadow: var(--shadow-lg); + animation: modalIn 200ms cubic-bezier(0.2, 0.8, 0.2, 1); + /* Override .card's translucent var(--panel) (rgba 0.85) — at + 0.85 the table behind shows through, making the modal form + look like it's overlapping page content. Force the panel + solid here so the modal reads as a discrete dialog. */ + background: var(--panel-solid) !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; +} +@keyframes modalIn { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.modal #modalTitle { + margin-bottom: 16px; + padding-right: 40px; + background: var(--accent-grad); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} +.modal .close { + position: absolute; + top: 16px; + right: 16px; + background: var(--panel-2); + border: 1px solid var(--border); + color: var(--muted); + font-size: 18px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + line-height: 1; +} +.modal .close:hover { color: var(--text); background: var(--panel-3); transform: none; } + +/* ── Sections inside modal ───────────────────────────────────────── */ + +.section { + border-top: 1px solid var(--border-subtle); + padding-top: 18px; + margin-top: 18px; +} +.section:first-child { border-top: none; padding-top: 0; margin-top: 0; } + +.kvp { + display: grid; + grid-template-columns: 130px 1fr; + gap: 8px 16px; + margin: 0; +} +.kvp .k { + color: var(--muted); + font-size: 12px; + font-weight: 500; +} +.kvp > * { padding: 4px 0; } + +/* ── Color swatches (hair / skin preview) ────────────────────────── */ + +.swatch-input { + display: flex; + gap: 8px; + align-items: stretch; +} +.swatch-input input { flex: 1; } +.swatch { + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid var(--border); + flex-shrink: 0; + background-image: + linear-gradient(45deg, #2a2f3a 25%, transparent 25%), + linear-gradient(-45deg, #2a2f3a 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #2a2f3a 75%), + linear-gradient(-45deg, transparent 75%, #2a2f3a 75%); + background-size: 8px 8px; + background-position: 0 0, 0 4px, 4px -4px, -4px 0px; +} + +/* ── Toast ───────────────────────────────────────────────────────── */ + +.toast { + position: fixed; + bottom: 24px; + right: 24px; + background: var(--panel-solid); + border: 1px solid var(--border); + color: var(--text); + padding: 12px 18px; + border-radius: 10px; + box-shadow: var(--shadow); + z-index: 200; + max-width: 360px; + font-size: 13px; + animation: toastIn 200ms cubic-bezier(0.2, 0.8, 0.2, 1); + border-left: 3px solid var(--accent); +} +.toast.error { border-left-color: var(--danger); } +.toast.good { border-left-color: var(--good); } +@keyframes toastIn { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +textarea { + width: 100%; + resize: vertical; + min-height: 80px; + font-family: 'Inter', -apple-system, sans-serif; +} +form { margin: 0; } + +/* ── Inline-checkbox helper ──────────────────────────────────────── */ + +.checkbox-label { + flex-direction: row !important; + align-items: center !important; + gap: 8px !important; + margin: 0 !important; + color: var(--text-dim); + cursor: pointer; + user-select: none; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel-solid); + transition: all var(--t); + font-size: 13px; +} +.checkbox-label:hover { border-color: var(--accent); color: var(--text); } +.checkbox-label input { width: auto; } + +/* ── Empty state ─────────────────────────────────────────────────── */ + +.empty { + text-align: center; + padding: 40px 20px; + color: var(--muted); + font-size: 13px; +} + +/* ── Scrollbar ───────────────────────────────────────────────────── */ + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; border: 2px solid var(--bg); } +::-webkit-scrollbar-thumb:hover { background: var(--panel-3); } + +/* ── Numeric figures align nicely in tables ──────────────────────── */ + +.table td:first-child, +.table td.num { font-feature-settings: 'tnum'; color: var(--muted); } + +/* ── Misc utilities ──────────────────────────────────────────────── */ + +.spacer { flex: 1; } +.right { margin-left: auto; } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } diff --git a/DorkNet.Server/wwwroot/feed/app.js b/DorkNet.Server/wwwroot/feed/app.js new file mode 100644 index 0000000..3c24440 --- /dev/null +++ b/DorkNet.Server/wwwroot/feed/app.js @@ -0,0 +1,244 @@ +// DorkNet feed — public photo gallery. Reads from same-origin +// /api/photos/v1/* (PhotosController is dual-hosted on api.rec.net and +// feed.rec.net so no CORS dance). Hash routing keeps it a single page: +// #/ → feed grid +// #/p/123 → photo detail +// #/u/456 → uploader profile + +const API = '/api/photos/v1'; + +const $ = (id) => document.getElementById(id); +const el = (tag, attrs, ...children) => { + const e = document.createElement(tag); + for (const k in attrs) { + if (k === 'class') e.className = attrs[k]; + else if (k === 'on') for (const ev in attrs.on) e.addEventListener(ev, attrs.on[ev]); + else if (k === 'html') e.innerHTML = attrs[k]; + else if (k === 'hidden' && attrs[k]) e.hidden = true; + else e[k] = attrs[k]; + } + for (const c of children.flat()) if (c != null) e.append(c?.nodeType ? c : document.createTextNode(String(c))); + return e; +}; + +async function api(path) { + const res = await fetch(API + path); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.json(); +} + +function toast(msg, kind = '') { + const t = $('toast'); + t.textContent = msg; + t.className = 'toast ' + kind; + t.hidden = false; + clearTimeout(toast._h); + toast._h = setTimeout(() => t.hidden = true, 3000); +} + +function fmtDate(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d)) return ''; + const now = new Date(); + const diff = (now - d) / 1000; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; + return d.toLocaleDateString(); +} + +function initials(name) { + if (!name) return '?'; + const parts = name.split(/[\s_]/).filter(Boolean); + return (parts[0]?.[0] || '') + (parts[1]?.[0] || ''); +} + +function showView(name) { + document.querySelectorAll('.view').forEach(v => v.hidden = v.id !== `${name}-view`); + document.querySelectorAll('.nav-link').forEach(l => l.classList.toggle('active', l.dataset.route === 'feed' && name === 'feed')); + window.scrollTo(0, 0); +} + +// ── Router ─────────────────────────────────────────────────────────── + +window.addEventListener('hashchange', route); +window.addEventListener('DOMContentLoaded', route); + +function goFeed() { location.hash = '#/'; } + +function route() { + const h = location.hash.slice(1) || '/'; + const photoMatch = h.match(/^\/p\/(\d+)$/); + const userMatch = h.match(/^\/u\/(\d+)$/); + if (photoMatch) renderPhoto(parseInt(photoMatch[1], 10)); + else if (userMatch) renderProfile(parseInt(userMatch[1], 10)); + else renderFeed(); +} + +// ── Feed view ──────────────────────────────────────────────────────── + +let feedSkip = 0; +const PAGE = 24; + +async function renderFeed() { + showView('feed'); + feedSkip = 0; + $('grid').replaceChildren(...skeletonCards(6)); + $('empty').hidden = true; + $('loadMore').hidden = true; + try { + const photos = await api(`/feed?take=${PAGE}&skip=0`); + $('grid').replaceChildren(); + if (photos.length === 0) { + $('empty').hidden = false; + return; + } + for (const p of photos) $('grid').append(photoCard(p)); + feedSkip = photos.length; + if (photos.length === PAGE) $('loadMore').hidden = false; + } catch (err) { + $('grid').replaceChildren(); + toast(err.message, 'error'); + } +} + +$('loadMoreBtn').addEventListener('click', async () => { + try { + const photos = await api(`/feed?take=${PAGE}&skip=${feedSkip}`); + for (const p of photos) $('grid').append(photoCard(p)); + feedSkip += photos.length; + if (photos.length < PAGE) $('loadMore').hidden = true; + } catch (err) { toast(err.message, 'error'); } +}); + +function skeletonCards(n) { + return Array.from({ length: n }, () => { + const card = el('div', { class: 'photo-card' }); + const h = 180 + Math.floor(Math.random() * 220); + card.append(el('div', { class: 'skeleton', style: `height: ${h}px;` })); + card.append(el('div', { class: 'photo-meta' }, + el('div', { class: 'skeleton', style: 'height: 14px; width: 60%; margin-bottom: 8px;' }), + el('div', { class: 'skeleton', style: 'height: 12px; width: 90%;' }), + )); + return card; + }); +} + +function photoCard(p) { + const card = el('a', { + class: 'photo-card', + href: `#/p/${p.Id}`, + }); + const img = el('img', { + class: 'photo-img', + src: p.ImageUrl, + alt: p.Caption || `Photo by ${p.UploaderDisplayName}`, + loading: 'lazy', + }); + img.addEventListener('error', () => { + img.style.display = 'none'; + }); + card.append(img); + card.append(el('div', { class: 'photo-meta' }, + el('div', { class: 'uploader' }, + el('div', { class: 'uploader-avatar' }, initials(p.UploaderDisplayName)), + el('span', {}, p.UploaderDisplayName), + ), + p.Caption ? el('div', { class: 'caption' }, p.Caption) : null, + el('div', { class: 'photo-foot' }, + el('span', { class: 'cheers' }, `♥ ${p.CheerCount.toLocaleString()}`), + p.RoomName ? el('span', { class: 'room' }, `^${p.RoomName}`) : el('span', { class: 'time' }, fmtDate(p.CreatedAt)), + ), + )); + return card; +} + +// ── Photo detail view ──────────────────────────────────────────────── + +async function renderPhoto(id) { + showView('photo'); + $('photoDetail').replaceChildren(el('div', { class: 'skeleton', style: 'height: 480px;' })); + try { + const p = await api(`/${id}`); + const tagged = (p.TaggedPlayerIds || []).slice(0, 8); + $('photoDetail').replaceChildren(el('div', { class: 'photo-detail' }, + el('div', { class: 'image-wrap' }, + el('img', { src: p.ImageUrl, alt: p.Caption || `Photo #${p.Id}` })), + el('div', { class: 'info' }, + el('h2', {}, `Photo #${p.Id}`), + el('div', { class: 'caption-full' }, p.Caption || ''), + el('div', { class: 'meta-row' }, + el('span', { class: 'k' }, 'By'), + el('a', { class: 'uploader-link', href: `#/u/${p.UploaderPlayerId}` }, + el('div', { class: 'uploader-avatar' }, initials(p.UploaderDisplayName)), + el('span', {}, p.UploaderDisplayName), + ), + ), + p.RoomName + ? el('div', { class: 'meta-row' }, + el('span', { class: 'k' }, 'In'), + el('span', { class: 'room-pill' }, `^${p.RoomName}`)) + : null, + el('div', { class: 'meta-row' }, + el('span', { class: 'k' }, 'Posted'), + el('span', {}, fmtDate(p.CreatedAt) + ' (' + new Date(p.CreatedAt).toLocaleString() + ')'), + ), + tagged.length + ? el('div', { class: 'meta-row' }, + el('span', { class: 'k' }, 'Tagged'), + el('span', {}, tagged.length + ' player' + (tagged.length === 1 ? '' : 's'))) + : null, + el('div', { class: 'stats' }, + el('div', { class: 'stat-pill cheers' }, + el('div', { class: 'v' }, p.CheerCount.toLocaleString()), + el('div', { class: 'l' }, 'cheers')), + el('div', { class: 'stat-pill' }, + el('div', { class: 'v' }, p.ViewCount.toLocaleString()), + el('div', { class: 'l' }, 'views')), + ), + ), + )); + } catch (err) { + $('photoDetail').replaceChildren(el('div', { class: 'empty' }, + el('p', {}, 'Photo not found.'))); + } +} + +$('backBtn').addEventListener('click', goFeed); + +// ── Profile view ───────────────────────────────────────────────────── + +async function renderProfile(playerId) { + showView('profile'); + $('profileDetail').replaceChildren(el('div', { class: 'skeleton', style: 'height: 100px;' })); + try { + const photos = await api(`/by/${playerId}?take=60`); + const uploaderName = photos[0]?.UploaderDisplayName || `Player ${playerId}`; + const uploaderUsername = photos[0]?.UploaderUsername || ''; + $('profileDetail').replaceChildren( + el('div', { class: 'profile-header' }, + el('div', { class: 'avatar' }, initials(uploaderName)), + el('div', {}, + el('h2', {}, uploaderName), + el('div', { class: 'username' }, + uploaderUsername ? `@${uploaderUsername} · ` : '', + `${photos.length} photo${photos.length === 1 ? '' : 's'}`), + ), + ), + (() => { + const grid = el('div', { class: 'grid' }); + if (photos.length === 0) + grid.append(el('div', { class: 'empty' }, + el('p', {}, 'No photos posted yet.'))); + else + for (const p of photos) grid.append(photoCard(p)); + return grid; + })(), + ); + } catch (err) { + $('profileDetail').replaceChildren(el('div', { class: 'empty' }, + el('p', {}, 'Profile not found.'))); + } +} diff --git a/DorkNet.Server/wwwroot/feed/index.html b/DorkNet.Server/wwwroot/feed/index.html new file mode 100644 index 0000000..2be46e5 --- /dev/null +++ b/DorkNet.Server/wwwroot/feed/index.html @@ -0,0 +1,55 @@ + + + + + + DorkNet — Feed + + + + + +
+
+
+

The Feed

+

Photos taken by players around the server, newest first.

+
+
+ + +
+ + + + +
+ +
+

DorkNet · a private Rec Room server · GitHub

+
+ + + + + diff --git a/DorkNet.Server/wwwroot/feed/style.css b/DorkNet.Server/wwwroot/feed/style.css new file mode 100644 index 0000000..83a1db9 --- /dev/null +++ b/DorkNet.Server/wwwroot/feed/style.css @@ -0,0 +1,448 @@ +@import url('https://rsms.me/inter/inter.css'); + +* { box-sizing: border-box; } + +:root { + color-scheme: dark; + --bg: #07080b; + --bg-grad: radial-gradient(circle at 20% -10%, #1d1748 0%, #07080b 55%); + --panel: rgba(20, 22, 30, 0.7); + --panel-2: #161922; + --panel-3: #1f2330; + --border: #262b3a; + --border-subtle: #1a1e29; + --text: #f0f2f8; + --text-dim: #b0b6c8; + --muted: #757b8e; + --accent: #ff6ec7; + --accent-2: #7aa9ff; + --accent-grad: linear-gradient(135deg, #ff6ec7 0%, #b48cff 50%, #7aa9ff 100%); + --good: #4ad28b; + --danger: #ff6f7d; + --shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + --t: 180ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +html, body { height: 100%; } +body { + margin: 0; + background: var(--bg); + background-image: var(--bg-grad); + background-attachment: fixed; + color: var(--text); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +@supports (font-variation-settings: normal) { + body { font-family: 'Inter var', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } +} + +a { color: var(--accent-2); text-decoration: none; transition: color var(--t); } +a:hover { color: #a3c4ff; } + +button { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + transition: all var(--t); +} +button:hover { background: var(--panel-3); border-color: #353c54; transform: translateY(-1px); } +button.primary { + background: var(--accent-grad); + border-color: transparent; + color: #07080b; + font-weight: 600; + box-shadow: 0 4px 18px rgba(255, 110, 199, 0.25); +} +button.primary:hover { box-shadow: 0 6px 24px rgba(255, 110, 199, 0.4); } +button.ghost { + background: transparent; + border-color: var(--border); + color: var(--text-dim); +} +button.ghost:hover { color: var(--text); border-color: var(--accent); background: rgba(255,110,199,0.06); } + +/* ── Site shell ──────────────────────────────────────────────────── */ + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 22px 36px; + background: rgba(7, 8, 11, 0.7); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-bottom: 1px solid var(--border-subtle); + position: sticky; + top: 0; + z-index: 50; +} +.logo { + display: flex; + align-items: center; + gap: 10px; + font-weight: 800; + font-size: 17px; + letter-spacing: -0.01em; + color: var(--text); +} +.logo:hover { color: var(--text); } +.logo-mark { + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--accent-grad); + color: transparent; + box-shadow: 0 4px 14px rgba(255, 110, 199, 0.5); +} +.site-nav { + display: flex; + gap: 4px; +} +.nav-link { + color: var(--muted); + padding: 8px 14px; + border-radius: 8px; + font-weight: 500; + transition: all var(--t); +} +.nav-link:hover { color: var(--text); background: var(--panel-2); } +.nav-link.active { color: var(--text); background: var(--panel-2); } + +main { max-width: 1280px; margin: 0 auto; padding: 32px 36px 80px; } + +.site-footer { + text-align: center; + padding: 32px 16px; + color: var(--muted); + font-size: 12px; + border-top: 1px solid var(--border-subtle); + margin-top: 80px; +} +.site-footer a { color: var(--muted); } +.site-footer a:hover { color: var(--text-dim); } + +/* ── Hero ────────────────────────────────────────────────────────── */ + +.hero { + margin-bottom: 28px; +} +.hero h1 { + margin: 0 0 4px; + font-size: 38px; + font-weight: 800; + letter-spacing: -0.03em; + background: var(--accent-grad); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} +.hero p { margin: 0; color: var(--text-dim); font-size: 15px; } +.muted { color: var(--muted); } + +/* ── Photo grid (masonry-ish via CSS columns) ────────────────────── */ + +.grid { + column-count: 3; + column-gap: 16px; +} +@media (max-width: 1024px) { .grid { column-count: 2; } } +@media (max-width: 600px) { .grid { column-count: 1; } } + +.photo-card { + break-inside: avoid; + margin-bottom: 16px; + background: var(--panel); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-subtle); + border-radius: 14px; + overflow: hidden; + transition: all var(--t); + cursor: pointer; + display: block; + color: inherit; + animation: fadeIn 280ms ease-out; +} +.photo-card:hover { + transform: translateY(-3px); + border-color: var(--border); + box-shadow: var(--shadow); +} +.photo-card .photo-img { + display: block; + width: 100%; + height: auto; + background: var(--panel-2); + aspect-ratio: auto; +} +.photo-card .photo-meta { + padding: 12px 14px 14px; +} +.photo-card .uploader { + font-weight: 600; + color: var(--text); + font-size: 13px; + margin: 0 0 4px; + display: flex; + align-items: center; + gap: 8px; +} +.photo-card .uploader-avatar { + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--accent-grad); + flex-shrink: 0; + font-size: 10px; + font-weight: 700; + color: #07080b; + display: flex; + align-items: center; + justify-content: center; +} +.photo-card .caption { + color: var(--text-dim); + font-size: 13px; + margin: 6px 0 8px; + line-height: 1.45; + /* clamp to 3 lines on the card */ + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.photo-card .photo-foot { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--muted); + font-size: 11px; + margin-top: 4px; +} +.photo-card .photo-foot .cheers { + color: var(--accent); + font-weight: 600; +} +.photo-card .photo-foot .room { + background: var(--panel-3); + padding: 2px 8px; + border-radius: 6px; + color: var(--text-dim); + max-width: 60%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Photo detail ────────────────────────────────────────────────── */ + +.back { + margin-bottom: 18px; + padding: 6px 14px; + font-size: 12px; +} + +.photo-detail { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 28px; + align-items: start; +} +@media (max-width: 900px) { + .photo-detail { grid-template-columns: 1fr; } +} +.photo-detail .image-wrap { + background: var(--panel); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-subtle); + border-radius: 16px; + overflow: hidden; +} +.photo-detail .image-wrap img { + display: block; + width: 100%; + height: auto; +} +.photo-detail .info { + background: var(--panel); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-subtle); + border-radius: 16px; + padding: 22px; + position: sticky; + top: 96px; +} +.photo-detail .info h2 { + margin: 0 0 12px; + font-size: 18px; + background: var(--accent-grad); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} +.photo-detail .info .caption-full { + color: var(--text); + margin: 0 0 16px; + line-height: 1.55; + white-space: pre-wrap; +} +.photo-detail .info .caption-full:empty::before { + content: 'No caption.'; + color: var(--muted); + font-style: italic; +} +.photo-detail .info .meta-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; + border-top: 1px solid var(--border-subtle); + color: var(--text-dim); + font-size: 13px; +} +.photo-detail .info .meta-row:first-of-type { border-top: none; } +.photo-detail .info .meta-row .k { color: var(--muted); min-width: 80px; } +.photo-detail .info .uploader-link { + display: flex; + align-items: center; + gap: 10px; + color: var(--text); + font-weight: 600; +} +.photo-detail .info .uploader-link:hover { color: var(--accent); } +.photo-detail .info .room-pill { + background: var(--panel-3); + padding: 4px 10px; + border-radius: 6px; + color: var(--text-dim); + font-size: 12px; +} +.photo-detail .info .stats { + display: flex; + gap: 12px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border-subtle); +} +.photo-detail .info .stat-pill { + background: var(--panel-2); + border: 1px solid var(--border-subtle); + padding: 8px 14px; + border-radius: 10px; + flex: 1; + text-align: center; +} +.photo-detail .info .stat-pill .v { + font-size: 18px; + font-weight: 700; + color: var(--text); + font-feature-settings: 'tnum'; +} +.photo-detail .info .stat-pill .l { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-top: 2px; +} +.photo-detail .info .stat-pill.cheers .v { color: var(--accent); } + +/* ── Profile view ────────────────────────────────────────────────── */ + +.profile-header { + display: flex; + align-items: center; + gap: 18px; + padding: 22px; + background: var(--panel); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-subtle); + border-radius: 16px; + margin-bottom: 28px; +} +.profile-header .avatar { + width: 64px; + height: 64px; + border-radius: 50%; + background: var(--accent-grad); + color: #07080b; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + font-weight: 800; + flex-shrink: 0; +} +.profile-header h2 { margin: 0 0 4px; font-size: 22px; } +.profile-header .username { color: var(--muted); font-size: 13px; } + +/* ── Empty / load-more ───────────────────────────────────────────── */ + +.empty { + text-align: center; + padding: 80px 24px; + color: var(--muted); +} +.empty p { margin: 0; font-size: 14px; } + +.load-more { + text-align: center; + margin: 28px 0; +} + +/* ── Toast ───────────────────────────────────────────────────────── */ + +.toast { + position: fixed; + bottom: 24px; + right: 24px; + background: var(--panel-2); + border: 1px solid var(--border); + color: var(--text); + padding: 12px 18px; + border-radius: 10px; + box-shadow: var(--shadow); + z-index: 200; + font-size: 13px; + border-left: 3px solid var(--accent); + animation: toastIn 200ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +.toast.error { border-left-color: var(--danger); } + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes toastIn { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; border: 2px solid var(--bg); } +::-webkit-scrollbar-thumb:hover { background: var(--panel-3); } + +.skeleton { + background: linear-gradient(90deg, var(--panel-2) 0%, var(--panel-3) 50%, var(--panel-2) 100%); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; + border-radius: 12px; +} +@keyframes shimmer { + from { background-position: 200% 0; } + to { background-position: -200% 0; } +} diff --git a/DorkNet.sln b/DorkNet.sln new file mode 100644 index 0000000..17398cf --- /dev/null +++ b/DorkNet.sln @@ -0,0 +1,75 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Server", "DorkNet.Server\DorkNet.Server.csproj", "{7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Models", "DorkNet.Models\DorkNet.Models.csproj", "{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.ClientPatch", "DorkNet.ClientPatch\DorkNet.ClientPatch.csproj", "{CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrateSqliteToPostgres", "Tools\MigrateSqliteToPostgres\MigrateSqliteToPostgres.csproj", "{70C60098-1B11-4C13-943B-A8A056429EAE}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Debug|x64.ActiveCfg = Debug|Any CPU + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Debug|x64.Build.0 = Debug|Any CPU + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Debug|x86.ActiveCfg = Debug|Any CPU + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Debug|x86.Build.0 = Debug|Any CPU + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Release|Any CPU.Build.0 = Release|Any CPU + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Release|x64.ActiveCfg = Release|Any CPU + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Release|x64.Build.0 = Release|Any CPU + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Release|x86.ActiveCfg = Release|Any CPU + {7DC352E8-24BD-4D84-A4D3-66C0FA1A91F7}.Release|x86.Build.0 = Release|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Debug|x64.ActiveCfg = Debug|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Debug|x64.Build.0 = Debug|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Debug|x86.Build.0 = Debug|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|Any CPU.Build.0 = Release|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x64.ActiveCfg = Release|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x64.Build.0 = Release|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x86.ActiveCfg = Release|Any CPU + {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x86.Build.0 = Release|Any CPU + {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Debug|x64.ActiveCfg = Debug|Any CPU + {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Debug|x86.ActiveCfg = Debug|Any CPU + {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Release|x64.ActiveCfg = Release|Any CPU + {CB0D59F5-C17D-49E1-9E6B-E89A9E89EAB6}.Release|x86.ActiveCfg = Release|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x64.ActiveCfg = Debug|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x64.Build.0 = Debug|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x86.ActiveCfg = Debug|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x86.Build.0 = Debug|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|Any CPU.Build.0 = Release|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x64.ActiveCfg = Release|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x64.Build.0 = Release|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.ActiveCfg = Release|Any CPU + {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {70C60098-1B11-4C13-943B-A8A056429EAE} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2239a76 --- /dev/null +++ b/NOTICE @@ -0,0 +1,39 @@ +DorkNet — Rec Room Private Server +Copyright (C) 2026 Alexa + +This program is free software: you can redistribute it and/or modify it +under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public +License along with this program. If not, see . + +──────────────────────────────────────────────────────────────────────── +Attribution requirement +──────────────────────────────────────────────────────────────────────── + +If you fork, modify, host, or otherwise redistribute DorkNet — including +running a publicly-accessible instance accessed over a network as +covered by AGPL §13 — you must: + + 1. Make your full source code (including any modifications) available + to all users who interact with the running instance, under the + same AGPL-3.0 license. + + 2. Preserve this NOTICE file in your distribution. + + 3. Credit the upstream project visibly. At minimum: + "Based on DorkNet (https://github.com/Alexa-RR/Dorknet) by Alexa, + licensed AGPL-3.0." + Acceptable placements: README, server console banner, in-game + credits screen, or web-facing footer of any deployment dashboard. + + 4. Indicate in your repository / release notes which substantive + changes you have made relative to the upstream version. + +The full text of the AGPL-3.0 license is in LICENSE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3798c54 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# DorkNet — `march-2020-03-10` + +This is the **March 2020.03.10** branch of DorkNet — a self-hostable +reimplementation of the Rec Room backend tuned to the wire protocol of +the **Rec Room 2020.03.10** client build (also serves 2020.03.06). + +> ⚠️ **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 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 +main also auto-detects your install. + +--- + +## What this branch targets + +| | | +|---|---| +| **Client build** | Rec Room 2020.03.10 (also 2020.03.06) | +| **Schema** | Multi-step EF migrations (~34 phases) | +| **Diverges from `december-2020-12-18` by** | ~170 files | + +Subsystems **NOT** present on this branch (these landed in December): + +- Clubs surface +- Playlists (curated + user-created) +- Group DMs / chat threads +- Weekly challenges +- Room keys (purchasable room access) +- Per-room co-owner / moderator / host roles +- Late-2020 store catalog additions + +If you need any of those, you're on the wrong branch — see +[`december-2020-12-18`](../../tree/december-2020-12-18). + +--- + +## Quickstart + +**1. Install .NET 9 SDK** and (optionally) `dotnet-ef`: + +```pwsh +dotnet tool install --global dotnet-ef +``` + +**2. Clone this branch:** + +```pwsh +git clone --branch march-2020-03-10 https://github.com/DorkSquadRR/DorkNet +cd DorkNet +``` + +**3. Configure secrets.** Drop in `DorkNet.Server/appsettings.Local.json` +(gitignored) or export environment variables: + +| Config key | Env var | Required | Notes | +|---|---|---|---| +| `Jwt:Secret` | `Jwt__Secret` | yes | ≥64 random chars | +| `Photon:AppId` | `Photon__AppId` | yes | Photon Realtime AppId (free at dashboard.photonengine.com) | +| `Photon:VoiceAppId` | `Photon__VoiceAppId` | no | only if voice chat | +| `Photon:CloudRegion` | `Photon__CloudRegion` | no | defaults to `us` | +| `Database:Provider` | `Database__Provider` | no | `sqlite` (default) or `postgres` | +| `ConnectionStrings:Default` | `ConnectionStrings__Default` | postgres-only | full Npgsql connection string | +| `ConnectionStrings:Redis` | `ConnectionStrings__Redis` | no | enables SignalR backplane | +| `Domain:Apex` | `DORKNET_DOMAIN` | no | defaults to `localhost`; set for production | + +**4. Boot:** + +```pwsh +dotnet run --project DorkNet.Server +``` + +First boot creates `bin/Debug/net9.0/data/dorknet.db`, applies all EF +migrations under `DorkNet.Server/Migrations/`, seeds canonical rooms + +store catalog, and starts listening. You should see `Application +started.` in the log; `curl http://localhost:8080/healthz` returns 200. + +For Docker / VPS, see [docs/deploy.md](docs/deploy.md). + +--- + +## Patching your client + +```pwsh +.\tools\install-plugin.ps1 ` + -RecRoomPath "C:\Path\To\Recroom_Release_Data" ` + -PhotonAppId "" ` + -PhotonVoiceAppId "" +``` + +This installs the BepInEx 6 plugin into the client's `BepInEx/plugins/` +and writes its config under `BepInEx/config/`. The plugin 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. + +If BepInEx isn't installable on the user's machine, the legacy +`tools/install-legacy-client.ps1` does the same patches via direct +byte-edits of `resources.assets` + `GameAssembly.dll`. Slower setup, +no Harmony runtime needed. + +--- + +## Code map + +| Folder | Purpose | +|---|---| +| `DorkNet.Server/` | ASP.NET Core backend (controllers, services, data, hubs, middleware) | +| `DorkNet.Server/Migrations/` | EF Core schema migrations applied at boot | +| `DorkNet.Models/` | DTOs shared between server and client mod | +| `DorkNet.ClientPatch/` | BepInEx IL2CPP plugin (production patcher) | +| `DorkNet.ClientMod/` | MelonLoader port (alternative patcher) | +| `tools/` | Installers + Cloudflare tunnel templates | + +Compared to `december-2020-12-18`, this branch's server is closer to +the original 2020-Q1 wire shape — fewer endpoints, simpler entities, +and the multi-step migrations haven't been consolidated. + +--- + +## Contributing + +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 `december-2020-12-18` + automatically. If a fix applies to both, cherry-pick it across. +- Don't rebase across branches — they're independent forks. +- New EF migrations: `dotnet ef migrations add --project DorkNet.Server`. + `Migrate()` runs on boot; don't run `database update` manually. + +--- + +## License + +[AGPL-3.0](LICENSE). Hosted forks must publish their source. diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..ac385ee --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,287 @@ +# DorkNet — Coolify deployment + +This doc covers the production cutover from the local-machine setup +(SQLite + Kestrel direct + mkcert + per-machine cloudflared) to the +horizontally-scaled Coolify deployment (Postgres + Redis + Garage S3 + +2-replica Kestrel behind cloudflared, all on the same Coolify project). + +The plan-of-record is `BUILD_PROPER_PLAN.md` at the repo root and the +phased writeup at `~/.claude/plans/pahese-0-1-horizontal-cheeky-hickey.md`. +Anything that contradicts this doc, that doc wins. + +--- + +## Coolify project layout + +One Coolify *project* with these services: + +| Service | Image / source | Notes | +| -------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | +| `dorknet-server` | Build from this repo's `Dockerfile` | Replicas: **2** to start. Internal port 8080. No public domain. | +| `dorknet-postgres` | Coolify one-click **Postgres** | Persistent volume on Coolify's data store. | +| `dorknet-redis` | Coolify one-click **Redis** | Persistent volume optional; used for ephemeral state + SignalR backplane. | +| `dorknet-garage` | Coolify one-click **Garage** | S3-compatible. Persistent volume (room blobs are 100s of MB). | +| `dorknet-cloudflared`| Cloudflare's `cloudflared` image, custom config | Reads `tools/cloudflared-config.coolify.yml.template` rendered with a tunnel ID. | + +All five sit on the same project network so the server reaches its +dependencies over Coolify's internal Docker DNS. + +## Required environment variables on `dorknet-server` + +Set these in Coolify's *Environment Variables* tab. Coolify expands +`${...}` references to other services in the same project, so the +connection-string lookups link automatically. + +``` +ASPNETCORE_ENVIRONMENT=Production + +# Database +ConnectionStrings__Default=Host=dorknet-postgres;Port=5432;Database=dorknet;Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD} + +# Redis +ConnectionStrings__Redis=dorknet-redis:6379 + +# JWT +DORKNET_JWT_SECRET= + +# Object storage (Garage). Bucket names are hard-coded in code; only +# the endpoint + credentials need configuring. Objects are organized +# under owner-scoped keys inside each bucket, e.g. +# players/{playerId}/camera, players/{playerId}/profile, +# players/{playerId}/rooms/{roomId}, players/{playerId}/dorm. +S3__Endpoint=http://dorknet-garage:3900 +S3__Region=garage +S3__AccessKey= +S3__SecretKey= + +# Image CDN signing. The 2020 client verifies Content-Signature on +# image downloads. Use the private half of the key whose public modulus +# the client knows for p1. Store PEM newlines as \n if your env UI is +# single-line, or use DORKNET_IMAGE_SIGNING_PRIVATE_KEY_BASE64. +DORKNET_IMAGE_SIGNING_KEY_ID=p1 +DORKNET_IMAGE_SIGNING_PRIVATE_KEY= + +# Photon (copy from local appsettings.json — same AppId across envs) +Photon__AppId= +Photon__VoiceAppId=1917ff4a-b7ad-4ad6-9ea2-9642bc174b70 +Photon__CloudRegion=us +``` + +### Cloudflare R2 instead of Garage + +DorkNet can use Cloudflare R2 without code changes because R2 speaks the +S3-compatible API. Create these three R2 buckets first: + +``` +profile-images +camera-photos +room-blobs +``` + +Then create an R2 API token with Object Read & Write access to both +buckets and set the server env vars: + +``` +S3__Endpoint=https://.r2.cloudflarestorage.com +S3__Region=auto +S3__AccessKey= +S3__SecretKey= +S3__MaxErrorRetry=1 +S3__TimeoutSeconds=8 +``` + +Do not use an R2 public bucket URL or custom domain for `S3__Endpoint`. +That value must be the S3 API endpoint ending in +`.r2.cloudflarestorage.com`. +DorkNet disables S3 chunked upload encoding in code because R2 rejects +`STREAMING-AWS4-HMAC-SHA256-PAYLOAD` request bodies. + +`appsettings.Production.json` already pins `Database:Provider=postgres` +and binds Kestrel to `http://0.0.0.0:8080`, so those don't need to be +set as env vars. + +## First-time cutover (stop-the-world) + +1. **Drain the local server.** Tell players, take it down. The cutover + is non-incremental — once the SQLite source is read, no further + writes can be accepted there. + +2. **Snapshot SQLite.** `tools/snapshot-db.ps1` writes a timestamped + copy. Keep it indefinitely as the pre-cutover baseline. + +3. **Deploy the Coolify project** with the four services above + (Postgres / Redis / Garage / cloudflared) but the server replicas + set to 0 — we want the database empty and idle while the migrator + runs. + +4. **Run the migrator** from the operator's laptop, pointed at the + newly-provisioned Postgres + Garage: + + ```pwsh + dotnet run --project Tools/MigrateSqliteToPostgres -- ` + --sqlite ./snapshots/dorknet-pre-cutover.db ` + --postgres "Host=;Port=5432;Database=dorknet;Username=...;Password=...;SSL Mode=Require" ` + --reset ` + --upload-blobs ` + --s3-endpoint http://:3900 ` + --s3-bucket room-blobs ` + --s3-access-key ... ` + --s3-secret-key ... + ``` + + The migrator runs the 9-step verification + an optional 10th step + that bulk-uploads every `RoomDataBlobs.Bytes` row to S3, idempotent + on re-run. A non-zero exit means **abort**: leave the destination + in its dirty state, fix the diff, re-run with `--reset` for a clean + slate. + +5. **Scale `dorknet-server` to 2 replicas.** First boot: one replica + wins the `pg_advisory_xact_lock` and runs `EnsureCreated` (no-op, + schema already there from the migrator) + the seed steps; the other + blocks on the lock until the first commits, sees the schema is + complete, and proceeds. + +6. **Verify `/healthz` is 200** on both replicas (Coolify shows this + in the Container tab) before pointing the tunnel at the service. + +7. **Bring up `dorknet-cloudflared`** with the rendered config (see + `tools/cloudflared-config.coolify.yml.template`). Confirm `dig` / + `curl` against `https://api.localhost/api/health/v1` returns 200. + +8. **Tell players it's back.** Done. + +## Notes on multi-replica behaviour + +* **Sticky sessions are no longer required.** Auth's + `OrphanAccountTracker` writes the pending claim to Redis, so a + player whose two HTTP requests hit different replicas still sees + the same account-creation state. + +* **SignalR groups fan out across replicas.** The Redis backplane is + registered under the channel prefix `dorknet-signalr`; both replicas + publish/subscribe to the same channels, so a notification raised on + replica B reaches a player connected to replica A. + +* **Player presence is shared.** `PlayerPresenceService` writes + `presence:player:{id}` keys to Redis with a 45 s TTL (3× heartbeat). + Either replica can answer "where is player N?". + +* **Game session ids are allocated by Postgres.** `GameSessionService` + uses a `bigserial` so two replicas can `JoinOrCreate` simultaneously + without colliding. + +* **Private instances live in Postgres.** Cross-replica visibility is + automatic — both replicas read from the same `PrivateInstances` + table. + +## Rollback + +If the cutover goes sideways before step 5 (no replicas running yet), +just delete the Postgres / Redis / Garage volumes in Coolify, set the +server replica count back to 0, and bring the local SQLite server back +up. The pre-cutover snapshot is still untouched. + +After step 5, rolling back means **restoring the SQLite snapshot** +(any data players wrote on Postgres post-cutover will be lost). Be +sure the cutover validation in step 6 is convincing before you tell +players it's safe. + +## Photon Cloud — own AppId + CustomAuth callback + +The hard-coded RecRoom Photon AppId rejects external clients (NameServer +error code 32736 / "No auth request during expected wait time") because +their dashboard has CustomAuth set to "Reject if Auth Failed" pointing +at RecRoom's own backend. Solution: register your own free AppId. + +1. Go to https://dashboard.photonengine.com → sign up → **Create a New App** + → "Photon Realtime" → save. Repeat for "Photon Voice" if you want voice. +2. Copy each **AppId** (UUID) and set in Coolify env vars: + ``` + Photon__AppId= + Photon__VoiceAppId= + Photon__CloudRegion=us + ``` +3. Restart the server. + +That alone gets multiplayer working — the Photon NameServer accepts the +client because no CustomAuth is required. + +### (Optional) wire up server-side single-session enforcement + +Server already exposes `https://auth.localhost/photon/customauth` — see +[PhotonCustomAuthController.cs](DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs). +It accepts `userid` + `LoginLock` and validates them against +`PlayerPresenceService` (Redis). Enable it in the dashboard: + +1. Photon dashboard → your app → **Manage** → **Custom Authentication** tab. +2. **Enable Custom Authentication**. +3. URL: `https://auth.localhost/photon/customauth?userid={userid}&LoginLock={LoginLock}` +4. **Reject if Auth Failed**: **OFF for now** (see caveat below). +5. Save. + +**Caveat — current 2020 watch doesn't send AuthValues**. A sweep of +`Cpp2IL_ISIL/IsilDump/Assembly-CSharp/` turns up no caller of +`PhotonNetwork.set_AuthValues` outside Photon's own library code, so +the unmodified client connects without supplying `{userid, LoginLock}`. +With "Reject if Auth Failed" ON, every Photon connect would fail with +NameServer 32736 — same symptom we tried to solve. With it OFF, the +client connects fine, the endpoint exists for future client patching. + +### Patching the watch to send AuthValues — `DorkNet.ClientPatch/` + +The vanilla 2020 watch never sets `PhotonNetwork.AuthValues` (verified +by an exhaustive grep across both `Assembly-CSharp.dll` and the +Photon `Assembly-CSharp-firstpass.dll` library). To make Photon +forward `userid` + `LoginLock` to our `/photon/customauth` endpoint we +ship a **BepInEx 6 IL2CPP plugin** that patches +`NetworkingPeer.OpAuthenticate` and attaches the values in flight. + +Source + build instructions: [DorkNet.ClientPatch/README.md](../DorkNet.ClientPatch/README.md). + +End-to-end install on a tester's machine: + +```pwsh +.\tools\patch-client.ps1 ` + -RecRoomPath "C:\…\RecRoom\Recroom_Release_Data" ` + -PhotonAppId cb0880d9-… ` + -InstallBepInEx "C:\Downloads\BepInEx_unix_x86_64_6.0.0-be.696_…zip" +``` + +The patcher will: + +1. Do its usual asset/hosts/cert work. +2. Extract BepInEx into the client folder (skipped if already + present). +3. If `BepInEx/interop/` exists, build + `DorkNet.ClientPatch.dll` against it and drop it into + `BepInEx/plugins/`. +4. If `interop/` doesn't exist yet (BepInEx never launched), prompt + the operator to launch the game once so BepInEx generates interop + assemblies, then re-run the patcher with the same `-InstallBepInEx` + path to finish step 3. + +Once the plugin is in production: + +1. Flip [`PhotonCustomAuthController`](../DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs) + from permissive-mode (`ResultCode 1` always) back to strict — + parse `userid` + `LoginLock`, validate against + `PlayerPresenceService.ValidateLock`, return `ResultCode 3` on + mismatch. +2. In the Photon dashboard, flip **"Reject if Auth Failed"** ON. +3. Duplicate-account login from a different LoginLock now fails + Photon auth at the NameServer — never reaches the dorm. + +## Future schema changes + +PR 4 keeps `EnsureCreated` for the first deploy (the migrator builds +the schema directly, then EnsureCreated is a no-op the server can do +under advisory-lock to be safe on multi-replica boot). Any +**post-cutover** schema change needs a real Postgres migration: + +1. `dotnet ef migrations add --project DorkNet.Server` +2. Replace the `EnsureCreated` call in `Program.cs` with + `db.Database.Migrate()` *inside* the same advisory-lock block. +3. Deploy. Both replicas race for the lock, the winner runs the + migration, the loser sees the new history row and skips. + +Until that swap happens, treat the schema as frozen. diff --git a/hosts.dorknet.txt b/hosts.dorknet.txt new file mode 100644 index 0000000..2c7f5d6 --- /dev/null +++ b/hosts.dorknet.txt @@ -0,0 +1,112 @@ +# ───────────────────────────────────────────────────────────────────────────── +# DorkNet — Rec Room private server hosts file entries +# ───────────────────────────────────────────────────────────────────────────── +# +# Append this entire block to the bottom of: +# C:\Windows\System32\drivers\etc\hosts +# +# Open Notepad as Administrator (right-click → Run as administrator), File → +# Open → set the file-type filter to "All Files (*.*)", navigate to +# C:\Windows\System32\drivers\etc\, pick `hosts`, paste, save. +# +# After saving, flush the resolver cache so Windows picks up the changes: +# ipconfig /flushdns +# +# Replace 127.0.0.1 with your server's LAN IP (e.g. 192.168.1.50) on every +# line if you're hosting for other machines on your network. Each client +# machine needs its own copy of these entries pointing at the server's IP. +# +# Why every subdomain is listed individually: the Windows hosts file (and +# /etc/hosts on Linux/Mac) does NOT support wildcards. *.rec.net won't work. +# If you want true wildcard resolution, install Acrylic DNS Proxy on Windows +# or dnsmasq on Linux instead — but the explicit list below is sufficient +# for a single-host setup. +# +# Every entry below corresponds to a [Host("xxx.rec.net")] controller in +# DorkNet.Server, plus a few subdomains the client sometimes hits that +# fall through to GlobalCatchAllController. + +127.0.0.1 rec.net +127.0.0.1 admin.rec.net +127.0.0.1 api.rec.net +127.0.0.1 feed.rec.net +127.0.0.1 accounts.rec.net +127.0.0.1 auth.rec.net +127.0.0.1 bugreporting.rec.net +127.0.0.1 cards.rec.net +127.0.0.1 cdn.rec.net +127.0.0.1 chat.rec.net +127.0.0.1 clubs.rec.net +127.0.0.1 cms.rec.net +127.0.0.1 commerce.rec.net +127.0.0.1 data.rec.net +127.0.0.1 datacollection.rec.net +127.0.0.1 discovery.rec.net +127.0.0.1 econ.rec.net +127.0.0.1 gamelogs.rec.net +127.0.0.1 geo.rec.net +127.0.0.1 img.rec.net +127.0.0.1 leaderboard.rec.net +127.0.0.1 link.rec.net +127.0.0.1 lists.rec.net +127.0.0.1 match.rec.net +127.0.0.1 moderation.rec.net +127.0.0.1 ns.rec.net +127.0.0.1 notify.rec.net +127.0.0.1 platformnotifications.rec.net +127.0.0.1 playersettings.rec.net +127.0.0.1 roomcomments.rec.net +127.0.0.1 roomieintegrations.rec.net +127.0.0.1 rooms.rec.net +127.0.0.1 storage.rec.net +127.0.0.1 strings.rec.net +127.0.0.1 strings-cdn.rec.net +127.0.0.1 studio.rec.net +127.0.0.1 thorn.rec.net +127.0.0.1 videos.rec.net + +# ── DorkNet's own domain (localhost) — same subdomains as rec.net but +# points at the public domain we own. The patch-domain.ps1 script +# rewrites every "rec.net" string in the client binaries to +# "localhost" (same length, byte-for-byte), so a freshly-patched +# client connects here. The rec.net entries above stay so an +# unpatched client still works against the local server. + +127.0.0.1 localhost +127.0.0.1 admin.localhost +127.0.0.1 api.localhost +127.0.0.1 accounts.localhost +127.0.0.1 auth.localhost +127.0.0.1 bugreporting.localhost +127.0.0.1 cards.localhost +127.0.0.1 cdn.localhost +127.0.0.1 chat.localhost +127.0.0.1 clubs.localhost +127.0.0.1 cms.localhost +127.0.0.1 commerce.localhost +127.0.0.1 data.localhost +127.0.0.1 datacollection.localhost +127.0.0.1 discovery.localhost +127.0.0.1 econ.localhost +127.0.0.1 feed.localhost +127.0.0.1 gamelogs.localhost +127.0.0.1 geo.localhost +127.0.0.1 img.localhost +127.0.0.1 leaderboard.localhost +127.0.0.1 link.localhost +127.0.0.1 lists.localhost +127.0.0.1 match.localhost +127.0.0.1 moderation.localhost +127.0.0.1 ns.localhost +127.0.0.1 notify.localhost +127.0.0.1 platformnotifications.localhost +127.0.0.1 playersettings.localhost +127.0.0.1 roomcomments.localhost +127.0.0.1 roomieintegrations.localhost +127.0.0.1 rooms.localhost +127.0.0.1 storage.localhost +127.0.0.1 strings.localhost +127.0.0.1 strings-cdn.localhost +127.0.0.1 studio.localhost +127.0.0.1 thorn.localhost +127.0.0.1 videos.localhost diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..a7cefc1 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,206 @@ +# ───────────────────────────────────────────────────────────────────────────── +# DorkNet — nginx config for the Rec Room private server (March 2020 build) +# ───────────────────────────────────────────────────────────────────────────── +# +# What this does: +# • Listens on :80 (HTTP) and :443 (HTTPS) for every *.rec.net subdomain +# • Terminates TLS using a single mkcert wildcard cert +# • Proxies all HTTP traffic to the DorkNet.Server backend on 127.0.0.1:5137 +# • Forwards the Host header so ASP.NET Core's [Host("subdomain.rec.net")] +# attribute routing keeps working +# • Supports WebSocket upgrades — required for: +# - notify.rec.net/hub/v1 (SignalR Core; client uses BestHTTP.SignalRCore) +# - notify.rec.net/notify/v1 (legacy custom WebSocket) +# • Long read timeouts (1h) so SignalR/WS connections don't get axed mid-session +# • No request-body size limit so multipart form uploads work +# +# Two preconditions before this is useful: +# 1) mkcert ran: mkcert -install && mkcert "*.rec.net" rec.net +# → produces _wildcard.rec.net.pem and _wildcard.rec.net-key.pem +# → fix the absolute paths in the ssl_certificate lines below +# 2) hosts file maps every *.rec.net subdomain to 127.0.0.1 +# (a sample block is at the bottom of this file) +# +# Where to put this file: +# • Windows: C:\nginx\conf\nginx.conf (replace the default) +# • Linux: /etc/nginx/sites-available/dorknet +# then symlink to /etc/nginx/sites-enabled/ +# • Validate: nginx -t +# • Reload: nginx -s reload (or systemctl reload nginx) +# +# If you change the .NET dev port (DorkNet.Server/Properties/launchSettings.json +# applicationUrl), update the `set $backend` line below to match. +# ───────────────────────────────────────────────────────────────────────────── + +worker_processes auto; +events { + worker_connections 4096; +} + +http { + # ── MIME / logging ─────────────────────────────────────────────────────── + include mime.types; + default_type application/octet-stream; + + log_format dorknet '$remote_addr - $host "$request" ' + '$status $body_bytes_sent ${request_time}s ' + '"$http_user_agent"'; + access_log logs/access.log dorknet; + error_log logs/error.log warn; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65s; + server_tokens off; + + # ── Body size — multipart uploads on bulk endpoints, image uploads ────── + # Set to 0 for unlimited, or pick a sane cap. 32MB covers profile images. + client_max_body_size 32m; + + # ── Buffers — large enough for the kitchen-sink config response ────────── + # /api/config/v2 can be 5–10KB; default 4k buffers cause "upstream sent too + # big header" 502s. + proxy_buffer_size 16k; + proxy_buffers 8 32k; + proxy_busy_buffers_size 64k; + + # ── Connection-Upgrade map — required for any WebSocket route ──────────── + # When the client sends `Upgrade: websocket`, this map produces + # `Connection: upgrade`. For non-WS traffic it produces "" (empty), which + # is what plain HTTP requires. + map $http_upgrade $connection_upgrade { + default upgrade; + '' ''; + } + + # ── HTTP → HTTPS redirect (port 80 → 443) ──────────────────────────────── + server { + listen 80; + listen [::]:80; + server_name *.rec.net rec.net; + + # ACME / health-check passthroughs would go here. Otherwise everything + # gets bumped to HTTPS. + location / { + return 301 https://$host$request_uri; + } + } + + # ── Main TLS server — every *.rec.net subdomain ────────────────────────── + server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + + # Match every Rec Room subdomain (api, accounts, auth, match, rooms, …) + # plus the bare apex domain. The Host header gets forwarded intact, so + # ASP.NET Core's [Host(...)] attribute routing dispatches per-subdomain. + server_name *.rec.net rec.net; + + # ─── TLS — mkcert wildcard cert ───────────────────────────────────── + # CHANGE THESE TWO PATHS to wherever mkcert dropped the files. + # Quickly find them: mkcert -CAROOT + # And the cert PEM is wherever you ran `mkcert "*.rec.net" rec.net`. + ssl_certificate C:/Users/you/Documents/DorkNet/_wildcard.rec.net.pem; + ssl_certificate_key C:/Users/you/Documents/DorkNet/_wildcard.rec.net-key.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # ─── Backend ──────────────────────────────────────────────────────── + # The DorkNet.Server Kestrel listener. Change the port here if you + # change Properties/launchSettings.json applicationUrl. + set $backend http://127.0.0.1:5137; + + # ─── Catch-all proxy: every path goes to DorkNet.Server ────────────── + location / { + proxy_pass $backend; + + # Preserve the original Host so ASP.NET Core's [Host("api.rec.net")] + # routing dispatches by subdomain. + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # WebSocket upgrade — required for SignalR (notify.rec.net/hub/v1) + # and the custom WS at notify.rec.net/notify/v1. The + # $connection_upgrade var is the map() above. + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Long timeouts so WebSocket / SSE connections survive idle. + # 1 hour is plenty for a play session; bump if you see disconnects. + proxy_connect_timeout 60s; + proxy_send_timeout 3600s; + proxy_read_timeout 3600s; + + # Don't buffer streaming responses — the SignalR transport breaks + # if frames are buffered before being flushed to the client. + proxy_buffering off; + proxy_request_buffering off; + + # Faster failure when the backend is actually down — avoids the + # 30-second hangs that otherwise pile up under retry storms. + proxy_next_upstream error timeout invalid_header http_502 http_503; + proxy_next_upstream_tries 1; + } + + # ─── Optional: explicit WS routes for clarity (not strictly required; + # the location / above already handles them via the upgrade map). ──── + # location /hub/v1 { proxy_pass $backend; ... } + # location /notify/v1 { proxy_pass $backend; ... } + } +} + +# ───────────────────────────────────────────────────────────────────────────── +# hosts file companion entries +# Add to: +# • Windows: C:\Windows\System32\drivers\etc\hosts (Notepad as Admin) +# • Linux/Mac: /etc/hosts +# +# Replace 127.0.0.1 with your server's LAN IP if other machines connect to it. +# ───────────────────────────────────────────────────────────────────────────── +# +# 127.0.0.1 rec.net +# 127.0.0.1 api.rec.net +# 127.0.0.1 accounts.rec.net +# 127.0.0.1 auth.rec.net +# 127.0.0.1 bugreporting.rec.net +# 127.0.0.1 cards.rec.net +# 127.0.0.1 cdn.rec.net +# 127.0.0.1 chat.rec.net +# 127.0.0.1 clubs.rec.net +# 127.0.0.1 cms.rec.net +# 127.0.0.1 commerce.rec.net +# 127.0.0.1 data.rec.net +# 127.0.0.1 datacollection.rec.net +# 127.0.0.1 discovery.rec.net +# 127.0.0.1 econ.rec.net +# 127.0.0.1 gamelogs.rec.net +# 127.0.0.1 geo.rec.net +# 127.0.0.1 img.rec.net +# 127.0.0.1 leaderboard.rec.net +# 127.0.0.1 link.rec.net +# 127.0.0.1 lists.rec.net +# 127.0.0.1 match.rec.net +# 127.0.0.1 moderation.rec.net +# 127.0.0.1 ns.rec.net +# 127.0.0.1 notify.rec.net +# 127.0.0.1 platformnotifications.rec.net +# 127.0.0.1 playersettings.rec.net +# 127.0.0.1 roomcomments.rec.net +# 127.0.0.1 roomieintegrations.rec.net +# 127.0.0.1 rooms.rec.net +# 127.0.0.1 storage.rec.net +# 127.0.0.1 strings.rec.net +# 127.0.0.1 strings-cdn.rec.net +# 127.0.0.1 studio.rec.net +# 127.0.0.1 thorn.rec.net +# 127.0.0.1 videos.rec.net diff --git a/tools/build-patcher-zip.ps1 b/tools/build-patcher-zip.ps1 new file mode 100644 index 0000000..3181ff5 --- /dev/null +++ b/tools/build-patcher-zip.ps1 @@ -0,0 +1,105 @@ +<# +.SYNOPSIS + Builds the per-version dorknet-clientpatch-{VersionKey}.zip the + DorkNet launcher expects to find as a GitHub Release asset. + +.DESCRIPTION + Output zip layout (see launcher/RELEASES.md on the public main + branch for the full contract): + + manifest.json (schema v1, describes the install) + MelonLoader.zip (downloaded from melonloader github) + DorkNet.ClientMod.dll (built from this repo) + dorknet-clientmod.json.template (copied from DorkNet.ClientMod/) + + The launcher reads manifest.json, unzips MelonLoader on top of the + user's Rec Room install root, copies the DLL into Mods/, then + renders the config template into UserData/. + +.PARAMETER VersionKey + The version key matching versions.json on the public main branch + (e.g. december_2020_12_18 or march_2020_03_10). + +.PARAMETER MelonLoaderVersion + MelonLoader release tag to bundle. Defaults to the 0.6.x stable + target — that's the loader DorkNet.ClientMod compiled against. + +.PARAMETER OutputDir + Where the final zip lands. Defaults to repo-root/dist. + +.EXAMPLE + .\tools\build-patcher-zip.ps1 -VersionKey march_2020_03_10 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$VersionKey, + [string]$MelonLoaderVersion = "v0.6.6", + [string]$OutputDir = $null +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Resolve-Path "$PSScriptRoot\.." +if (-not $OutputDir) { $OutputDir = Join-Path $repoRoot 'dist' } +New-Item -ItemType Directory -Force $OutputDir | Out-Null + +$stage = Join-Path $env:TEMP "dorknet-patcher-$VersionKey-$([guid]::NewGuid().Guid.Substring(0,8))" +New-Item -ItemType Directory -Force $stage | Out-Null +try { + # ── 1. Build DorkNet.ClientMod ─────────────────────────────────────── + Write-Host "[build] dotnet build DorkNet.ClientMod (Release)" + dotnet build "$repoRoot\DorkNet.ClientMod\DorkNet.ClientMod.csproj" ` + -c Release --nologo | Out-Null + if ($LASTEXITCODE -ne 0) { throw "ClientMod build failed" } + $dll = Get-ChildItem "$repoRoot\DorkNet.ClientMod\bin\Release" -Recurse ` + -Filter "DorkNet.ClientMod.dll" | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if (-not $dll) { throw "DorkNet.ClientMod.dll not found post-build" } + Copy-Item $dll.FullName "$stage\DorkNet.ClientMod.dll" + Write-Host "[build] copied $($dll.FullName)" + + # ── 2. Fetch MelonLoader from upstream releases ────────────────────── + # The x64 .NET 6 build is what targets 2020-era Rec Room. The asset + # name pattern is stable across 0.6.x: MelonLoader.x64.zip. + $mlAsset = "MelonLoader.x64.zip" + $mlUrl = "https://github.com/LavaGang/MelonLoader/releases/download/$MelonLoaderVersion/$mlAsset" + $mlOut = "$stage\MelonLoader.zip" + Write-Host "[build] downloading $mlUrl" + Invoke-WebRequest -Uri $mlUrl -OutFile $mlOut + if (-not (Test-Path $mlOut) -or (Get-Item $mlOut).Length -lt 1MB) { + throw "MelonLoader download failed or produced an unexpectedly small file" + } + + # ── 3. Copy the config template ────────────────────────────────────── + $template = "$repoRoot\DorkNet.ClientMod\dorknet-clientmod.json.template" + if (-not (Test-Path $template)) { + throw "Config template missing: $template" + } + Copy-Item $template "$stage\dorknet-clientmod.json.template" + + # ── 4. Write manifest.json ────────────────────────────────────────── + $manifest = @{ + '$schema_version' = 1 + 'loader_archive' = 'MelonLoader.zip' + 'plugin_dll' = 'DorkNet.ClientMod.dll' + 'plugin_dest' = 'MelonLoader/Mods' + 'config_template' = 'dorknet-clientmod.json.template' + 'config_dest' = 'MelonLoader/UserData/dorknet-clientmod.json' + 'old_plugin_paths' = @( + 'BepInEx/plugins/DorkNet.ClientPatch.dll' + ) + } | ConvertTo-Json -Depth 4 + Set-Content -LiteralPath "$stage\manifest.json" -Value $manifest + + # ── 5. Bundle into the final zip ──────────────────────────────────── + $outZip = Join-Path $OutputDir "dorknet-clientpatch-$VersionKey.zip" + if (Test-Path $outZip) { Remove-Item $outZip -Force } + Compress-Archive -Path "$stage\*" -DestinationPath $outZip + $sizeMb = [math]::Round((Get-Item $outZip).Length / 1MB, 1) + Write-Host "" + Write-Host "Built $outZip ($sizeMb MB)" -ForegroundColor Green + Write-Host " Upload this to the v1-* GitHub Release on this branch." +} +finally { + if (Test-Path $stage) { Remove-Item -Recurse -Force $stage } +} diff --git a/tools/cloudflared-config.coolify.yml.template b/tools/cloudflared-config.coolify.yml.template new file mode 100644 index 0000000..f263b44 --- /dev/null +++ b/tools/cloudflared-config.coolify.yml.template @@ -0,0 +1,72 @@ +# DorkNet Cloudflare Tunnel config — Coolify production target. +# ──────────────────────────────────────────────────────────────────── +# Routes every localhost subdomain through the tunnel into the server +# container running on Coolify. The tunnel runs as its own Coolify +# service (or as a sidecar in the same project) and reaches the +# server over the internal Docker network. +# +# Two upstream options, pick one for {{UPSTREAM}}: +# +# a) Internal Docker DNS (preferred when cloudflared runs in the +# same Coolify project as the server): +# http://dorknet-server:8080 +# The server image binds Kestrel to 0.0.0.0:8080 (HTTP only — +# this leg of traffic is already inside the project network). +# +# b) Coolify-published HTTPS endpoint (when cloudflared runs in a +# separate project or on a different host): +# https://dorknet. +# Requires Coolify to be issuing a real TLS cert for that +# hostname. originRequest.noTLSVerify can stay off. +# +# Origin TLS is now off the critical path because the server speaks +# plain HTTP inside the project network. Traffic between cloudflared +# and Cloudflare's edge is still TLS-encrypted; the only unencrypted +# leg is server → cloudflared inside the same Docker network. + +tunnel: {{TUNNEL_ID}} +credentials-file: {{CREDENTIALS_FILE}} + +# Defaults that apply to every ingress rule unless overridden. The +# notify.* SignalR hub needs long-lived WebSockets — cloudflared's +# defaults are conservative and can tear idle connections down, which +# the 2020 watch surfaces as "check your internet" mid-session. Two +# levers matter: +# * keepAliveTimeout — how long cloudflared keeps the origin +# connection pooled and alive between requests; long-lived +# WebSockets stay open well past the default 1m30s, raise it. +# * connectTimeout — initial origin handshake; bumping it past the +# default avoids spurious 502s when the server is warm-booting +# after a Coolify deploy. +# These pair with the matching Program.cs SignalR + WebSocket +# KeepAliveInterval values (15s / 30s) so traffic flows continuously +# in both directions and the tunnel never marks the stream idle. +originRequest: + connectTimeout: 30s + tlsTimeout: 10s + tcpKeepAlive: 30s + keepAliveConnections: 100 + keepAliveTimeout: 10m + noHappyEyeballs: false + +# Ingress rules are evaluated top-down. The wildcard catches every +# subdomain we don't list explicitly, so when the 2020 Rec Room client +# (post-domain-patch) hits a niche subdomain like roomieintegrations. +# localhost, the request still reaches the server. +ingress: + - hostname: localhost + service: {{UPSTREAM}} + originRequest: + httpHostHeader: localhost + + - hostname: "*.localhost" + service: {{UPSTREAM}} + # cloudflared forwards the original Host header by default, which + # is what the server's [Host("api.localhost")] etc. attributes match + # against. No explicit httpHostHeader here so wildcards stay + # transparent. + + # Catch-all rejects anything that didn't match (defensive — the + # tunnel only routes traffic for hostnames we've added DNS records + # for, so this is rarely reached). + - service: http_status:404 diff --git a/tools/cloudflared-config.yml.template b/tools/cloudflared-config.yml.template new file mode 100644 index 0000000..43b853a --- /dev/null +++ b/tools/cloudflared-config.yml.template @@ -0,0 +1,40 @@ +# DorkNet Cloudflare Tunnel config +# ──────────────────────────────────────────────────────────────────── +# Routes every localhost subdomain through the tunnel into the local +# server (HTTPS on port 443). Generated by tools/setup-cloudflared.ps1 +# — copy to %USERPROFILE%\.cloudflared\config.yml and replace the +# {{TUNNEL_ID}} / {{CREDENTIALS_FILE}} placeholders. +# +# Origin TLS verify is OFF because the local server's wildcard cert is +# from mkcert (a private CA trusted only on this machine). cloudflared +# would reject it as untrusted otherwise. Traffic between cloudflared +# and Cloudflare's edge is still TLS-encrypted; this only relaxes the +# leg from cloudflared → localhost. + +tunnel: {{TUNNEL_ID}} +credentials-file: {{CREDENTIALS_FILE}} + +# Ingress rules are evaluated top-down. The wildcard catches every +# subdomain we don't list explicitly, so when the 2020 Rec Room client +# (post-domain-patch) hits a niche subdomain like roomieintegrations. +# localhost, the request still reaches the server. +ingress: + - hostname: localhost + service: https://localhost:443 + originRequest: + noTLSVerify: true + httpHostHeader: localhost + + - hostname: "*.localhost" + service: https://localhost:443 + originRequest: + noTLSVerify: true + # Forward the original Host header verbatim so the server's + # [Host("api.localhost")] etc. attributes match correctly. + # (cloudflared does this by default; setting it explicitly so + # config.yml is self-documenting.) + + # Catch-all rejects anything that didn't match (defensive — the + # tunnel only routes traffic for hostnames we've added DNS records + # for, so this is rarely reached). + - service: http_status:404 diff --git a/tools/cloudflared.README.md b/tools/cloudflared.README.md new file mode 100644 index 0000000..aa97e5e --- /dev/null +++ b/tools/cloudflared.README.md @@ -0,0 +1,99 @@ +# Cloudflare Tunnel for `localhost` + +Stand up a Cloudflare Tunnel that exposes your local DorkNet server +on `*.localhost` for public testing — without opening port 443 on your +firewall, and using Cloudflare's real Let's-Encrypt-equivalent cert +edge-side so visitors don't need your mkcert CA installed. + +## When to use this + +- You want a friend to play the patched 2020 Rec Room client against + your server, but they're not on your LAN and don't want to install + your local CA. +- You want `https://admin.localhost` / `https://feed.localhost` reachable + from any browser, not just yours. + +## Prerequisites + +1. **`localhost` is on a Cloudflare account you control.** The DNS + nameservers for the domain need to be Cloudflare's. Check at + — the zone should be Active. +2. **Local server running** on `0.0.0.0:443` with the wildcard cert + that covers `*.localhost` (re-run `tools/patch-client.ps1` if your + cert is still the `+1` rec.net-only one — it'll regenerate as + `+3` covering both domains). +3. **Patched client** with `tools/patch-domain.ps1` so the in-game + URLs resolve to `*.localhost`. (Players using the public deployment + need to run this on their own client too.) + +## One-shot setup + +```powershell +PS C:\…\Recnet> .\tools\setup-cloudflared.ps1 +``` + +The script: +1. Installs `cloudflared` via winget if missing. +2. Runs `cloudflared tunnel login` — opens a browser, you pick the + `localhost` zone, Cloudflare drops `cert.pem` into + `%USERPROFILE%\.cloudflared\`. +3. Creates a named tunnel called `dorknet` (or reuses one if it + already exists). +4. Generates `%USERPROFILE%\.cloudflared\config.yml` from the + `tools/cloudflared-config.yml.template` template, filling in the + tunnel id and credentials path. +5. Adds DNS CNAMEs for every localhost subdomain DorkNet uses + (api, auth, accounts, match, notify, ns, admin, feed, …). + +## Run the tunnel + +Foreground (for testing — Ctrl+C to stop): + +```powershell +cloudflared tunnel run dorknet +``` + +Or install as a Windows service so it survives reboots: + +```powershell +cloudflared service install +``` + +## What happens at request time + +``` +visitor → https://api.localhost/api/versioncheck/v4 + → Cloudflare edge (real LE cert, validated) + → cloudflared tunnel + → cloudflared (running on your machine) + → https://localhost:443 (Host: api.localhost, mkcert cert) + → DorkNet.Server matches [Host("api.localhost")] → handler runs +``` + +The leg from cloudflared → localhost has `noTLSVerify: true` because +the local server's cert is from mkcert (private CA only trusted on +your machine). The Cloudflare-edge → cloudflared leg is still +TLS-encrypted with their cert, so this isn't a security weakening +— it's just acknowledging that "127.0.0.1 with self-trusted cert" is +where the trust chain ends. + +## Tearing down + +```powershell +cloudflared service uninstall # if you ran service install +cloudflared tunnel delete dorknet +``` + +DNS records left behind in the Cloudflare dashboard need to be +deleted manually if you want them gone. + +## Limits / caveats + +- **Photon traffic is NOT proxied.** Cloudflare Tunnel is HTTP-only; + the realtime Photon connection still hits the public Photon Cloud + directly, with the AppId from your `appsettings.json`. That's fine + — Photon doesn't go through your server. +- **WebSocket support** is on by default in modern cloudflared, so + the SignalR notify hub works through the tunnel. +- **Free tier rate limits** — Cloudflare Free is generous for a + small private server but watch the dashboard if you scale up. diff --git a/tools/install-legacy-client.ps1 b/tools/install-legacy-client.ps1 new file mode 100644 index 0000000..4908404 --- /dev/null +++ b/tools/install-legacy-client.ps1 @@ -0,0 +1,190 @@ +<# +.SYNOPSIS + Switch a Rec Room 2020 client to DorkNet without BepInEx. + +.DESCRIPTION + This is the loader-free fallback path. It removes the BepInEx plugin + install made by tools/install-plugin.ps1, then runs tools/patch-client.ps1 + in its legacy mode: + + - byte-patches Photon Realtime/Voice AppIds in resources.assets; + - byte-patches the native BestHTTP/BouncyCastle TLS certificate + verifier return in GameAssembly.dll; + - points *.rec.net at the DorkNet server through the hosts file; + - generates/uses a mkcert wildcard certificate for HTTPS. + + No BepInEx, Harmony, Doorstop, or IL2CPP managed plugin remains involved + unless -KeepBepInEx is supplied. + + Photon identity note: the stock 2020 client does not send userid/LoginLock + AuthValues. DorkNet's Photon custom-auth endpoint allows anonymous connects, + but duplicate-login enforcement through Photon is unavailable in this mode. + +.PARAMETER RecRoomPath + Path to Recroom_Release_Data. + +.PARAMETER PhotonAppId + Photon Cloud Realtime AppId. + +.PARAMETER PhotonVoiceAppId + Photon Cloud Voice AppId. Defaults to PhotonAppId. + +.PARAMETER ServerIp + IP address to put in the hosts file for *.rec.net. + +.PARAMETER CertPath + Optional path for the mkcert wildcard certificate. + +.PARAMETER KeepBepInEx + Skip removing BepInEx before applying the legacy patch. + +.PARAMETER DryRun + Show what would happen without changing files. + +.EXAMPLE + .\tools\install-legacy-client.ps1 ` + -RecRoomPath "C:\Users\you\Documents\Recnet\dist\RecRoom-Clean-2020.03.10\Recroom_Release_Data" ` + -PhotonAppId "" +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$RecRoomPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern('(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')] + [string]$PhotonAppId, + + [ValidatePattern('(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')] + [string]$PhotonVoiceAppId, + + [string]$ServerIp = '127.0.0.1', + + [string]$CertPath, + + [switch]$KeepBepInEx, + + [switch]$DryRun +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Write-Step($msg) { Write-Host ('> ' + $msg) -ForegroundColor Cyan } +function Write-OK($msg) { Write-Host ('OK: ' + $msg) -ForegroundColor Green } +function Write-Warn($msg) { Write-Host ('WARN: ' + $msg) -ForegroundColor Yellow } + +$TlsBypassOffset = 0xB5EE50 +$TlsBypassOriginalByte = 0x48 +$TlsBypassPatchedByte = 0xC3 + +function Apply-TlsBypassPatch { + param( + [Parameter(Mandatory = $true)] + [string]$DataPath, + + [switch]$DryRun + ) + + $gameDir = Split-Path -Parent $DataPath + $gameAssembly = Join-Path $gameDir 'GameAssembly.dll' + if (-not (Test-Path -LiteralPath $gameAssembly -PathType Leaf)) { + throw "GameAssembly.dll not found next to '$DataPath'." + } + + Write-Step 'Applying native TLS verifier bypass' + $bytes = [System.IO.File]::ReadAllBytes($gameAssembly) + if ($bytes.Length -le $TlsBypassOffset) { + throw "GameAssembly.dll is too small for TLS bypass offset 0x$($TlsBypassOffset.ToString('X')). Wrong client build?" + } + + $current = $bytes[$TlsBypassOffset] + if ($current -eq $TlsBypassPatchedByte) { + Write-OK "TLS bypass already patched at GameAssembly.dll+0x$($TlsBypassOffset.ToString('X'))." + return + } + + if ($current -ne $TlsBypassOriginalByte) { + $actual = '0x{0:X2}' -f $current + $expected = '0x{0:X2}' -f $TlsBypassOriginalByte + throw "Unexpected byte at GameAssembly.dll+0x$($TlsBypassOffset.ToString('X')): $actual, expected $expected. Refusing to patch an unknown build." + } + + $backup = "$gameAssembly.tls-bypass-backup-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + if ($DryRun) { + Write-Host "DRY RUN: would backup GameAssembly.dll to $backup" + Write-Host "DRY RUN: would patch GameAssembly.dll+0x$($TlsBypassOffset.ToString('X')) from 0x48 to 0xC3" + return + } + + Copy-Item -LiteralPath $gameAssembly -Destination $backup + $bytes[$TlsBypassOffset] = [byte]$TlsBypassPatchedByte + [System.IO.File]::WriteAllBytes($gameAssembly, $bytes) + Write-OK "TLS bypass patched at GameAssembly.dll+0x$($TlsBypassOffset.ToString('X'))." + Write-OK "Backup written to $backup" +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$installPlugin = Join-Path $PSScriptRoot 'install-plugin.ps1' +$patchClient = Join-Path $PSScriptRoot 'patch-client.ps1' + +if (-not (Test-Path -LiteralPath $installPlugin -PathType Leaf)) { + throw "Missing $installPlugin" +} +if (-not (Test-Path -LiteralPath $patchClient -PathType Leaf)) { + throw "Missing $patchClient" +} + +$resolved = Resolve-Path -LiteralPath $RecRoomPath -ErrorAction SilentlyContinue +if (-not $resolved) { + throw "RecRoomPath '$RecRoomPath' does not exist." +} +$RecRoomPath = $resolved.Path + +if (-not $PhotonVoiceAppId) { + $PhotonVoiceAppId = $PhotonAppId +} + +Write-Host '' +Write-Step 'DorkNet legacy client install' +Write-Host (" RecRoomPath : {0}" -f $RecRoomPath) +Write-Host (" PhotonAppId : {0}" -f $PhotonAppId) +Write-Host (" VoiceAppId : {0}" -f $PhotonVoiceAppId) +Write-Host (" ServerIp : {0}" -f $ServerIp) +Write-Host (" RepoRoot : {0}" -f $repoRoot) +Write-Host '' + +if (-not $KeepBepInEx) { + Write-Step 'Removing BepInEx/plugin install' + if ($DryRun) { + Write-Host "DRY RUN: would run install-plugin.ps1 -Revert" + } else { + & $installPlugin -RecRoomPath $RecRoomPath -Revert + if (-not $?) { + throw "install-plugin.ps1 -Revert failed" + } + } +} else { + Write-Warn 'Keeping BepInEx because -KeepBepInEx was supplied.' +} + +Write-Step 'Applying legacy resources.assets/hosts/cert patch' +if ($CertPath -and $DryRun) { + & $patchClient -RecRoomPath $RecRoomPath -PhotonAppId $PhotonAppId -PhotonVoiceAppId $PhotonVoiceAppId -ServerIp $ServerIp -CertPath $CertPath -DryRun +} elseif ($CertPath) { + & $patchClient -RecRoomPath $RecRoomPath -PhotonAppId $PhotonAppId -PhotonVoiceAppId $PhotonVoiceAppId -ServerIp $ServerIp -CertPath $CertPath +} elseif ($DryRun) { + & $patchClient -RecRoomPath $RecRoomPath -PhotonAppId $PhotonAppId -PhotonVoiceAppId $PhotonVoiceAppId -ServerIp $ServerIp -DryRun +} else { + & $patchClient -RecRoomPath $RecRoomPath -PhotonAppId $PhotonAppId -PhotonVoiceAppId $PhotonVoiceAppId -ServerIp $ServerIp +} +if (-not $?) { + throw "patch-client.ps1 failed" +} + +Apply-TlsBypassPatch -DataPath $RecRoomPath -DryRun:$DryRun + +Write-Host '' +Write-OK 'Legacy client path installed.' +Write-Warn 'For this mode, Photon Custom Authentication should allow anonymous connects; DorkNet already does this, but Photon dashboard strict rejection should not depend on userid/LoginLock.' diff --git a/tools/install-melon.ps1 b/tools/install-melon.ps1 new file mode 100644 index 0000000..ab2a63e --- /dev/null +++ b/tools/install-melon.ps1 @@ -0,0 +1,648 @@ +<# +.SYNOPSIS + MelonLoader-based setup for the DorkNet 2020 client. + +.DESCRIPTION + One command that: + 1. Downloads MelonLoader (IL2CPP build, x64) from GitHub releases + and extracts it into the Rec Room install dir. + 2. Builds DorkNet.ClientMod (the MelonLoader port of the BepInEx + ClientPatch plugin) against MelonLoader's generated + Il2CppAssemblies. + 3. Drops the built DLL into \Mods\ and writes + \MelonLoader\UserData\dorknet-clientmod.json + with the user-supplied Photon AppId / server host / etc. + 4. Calls tools\patch-client.ps1 to apply the static byte patches + (Photon AppId in resources.assets, image signing public key, + image signature verifier, and the CodeStage Anti-Cheat Toolkit + neutraliser). + + Idempotent — re-running re-applies any missing pieces but won't + re-download MelonLoader if it's already cached, and the byte patches + detect already-patched bytes and skip. + + First-run gotcha: MelonLoader needs ONE successful game launch to + generate Il2CppAssemblies\ before the mod can build against it. This + script auto-detects that state. If Il2CppAssemblies\ is missing it + installs MelonLoader, writes the JSON config, runs the byte patches, + then prints "launch the game once, then re-run this script with + -ResumeBuild" instead of failing the build. + +.PARAMETER RecRoomPath + Path to the Recroom_Release_Data folder (the one with + resources.assets). The script uses this folder's parent as the install + root for MelonLoader extraction. + +.PARAMETER PhotonAppId + Photon Cloud Realtime AppId (GUID, no curly braces). Required on + the first install run. + +.PARAMETER PhotonVoiceAppId + Photon Cloud Voice AppId. Defaults to PhotonAppId. + +.PARAMETER ServerHost + Domain that replaces .rec.net in every URL the watch builds. + Default: localhost. + +.PARAMETER MelonLoaderZip + Optional local path to a MelonLoader.x64.zip. Skips the download. + +.PARAMETER ForceMelonLoader + Re-extract MelonLoader even if it's already present. + +.PARAMETER ResumeBuild + Skip the MelonLoader install + patch-client.ps1 step and only do + the mod build/copy step. Use after the first run has prompted you to + launch the game once. + +.PARAMETER SkipBytePatches + Skip the tools\patch-client.ps1 call. Use if you've already run it + separately or want to manage byte patches by hand. + +.PARAMETER Revert + Uninstall the mod + MelonLoader. Does not touch resources.assets + byte patches (use patch-client.ps1 -Revert for those). + +.PARAMETER ImageSigningPublicKeyPath + Forwarded to patch-client.ps1 unchanged — see that script's help. + +.EXAMPLE + # First-time setup. After the script prints "launch the game once", + # double-click Recroom_Release.exe, wait for the watch UI, quit, then + # re-run with -ResumeBuild to finish the mod build. + .\tools\install-melon.ps1 ` + -RecRoomPath "C:\Users\you\Documents\Recnet\dist\RecRoom-Clean-2020.03.10\Recroom_Release_Data" ` + -PhotonAppId + +.EXAMPLE + # Second pass after the first game launch generated Il2CppAssemblies. + .\tools\install-melon.ps1 ` + -RecRoomPath "C:\Users\you\Documents\Recnet\dist\RecRoom-Clean-2020.03.10\Recroom_Release_Data" ` + -PhotonAppId ` + -ResumeBuild + +.EXAMPLE + # Uninstall the mod (keeps the byte patches; use patch-client.ps1 -Revert + # for those). + .\tools\install-melon.ps1 -RecRoomPath D:\RecRoom\Recroom_Release_Data -Revert +#> + +[CmdletBinding(DefaultParameterSetName = 'Install')] +param( + [Parameter(Mandatory = $true)] + [string]$RecRoomPath, + + # Mandatory only when -FromServerConfig is not used. Validation + # happens after FromServerConfig fills it in below. + [Parameter(ParameterSetName = 'Install')] + [string]$PhotonAppId, + + [Parameter(ParameterSetName = 'Install')] + [string]$PhotonVoiceAppId, + + [Parameter(ParameterSetName = 'Install')] + [ValidateSet('us','eu','asia','jp','au','usw','sa','cae','kr','in','ru','rue')] + [string]$PhotonCloudRegion = 'us', + + # Read PhotonAppId / VoiceAppId / CloudRegion from the DorkNet.Server + # appsettings file at this path. Use the value from + # `DorkNet.Server\appsettings.Local.json` (falls back to + # `appsettings.json`) so you don't have to retype the GUIDs you + # already configured on the server side. Any explicit -PhotonAppId / + # -PhotonVoiceAppId / -PhotonCloudRegion override the file values. + [Parameter(ParameterSetName = 'Install')] + [string]$FromServerConfig, + + [Parameter(ParameterSetName = 'Install')] + [string]$ServerHost = 'localhost', + + [Parameter(ParameterSetName = 'Install')] + [string]$MelonLoaderZip, + + [Parameter(ParameterSetName = 'Install')] + [switch]$ForceMelonLoader, + + [Parameter(ParameterSetName = 'Install')] + [switch]$ResumeBuild, + + # Auto-bootstrap MelonLoader by launching Recroom_Release.exe in the + # background, polling for Il2CppAssemblies\Assembly-CSharp.dll to + # appear, then killing the process. Makes the install a single + # script run instead of the install -> launch -> ResumeBuild dance. + # On by default; pass -AutoBootstrap:$false to keep the two-step flow. + [Parameter(ParameterSetName = 'Install')] + [bool]$AutoBootstrap = $true, + + [Parameter(ParameterSetName = 'Install')] + [switch]$SkipBytePatches, + + [Parameter(ParameterSetName = 'Install')] + [string]$ImageSigningPublicKeyPath, + + [Parameter(Mandatory = $true, ParameterSetName = 'Revert')] + [switch]$Revert +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Seed $LASTEXITCODE so every later "did the native call succeed" check +# has something to read under StrictMode. PowerShell only populates this +# automatic variable AFTER a native executable runs, so a cold-shell +# invocation of this script can otherwise hit a VariableIsUndefined +# error the first time we test it. +$global:LASTEXITCODE = 0 + +# Pinned MelonLoader release. "latest" would be more convenient but +# pinning means a regression upstream doesn't silently land on tester +# machines — same rationale as the BepInEx pin in install-plugin.ps1. +# Bump when there's a known-good newer build. +$Script:MelonLoaderUrl = 'https://github.com/LavaGang/MelonLoader/releases/download/v0.6.6/MelonLoader.x64.zip' +$Script:MelonLoaderCacheName = 'MelonLoader.x64-0.6.6.zip' + +# Steamless pin — same release as install-plugin.ps1 so the cache hits +# between the two scripts. The 2020 Rec Room build is wrapped with +# Valve's SteamStub DRM; if the .exe is launched outside Steam the +# stub aborts with exit code 1, which is what kills MelonLoader's +# auto-bootstrap before it can generate Il2CppAssemblies. Stripping +# the wrapper is the cleanest fix. +$Script:SteamlessUrl = 'https://github.com/atom0s/Steamless/releases/download/v3.1.0.5/Steamless.v3.1.0.5.-.by.atom0s.zip' +$Script:SteamlessCacheName = 'Steamless.v3.1.0.5.zip' + +function Write-Step($msg) { Write-Host ('> ' + $msg) -ForegroundColor Cyan } +function Write-OK($msg) { Write-Host ('+ ' + $msg) -ForegroundColor Green } +function Write-Warn2($msg) { Write-Host ('! ' + $msg) -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host ('x ' + $msg) -ForegroundColor Red } + +# ── Steam DRM detection + stripping ────────────────────────────────── +# These three helpers are mechanically identical to the ones in +# install-plugin.ps1 (they predate this script). Kept locally here +# rather than dot-sourced so install-melon.ps1 stays a single file you +# can drop on a fresh machine. + +function Test-SteamDrmWrapped { + # Walks the PE header to see whether the AddressOfEntryPoint lands + # inside a `.bind` section — Valve's drm_wrap injects `.bind` + # carrying the SteamStub trampoline and rewrites the entry point + # to land there. After Steamless strips, the entry point points + # back into `.text` and the leftover `.bind` section is harmless. + param([string]$ExePath) + try { + $fs = [System.IO.File]::OpenRead($ExePath) + try { + $br = New-Object System.IO.BinaryReader($fs) + $fs.Seek(0x3C, 'Begin') | Out-Null + $peOff = $br.ReadInt32() + if ($peOff -lt 0 -or $peOff + 24 -gt $fs.Length) { return $false } + $fs.Seek($peOff + 6, 'Begin') | Out-Null + $sections = $br.ReadUInt16() + $fs.Seek($peOff + 20, 'Begin') | Out-Null + $optSize = $br.ReadUInt16() + $fs.Seek($peOff + 24 + 16, 'Begin') | Out-Null + $entryRva = $br.ReadUInt32() + $secStart = $peOff + 24 + $optSize + for ($i = 0; $i -lt $sections; $i++) { + $fs.Seek($secStart + $i * 40, 'Begin') | Out-Null + $name = [System.Text.Encoding]::ASCII.GetString($br.ReadBytes(8)).TrimEnd([char]0) + $vSize = $br.ReadUInt32() + $vAddr = $br.ReadUInt32() + if ($name -eq '.bind' -and $entryRva -ge $vAddr -and $entryRva -lt ($vAddr + $vSize)) { + return $true + } + } + return $false + } finally { $fs.Close() } + } catch { + return $false + } +} + +function Get-Steamless { + # Returns the path to a usable Steamless.CLI.exe. Downloads + extracts + # the pinned release on first run, caches under %LOCALAPPDATA%\DorkNet. + $cacheDir = Join-Path $env:LOCALAPPDATA 'DorkNet' + $extractDir = Join-Path $cacheDir 'Steamless' + $cli = Join-Path $extractDir 'Steamless.CLI.exe' + if (Test-Path -LiteralPath $cli -PathType Leaf) { + return $cli + } + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + $zipPath = Join-Path $cacheDir $Script:SteamlessCacheName + if (-not (Test-Path -LiteralPath $zipPath -PathType Leaf)) { + Write-Step "Downloading Steamless -> $zipPath" + Write-Host " $Script:SteamlessUrl" -ForegroundColor DarkGray + Invoke-WebRequest -Uri $Script:SteamlessUrl -OutFile $zipPath ` + -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop + } + Write-Step "Extracting Steamless -> $extractDir" + Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force + if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) { + # Some Steamless releases nest the CLI in a subfolder. + $found = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'Steamless.CLI.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($found) { $cli = $found.FullName } + } + if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) { + throw "Steamless.CLI.exe not found after extracting $zipPath" + } + return $cli +} + +function Invoke-DrmStrip { + # Runs Steamless against the wrapped exe in-place. Steamless writes + # an unpacked copy alongside the original; we move the original to + # .drm-original.exe and rename the unpacked file in place so the + # auto-bootstrap step doesn't have to know which is which. + param([string]$ExePath) + $cli = Get-Steamless + Write-Step "Stripping Steam DRM from $ExePath" + $exeDir = Split-Path -Parent $ExePath + $proc = Start-Process -FilePath $cli ` + -ArgumentList @('--quiet', '--keepbind', '0', $ExePath) ` + -NoNewWindow -PassThru -Wait + if ($proc.ExitCode -ne 0) { + throw "Steamless exited with code $($proc.ExitCode). The exe may not be a recognised Steam DRM build." + } + $unpacked = "$ExePath.unpacked.exe" + if (-not (Test-Path -LiteralPath $unpacked -PathType Leaf)) { + throw "Steamless ran but didn't produce $unpacked. Check the tool's output." + } + $bak = Join-Path $exeDir 'Recroom_Release.drm-original.exe' + if (-not (Test-Path -LiteralPath $bak -PathType Leaf)) { + Move-Item -LiteralPath $ExePath -Destination $bak -Force + } else { + Remove-Item -LiteralPath $ExePath -Force + } + Move-Item -LiteralPath $unpacked -Destination $ExePath -Force + Write-OK "DRM stripped. Original kept as $bak" +} + +# ── Path resolution ────────────────────────────────────────────────── +$resolved = Resolve-Path -LiteralPath $RecRoomPath -ErrorAction SilentlyContinue +if ($resolved) { $RecRoomPath = $resolved.Path } else { $RecRoomPath = $null } +if (-not $RecRoomPath -or -not (Test-Path -LiteralPath $RecRoomPath -PathType Container)) { + Write-Err "RecRoomPath '$RecRoomPath' does not exist." + exit 1 +} +if (-not (Test-Path -LiteralPath (Join-Path $RecRoomPath 'resources.assets') -PathType Leaf)) { + Write-Err "resources.assets not found in '$RecRoomPath'. Point at the Recroom_Release_Data folder." + exit 1 +} + +$RecRoomRoot = Split-Path -Parent $RecRoomPath +$RecRoomExe = Join-Path $RecRoomRoot 'Recroom_Release.exe' +$MelonLoaderDir = Join-Path $RecRoomRoot 'MelonLoader' +$ModsDir = Join-Path $RecRoomRoot 'Mods' +$UserDataDir = Join-Path $MelonLoaderDir 'UserData' +$Il2CppAsmsDir = Join-Path $MelonLoaderDir 'Il2CppAssemblies' +$RepoRoot = Split-Path -Parent $PSScriptRoot +$ModProj = Join-Path $RepoRoot 'DorkNet.ClientMod' +$ModCsproj = Join-Path $ModProj 'DorkNet.ClientMod.csproj' +$PatchClient = Join-Path $PSScriptRoot 'patch-client.ps1' + +# ── Pull Photon values from the server's appsettings when -FromServerConfig +# is set OR when a sibling DorkNet.Server\appsettings.Local.json exists. +# Explicit -Photon* params still win over file values so you can patch +# a one-off client against a different Photon app without touching the +# server config. Both Local and the base file get a look — Local first. +function Read-ServerPhotonConfig { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + try { + $json = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if (-not $json.PSObject.Properties['Photon']) { return $null } + $photon = $json.Photon + $appId = if ($photon.PSObject.Properties['AppId']) { [string]$photon.AppId } else { $null } + $voice = if ($photon.PSObject.Properties['VoiceAppId']) { [string]$photon.VoiceAppId } else { $null } + $region = if ($photon.PSObject.Properties['CloudRegion']) { [string]$photon.CloudRegion } else { $null } + return [pscustomobject]@{ AppId = $appId; VoiceAppId = $voice; CloudRegion = $region; Source = $Path } + } catch { + Write-Warn2 "Could not parse Photon section from $Path : $($_.Exception.Message)" + return $null + } +} + +if ($PSCmdlet.ParameterSetName -eq 'Install') { + if ($FromServerConfig) { + $candidates = @($FromServerConfig) + } else { + $candidates = @( + (Join-Path $RepoRoot 'DorkNet.Server\appsettings.Local.json'), + (Join-Path $RepoRoot 'DorkNet.Server\appsettings.json') + ) + } + foreach ($cand in $candidates) { + $cfg = Read-ServerPhotonConfig -Path $cand + if (-not $cfg) { continue } + if (-not $PhotonAppId -and $cfg.AppId) { $PhotonAppId = $cfg.AppId } + if (-not $PhotonVoiceAppId -and $cfg.VoiceAppId) { $PhotonVoiceAppId = $cfg.VoiceAppId } + if (-not $PSBoundParameters.ContainsKey('PhotonCloudRegion') -and $cfg.CloudRegion) { + $PhotonCloudRegion = $cfg.CloudRegion + } + Write-OK ("Loaded Photon config from $($cfg.Source) " + + "(AppId=$(if($cfg.AppId){''}else{''}), " + + "VoiceAppId=$(if($cfg.VoiceAppId){''}else{''}), " + + "CloudRegion=$(if($cfg.CloudRegion){$cfg.CloudRegion}else{''}))") + break + } + + if (-not $PhotonAppId) { + Write-Err 'PhotonAppId is required (pass -PhotonAppId or -FromServerConfig ).' + exit 1 + } + $guidRe = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + if ($PhotonAppId -notmatch $guidRe) { + Write-Err "PhotonAppId '$PhotonAppId' is not a GUID." + exit 1 + } + if ($PhotonVoiceAppId -and $PhotonVoiceAppId -notmatch $guidRe) { + Write-Err "PhotonVoiceAppId '$PhotonVoiceAppId' is not a GUID." + exit 1 + } +} + +# ── Revert ─────────────────────────────────────────────────────────── +if ($Revert) { + Write-Step 'REVERT MODE — removing MelonLoader + DorkNet.ClientMod' + + foreach ($p in @( + (Join-Path $RecRoomRoot 'MelonLoader'), + (Join-Path $RecRoomRoot 'Mods'), + (Join-Path $RecRoomRoot 'version.dll'), + (Join-Path $RecRoomRoot 'dobby.dll'), + (Join-Path $RecRoomRoot 'NOTICE.txt') + )) { + if (Test-Path -LiteralPath $p) { + Write-Step "Removing $p" + Remove-Item -LiteralPath $p -Recurse -Force + Write-OK "Removed $p" + } + } + + Write-OK 'MelonLoader and ClientMod removed.' + Write-Host ' resources.assets / image signing / anti-cheat byte patches are still applied.' -ForegroundColor DarkGray + Write-Host ' Use tools\patch-client.ps1 -Revert -RecRoomPath ... to roll those back too.' -ForegroundColor DarkGray + exit 0 +} + +# ── 1. MelonLoader install ─────────────────────────────────────────── +function Get-MelonLoaderZip { + if ($MelonLoaderZip) { + if (-not (Test-Path -LiteralPath $MelonLoaderZip -PathType Leaf)) { + Write-Err "MelonLoaderZip '$MelonLoaderZip' not found." + exit 1 + } + return $MelonLoaderZip + } + $cacheDir = Join-Path $env:LOCALAPPDATA 'DorkNet' + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + $cached = Join-Path $cacheDir $Script:MelonLoaderCacheName + if (Test-Path -LiteralPath $cached -PathType Leaf) { + Write-OK "Using cached MelonLoader zip -> $cached" + return $cached + } + Write-Step "Downloading MelonLoader -> $cached" + Write-Host " $Script:MelonLoaderUrl" -ForegroundColor DarkGray + Invoke-WebRequest -Uri $Script:MelonLoaderUrl -OutFile $cached ` + -UseBasicParsing -TimeoutSec 120 -ErrorAction Stop + return $cached +} + +$melonAlreadyInstalled = (Test-Path -LiteralPath (Join-Path $RecRoomRoot 'version.dll') -PathType Leaf) ` + -and (Test-Path -LiteralPath $MelonLoaderDir -PathType Container) + +if ($ResumeBuild) { + if (-not $melonAlreadyInstalled) { + Write-Err 'ResumeBuild requested but MelonLoader is not installed. Run without -ResumeBuild first.' + exit 1 + } + Write-OK 'Resuming after game launch — skipping MelonLoader extract.' +} elseif ($melonAlreadyInstalled -and -not $ForceMelonLoader) { + Write-OK "MelonLoader already installed at $MelonLoaderDir" +} else { + $zipPath = Get-MelonLoaderZip + Write-Step "Extracting MelonLoader -> $RecRoomRoot" + Expand-Archive -LiteralPath $zipPath -DestinationPath $RecRoomRoot -Force + if (-not (Test-Path -LiteralPath (Join-Path $RecRoomRoot 'version.dll'))) { + Write-Err 'MelonLoader extracted but version.dll proxy missing. The downloaded zip may be the wrong shape.' + exit 1 + } + Write-OK 'MelonLoader extracted.' +} + +# ── 2. Make sure UserData + Mods exist, write mod config ──────────── +New-Item -ItemType Directory -Path $UserDataDir -Force | Out-Null +New-Item -ItemType Directory -Path $ModsDir -Force | Out-Null + +$cfgPath = Join-Path $UserDataDir 'dorknet-clientmod.json' +$cfg = [ordered]@{ + ServerHost = $ServerHost + PhotonAppId = $PhotonAppId + PhotonVoiceAppId = if ($PhotonVoiceAppId) { $PhotonVoiceAppId } else { '' } + PhotonCloudRegion = $PhotonCloudRegion + InjectAuthValues = $true + EnableTlsTrustBypass = $true +} +Write-Step "Writing mod config -> $cfgPath" +[System.IO.File]::WriteAllText($cfgPath, ($cfg | ConvertTo-Json -Depth 4)) +Write-OK 'dorknet-clientmod.json written.' + +# ── 3. Byte patches via patch-client.ps1 ───────────────────────────── +if (-not $SkipBytePatches) { + if (-not (Test-Path -LiteralPath $PatchClient -PathType Leaf)) { + Write-Warn2 "patch-client.ps1 not found at $PatchClient — skipping byte patches." + } else { + Write-Step 'Running patch-client.ps1 (Photon AppIds, image signing, anti-cheat)' + $patchArgs = @{ + RecRoomPath = $RecRoomPath + PhotonAppId = $PhotonAppId + } + if ($PhotonVoiceAppId) { $patchArgs.PhotonVoiceAppId = $PhotonVoiceAppId } + if ($ImageSigningPublicKeyPath) { $patchArgs.ImageSigningPublicKeyPath = $ImageSigningPublicKeyPath } + # Seed $LASTEXITCODE so the post-call check works under StrictMode. + # PowerShell only populates $LASTEXITCODE after a native exe runs; + # a pure-cmdlet path through patch-client.ps1 leaves it undefined, + # and reading an undefined variable throws under + # Set-StrictMode -Version Latest. + $global:LASTEXITCODE = 0 + & $PatchClient @patchArgs + $patchExit = $LASTEXITCODE + if ($patchExit -ne 0) { + Write-Err "patch-client.ps1 returned exit code $patchExit" + exit $patchExit + } + } +} + +# ── 4a. Auto-bootstrap MelonLoader to generate Il2CppAssemblies ────── +# +# When MelonLoader extracts for the first time it ships no interop +# assemblies — they only appear once the game boots with the loader +# attached and Cpp2IL + Il2CppInterop.Generator run against +# GameAssembly.dll + il2cpp_data\Metadata\global-metadata.dat. +# Generation finishes a few seconds into startup (long before the +# game's own bootloader gets anywhere interesting), so we can launch +# the .exe, poll for the marker DLL, then kill the process. +# +# Anchor marker: Assembly-CSharp.dll inside Il2CppAssemblies\. That's +# the last assembly the generator writes (after Il2Cppmscorlib + +# Il2CppSystem + all the Unity proxies), so seeing it on disk means +# the rest are already in place. +# +# Caveats called out in the user-facing log line on failure: +# * if Recroom_Release.exe is wrapped with Steam DRM (.bind section) +# it'll silently refuse to launch outside Steam; the user has +# either to keep Steam running OR strip DRM via Steamless first +# (see install-plugin.ps1's Invoke-DrmStrip for that path). +# * anti-cheat WOULD interfere here, but patch-client.ps1 above +# just neutralised it before this step runs. +function Invoke-MelonBootstrap { + param([string]$ExePath, [string]$Il2CppAsmsDir) + + if (-not (Test-Path -LiteralPath $ExePath -PathType Leaf)) { + Write-Err "Recroom_Release.exe not found at $ExePath" + return $false + } + $exeDir = Split-Path -Parent $ExePath + foreach ($required in @( + (Join-Path $exeDir 'version.dll'), + (Join-Path $exeDir 'MelonLoader\Dependencies\Bootstrap.dll'), + (Join-Path $exeDir 'MelonLoader\Dependencies\dobby.dll') + )) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + Write-Err "MelonLoader native dependency missing: $required" + Write-Host ' Re-run this script without -ResumeBuild, or use -ForceMelonLoader to re-extract MelonLoader.' -ForegroundColor DarkGray + return $false + } + } + + Write-Step "Auto-bootstrap: launching Recroom_Release.exe to generate Il2CppAssemblies" + Write-Host ' (the game window will open briefly, then get killed once the proxy' -ForegroundColor DarkGray + Write-Host ' assemblies are on disk. Boot to the watch is NOT required.)' -ForegroundColor DarkGray + + $proc = $null + try { + $proc = Start-Process -FilePath $ExePath -WorkingDirectory $exeDir -PassThru -ErrorAction Stop + } catch { + Write-Err "Failed to launch the game: $($_.Exception.Message)" + return $false + } + + $marker = Join-Path $Il2CppAsmsDir 'Assembly-CSharp.dll' + $timeoutSec = 180 + $pollSec = 2 + $start = Get-Date + + while ($true) { + if ($proc.HasExited) { + Write-Err "Game process exited (code $($proc.ExitCode)) before generating assemblies." + Write-Host ' Common causes: Steam DRM wrapper rejects standalone launch, MelonLoader proxy' -ForegroundColor DarkGray + Write-Host ' DLL got quarantined by Defender, or the game crashed on boot.' -ForegroundColor DarkGray + return $false + } + if (Test-Path -LiteralPath $marker -PathType Leaf) { + # Give Cpp2IL/Il2CppInterop a few extra seconds to finish + # writing the last of the proxy DLLs before we yank the process. + # The marker shows up partway through the generator run; the + # rest of the writes are quick but not instant. + Start-Sleep -Seconds 6 + break + } + $elapsed = (Get-Date) - $start + if ($elapsed.TotalSeconds -gt $timeoutSec) { + Write-Warn2 ("Timed out after {0:n0}s waiting for {1}." -f $timeoutSec, $marker) + try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch {} + return $false + } + Start-Sleep -Seconds $pollSec + } + + try { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + # Wait a beat for filesystem handles to release. + Start-Sleep -Seconds 1 + } catch {} + Write-OK "Il2CppAssemblies generated; game process closed." + return $true +} + +if (-not (Test-Path -LiteralPath $Il2CppAsmsDir -PathType Container) -and $AutoBootstrap -and -not $ResumeBuild) { + # Strip Steam DRM first if the .exe is wrapped. The launch in + # Invoke-MelonBootstrap is detached from Steam, so the SteamStub + # trampoline would reject with exit code 1 (same symptom the user + # gets if they double-click outside Steam too — MelonLoader's + # proxy DLL never gets loaded, no Il2CppAssemblies generated, and + # MelonLoader's first-launch state stays incomplete). + if (Test-SteamDrmWrapped -ExePath $RecRoomExe) { + Write-Warn2 "Recroom_Release.exe is wrapped with Steam DRM." + Write-Step "Stripping DRM so MelonLoader can launch the .exe directly." + try { + Invoke-DrmStrip -ExePath $RecRoomExe + } catch { + Write-Err "DRM strip failed: $($_.Exception.Message)" + Write-Host ' Falling back to the manual two-step flow. Either run' -ForegroundColor DarkGray + Write-Host ' tools\install-plugin.ps1 -InstallBepInEx on this same .exe' -ForegroundColor DarkGray + Write-Host ' (it has the same Steamless logic) or launch the game from Steam' -ForegroundColor DarkGray + Write-Host ' once and re-run with -ResumeBuild.' -ForegroundColor DarkGray + } + } else { + Write-OK "Recroom_Release.exe is already DRM-stripped." + } + + if (-not (Invoke-MelonBootstrap -ExePath $RecRoomExe -Il2CppAsmsDir $Il2CppAsmsDir)) { + Write-Warn2 'Auto-bootstrap failed. Falling through to the manual two-step flow.' + } +} + +# ── 4b. Build DorkNet.ClientMod against Il2CppAssemblies ───────────── +if (-not (Test-Path -LiteralPath $Il2CppAsmsDir -PathType Container)) { + Write-Warn2 "Il2CppAssemblies not generated yet at $Il2CppAsmsDir" + Write-Host '' + Write-Host ' MelonLoader needs ONE successful launch to generate the IL2CPP proxy' -ForegroundColor Yellow + Write-Host ' assemblies the mod builds against. Steps:' -ForegroundColor Yellow + Write-Host '' + Write-Host ' 1. Launch Recroom_Release.exe (just open the game and let it boot' -ForegroundColor Yellow + Write-Host ' to the watch UI; MelonLoader will dump assemblies in the' -ForegroundColor Yellow + Write-Host ' background).' -ForegroundColor Yellow + Write-Host ' 2. Close the game.' -ForegroundColor Yellow + Write-Host ' 3. Re-run this script with -ResumeBuild to finish the mod build.' -ForegroundColor Yellow + Write-Host '' + Write-Host ' (The byte patches and mod config are already in place; only the' -ForegroundColor DarkGray + Write-Host ' compiled mod DLL is missing.)' -ForegroundColor DarkGray + exit 0 +} + +if (-not (Test-Path -LiteralPath $ModCsproj -PathType Leaf)) { + Write-Err "DorkNet.ClientMod csproj not found at $ModCsproj" + exit 1 +} + +Write-Step "Building DorkNet.ClientMod (interop=$Il2CppAsmsDir)" +$buildOutput = & dotnet build $ModCsproj -c Release --nologo ` + -p:MelonLoaderDir=$MelonLoaderDir ` + -p:Il2CppAssembliesDir=$Il2CppAsmsDir 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-Err 'DorkNet.ClientMod build failed. Build output:' + Write-Host ($buildOutput -join "`n") -ForegroundColor DarkGray + exit 1 +} +$builtDll = Join-Path $ModProj 'bin\Release\DorkNet.ClientMod.dll' +if (-not (Test-Path -LiteralPath $builtDll -PathType Leaf)) { + Write-Err "Build succeeded but $builtDll not found." + exit 1 +} + +# ── 5. Copy mod DLL into Mods/ ─────────────────────────────────────── +Copy-Item -LiteralPath $builtDll -Destination $ModsDir -Force +Write-OK ("Mod DLL copied -> " + (Join-Path $ModsDir 'DorkNet.ClientMod.dll')) + +# ── 6. Summary ─────────────────────────────────────────────────────── +Write-Host '' +Write-OK 'Install complete.' +Write-Host (' MelonLoader : ' + $MelonLoaderDir) -ForegroundColor DarkGray +Write-Host (' Mods folder : ' + $ModsDir) -ForegroundColor DarkGray +Write-Host (' Mod config : ' + $cfgPath) -ForegroundColor DarkGray +Write-Host (' Photon AppId : ' + $PhotonAppId) -ForegroundColor DarkGray +Write-Host (' Server host : ' + $ServerHost) -ForegroundColor DarkGray +Write-Host '' +Write-Host ' Launch Recroom_Release.exe. MelonLoader log appears in:' -ForegroundColor DarkGray +Write-Host (' ' + (Join-Path $MelonLoaderDir 'Latest.log')) -ForegroundColor DarkGray diff --git a/tools/install-plugin.ps1 b/tools/install-plugin.ps1 new file mode 100644 index 0000000..6bdf3bd --- /dev/null +++ b/tools/install-plugin.ps1 @@ -0,0 +1,596 @@ +<# +.SYNOPSIS + BepInEx-only setup for DorkNet ClientPatch. + +.DESCRIPTION + Replaces the legacy patch-client.ps1 (asset-byte-edit + hosts file + + mkcert wildcard). Now everything lives in the + DorkNet.ClientPatch BepInEx plugin: Photon AppId override, *.rec.net + → user-configured-host URL rewrite, and Photon CustomAuth identity + injection. + + After this script runs once successfully, all you need to do on a + client version update is download the new build and re-run + this script — it'll re-extract BepInEx and rebuild the plugin + against the new game's interop assemblies. No more hardcoded + resources.assets offsets, no more hosts file, no more wildcard + cert. + +.PARAMETER RecRoomPath + Path to the Recroom_Release_Data folder (the one containing + resources.assets). The patcher uses this to locate the actual + install root (its parent directory). + +.PARAMETER BepInExZip + Optional path to a BepInEx 6.x IL2CPP zip. Only needed if you want + to override the pinned build the script auto-downloads. On first + install (or when -ForceBepInEx is set), the script fetches a + pinned BepInEx build from builds.bepinex.dev and caches it under + %LOCALAPPDATA%\DorkNet so repeated runs don't re-download. Pass + this only if you need a specific (newer/older) build. + +.PARAMETER ServerHost + Domain that replaces *.rec.net in every URL the watch builds. + Default "localhost" — set to your own apex if hosting elsewhere. + +.PARAMETER PhotonAppId + Photon Cloud Realtime AppId (GUID without curly braces). + +.PARAMETER PhotonVoiceAppId + Photon Cloud Voice AppId. Defaults to PhotonAppId. + +.PARAMETER ForceBepInEx + Re-extract BepInEx even if it's already installed. Use after a client + update. + +.PARAMETER Revert + Remove BepInEx + the plugin from the client install. Doesn't + touch the game's own files. + +.EXAMPLE + # First-time install — script auto-downloads BepInEx + .\tools\install-plugin.ps1 ` + -RecRoomPath "C:\Program Files (x86)\Steam\steamapps\common\RecRoom\Recroom_Release_Data" ` + -PhotonAppId + +.EXAMPLE + # Override the pinned BepInEx build with your own zip + .\tools\install-plugin.ps1 ` + -RecRoomPath "C:\…\Recroom_Release_Data" ` + -BepInExZip "C:\Downloads\BepInEx-Unity.IL2CPP-win-x64-6.0.0-be.NNN.zip" ` + -PhotonAppId cb0880d9-… + +.EXAMPLE + # After a client update (interop changed) — re-run with + # the same args, optionally adding -ForceBepInEx if BepInEx itself + # got clobbered. + .\tools\install-plugin.ps1 ` + -RecRoomPath "C:\…\Recroom_Release_Data" ` + -PhotonAppId cb0880d9-… + +.EXAMPLE + # Remove the plugin + .\tools\install-plugin.ps1 -RecRoomPath D:\RecRoom\Recroom_Release_Data -Revert +#> + +[CmdletBinding(DefaultParameterSetName = 'Install')] +param( + [Parameter(Mandatory = $true)] + [string]$RecRoomPath, + + [Parameter(ParameterSetName = 'Install')] + [string]$BepInExZip, + + [Parameter(ParameterSetName = 'Install')] + [string]$ServerHost = 'localhost', + + [Parameter(Mandatory = $true, ParameterSetName = 'Install')] + [ValidatePattern('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')] + [string]$PhotonAppId, + + [Parameter(ParameterSetName = 'Install')] + [ValidatePattern('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')] + [string]$PhotonVoiceAppId, + + [Parameter(ParameterSetName = 'Install')] + [switch]$ForceBepInEx, + + # Tell the plugin to fake the Steamworks SDK so the watch boots + # without Steam.exe running. Pure C#-level Harmony patches inside + # the plugin DLL — no replacement steam_api64.dll, no Goldberg / + # SmartSteamEmu (which Defender flags as PUA). Default off so + # Steam-launched runs behave normally. + [Parameter(ParameterSetName = 'Install')] + [switch]$FakeSteamApi, + + # SteamID64 the plugin should report from SteamUser.GetSteamID + # when -FakeSteamApi is on. Empty = deterministic per-machine ID + # derived from OS user + machine name. Pin an explicit value to + # keep the same DorkNet account across machines. + [Parameter(ParameterSetName = 'Install')] + [string]$FakeSteamId, + + # Display name returned from SteamFriends.GetPersonaName when + # -FakeSteamApi is on. Empty = OS user name. + [Parameter(ParameterSetName = 'Install')] + [string]$FakeAccountName, + + [Parameter(Mandatory = $true, ParameterSetName = 'Revert')] + [switch]$Revert +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Pinned BepInEx 6 IL2CPP win-x64 build. Auto-downloaded when neither +# BepInEx is already installed nor -BepInExZip is provided. Pin a +# specific build rather than 'latest' so a regression upstream +# doesn't silently land on tester machines. +$Script:BepInExUrl = 'https://builds.bepinex.dev/projects/bepinex_be/755/BepInEx-Unity.IL2CPP-win-x64-6.0.0-be.755%2B3fab71a.zip' +$Script:BepInExCacheName = 'BepInEx-Unity.IL2CPP-win-x64-6.0.0-be.755.zip' + +# Pinned Steamless release. atom0s ships it as a single zip with a +# CLI exe inside. Cached alongside BepInEx under %LOCALAPPDATA%\DorkNet +# so a one-time download per machine handles both DRM-stripping and +# mod loading. +$Script:SteamlessUrl = 'https://github.com/atom0s/Steamless/releases/download/v3.1.0.5/Steamless.v3.1.0.5.-.by.atom0s.zip' +$Script:SteamlessCacheName = 'Steamless.v3.1.0.5.zip' + +# NOTE: Goldberg Steam Emu integration was tried and removed. +# Windows Defender flags every Steam emulator as PUA and +# quarantines mid-download. The "boot without Steam running" +# capability now lives entirely inside DorkNet.ClientPatch — +# Harmony patches on the Steamworks.NET C# wrappers +# (SteamAPI.Init, SteamUser.GetSteamID, GetAuthSessionTicket, …) +# so the watch's SteamPlatformManager.Initialize succeeds without +# Steam.exe running. Toggled via the -FakeSteamApi switch on this +# script (writes the [Steam] FakeSteamApi=true config the plugin +# reads at load). + +function Write-Step($msg) { Write-Host ('▸ ' + $msg) -ForegroundColor Cyan } +function Write-OK($msg) { Write-Host ('✓ ' + $msg) -ForegroundColor Green } +function Write-Warn2($msg) { Write-Host ('! ' + $msg) -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host ('✗ ' + $msg) -ForegroundColor Red } + +function Test-SteamDrmWrapped { + # Detects the Steamworks DRM wrapper by checking whether the PE's + # AddressOfEntryPoint falls inside a '.bind' section. Valve's + # drm_wrap tool injects .bind carrying the DRM stub and rewrites + # the entry point to land there; SteamStub jumps to the real + # .text once it's done. After Steamless strips, the entry point + # is rewritten back into .text and .bind is left as dead weight + # (the section header survives but execution never visits it), + # so we'd false-positive if we just looked at .bind's size. + # + # PE layout walked here (offsets relative to start of file): + # e_lfanew @ 0x3C → 4-byte offset to PE header + # PE+0 "PE\0\0" + COFF header (Sections @ +6, OptHeader size @ +20) + # OptionalHeader+0x10 = AddressOfEntryPoint (RVA) + # Section table starts at PE+24+OptHeaderSize, 40 bytes/entry: + # Name(8) VirtSize(4) VirtAddr(4) RawSize(4) RawPtr(4) ... + param([string]$ExePath) + try { + $fs = [System.IO.File]::OpenRead($ExePath) + try { + $br = New-Object System.IO.BinaryReader($fs) + $fs.Seek(0x3C, 'Begin') | Out-Null + $peOff = $br.ReadInt32() + if ($peOff -lt 0 -or $peOff + 24 -gt $fs.Length) { return $false } + $fs.Seek($peOff + 6, 'Begin') | Out-Null + $sections = $br.ReadUInt16() + $fs.Seek($peOff + 20, 'Begin') | Out-Null + $optSize = $br.ReadUInt16() + $fs.Seek($peOff + 24 + 16, 'Begin') | Out-Null + $entryRva = $br.ReadUInt32() + $secStart = $peOff + 24 + $optSize + for ($i = 0; $i -lt $sections; $i++) { + $fs.Seek($secStart + $i * 40, 'Begin') | Out-Null + $name = [System.Text.Encoding]::ASCII.GetString($br.ReadBytes(8)).TrimEnd([char]0) + $vSize = $br.ReadUInt32() + $vAddr = $br.ReadUInt32() + if ($name -eq '.bind' -and $entryRva -ge $vAddr -and $entryRva -lt ($vAddr + $vSize)) { + return $true + } + } + return $false + } finally { $fs.Close() } + } catch { + return $false + } +} + + +function Get-Steamless { + # Returns the path to a usable Steamless.CLI.exe. Downloads + extracts + # the pinned release on first run, caches under %LOCALAPPDATA%\DorkNet. + param() + $cacheDir = Join-Path $env:LOCALAPPDATA 'DorkNet' + $extractDir = Join-Path $cacheDir 'Steamless' + $cli = Join-Path $extractDir 'Steamless.CLI.exe' + if (Test-Path -LiteralPath $cli -PathType Leaf) { + return $cli + } + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + $zipPath = Join-Path $cacheDir $Script:SteamlessCacheName + if (-not (Test-Path -LiteralPath $zipPath -PathType Leaf)) { + Write-Step "Downloading Steamless → $zipPath" + Write-Host " $Script:SteamlessUrl" -ForegroundColor DarkGray + try { + Invoke-WebRequest -Uri $Script:SteamlessUrl -OutFile $zipPath ` + -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop + } catch { + Write-Err "Steamless download failed: $($_.Exception.Message)" + Write-Host ' Manually download from https://github.com/atom0s/Steamless/releases' -ForegroundColor DarkGray + Write-Host " and extract to $extractDir, then re-run." -ForegroundColor DarkGray + throw + } + } + Write-Step "Extracting Steamless → $extractDir" + Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force + if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) { + # Some Steamless zips put the CLI inside a subfolder; locate it. + $found = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'Steamless.CLI.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($found) { $cli = $found.FullName } + } + if (-not (Test-Path -LiteralPath $cli -PathType Leaf)) { + throw "Steamless.CLI.exe not found after extracting $zipPath" + } + return $cli +} + +function Invoke-DrmStrip { + # Runs Steamless against the wrapped exe in-place. Steamless writes + # an unpacked copy alongside the original; we move the original to + # .drm-original.exe and rename the unpacked file in place so the + # rest of the script doesn't have to know which is which. + param([string]$ExePath) + $cli = Get-Steamless + Write-Step "Stripping Steam DRM from $ExePath" + $exeDir = Split-Path -Parent $ExePath + # Steamless emits .exe.unpacked.exe by default. Run with + # --quiet to keep our log clean; --keepbind 0 lets it strip the + # .bind section so subsequent runs don't re-trigger the detector. + $proc = Start-Process -FilePath $cli ` + -ArgumentList @('--quiet', '--keepbind', '0', $ExePath) ` + -NoNewWindow -PassThru -Wait + if ($proc.ExitCode -ne 0) { + throw "Steamless exited with code $($proc.ExitCode). The exe may not be a recognised Steam DRM build." + } + $unpacked = "$ExePath.unpacked.exe" + if (-not (Test-Path -LiteralPath $unpacked -PathType Leaf)) { + throw "Steamless ran but didn't produce $unpacked. Check the tool's output." + } + $bak = Join-Path $exeDir 'Recroom_Release.drm-original.exe' + Move-Item -LiteralPath $ExePath -Destination $bak -Force + Move-Item -LiteralPath $unpacked -Destination $ExePath -Force + Write-OK "DRM stripped. Original kept as $bak" +} + +function Get-BepInExZip { + # Returns a path to a usable BepInEx zip. Reuses a cached download + # under %LOCALAPPDATA%\DorkNet so a repeated install on the same + # box doesn't re-download the ~9 MB blob each run. + param() + $cacheDir = Join-Path $env:LOCALAPPDATA 'DorkNet' + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + $cached = Join-Path $cacheDir $Script:BepInExCacheName + if (Test-Path -LiteralPath $cached -PathType Leaf) { + Write-OK "Using cached BepInEx zip → $cached" + return $cached + } + Write-Step "Downloading BepInEx → $cached" + Write-Host " $Script:BepInExUrl" -ForegroundColor DarkGray + try { + # -UseBasicParsing for compatibility with Windows PowerShell 5.1 + # (PS7 ignores it). 90-second timeout — the file is small but a + # cold builds.bepinex.dev cdn can be sluggish from some regions. + Invoke-WebRequest -Uri $Script:BepInExUrl -OutFile $cached ` + -UseBasicParsing -TimeoutSec 90 -ErrorAction Stop + } catch { + Write-Err "Download failed: $($_.Exception.Message)" + Write-Host ' Manually download the zip and pass it via -BepInExZip .' -ForegroundColor DarkGray + Write-Host " Releases page: https://github.com/BepInEx/BepInEx/releases" -ForegroundColor DarkGray + throw + } + $size = (Get-Item -LiteralPath $cached).Length + Write-OK ("Downloaded {0:N1} MB" -f ($size / 1MB)) + return $cached +} + +# Resolve via a two-step assignment instead of the ?. null-conditional +# operator — that's PS 7+ only and we want to support Windows +# PowerShell 5.1 (still the default on stock Win10/11). +$resolved = Resolve-Path -LiteralPath $RecRoomPath -ErrorAction SilentlyContinue +if ($resolved) { $RecRoomPath = $resolved.Path } else { $RecRoomPath = $null } +if (-not $RecRoomPath -or -not (Test-Path -LiteralPath $RecRoomPath -PathType Container)) { + Write-Err "RecRoomPath '$RecRoomPath' does not exist." + exit 1 +} + +# RecRoomPath = …\Recroom_Release_Data, BepInEx lives one level up +$RecRoomRoot = Split-Path -Parent $RecRoomPath +$BepInExDir = Join-Path $RecRoomRoot 'BepInEx' +$ConfigDir = Join-Path $BepInExDir 'config' +$PluginsDir = Join-Path $BepInExDir 'plugins' +$InteropDir = Join-Path $BepInExDir 'interop' +$RepoRoot = Split-Path -Parent $PSScriptRoot +$PluginProj = Join-Path $RepoRoot 'DorkNet.ClientPatch' + +if ($Revert) { + Write-Step "Removing BepInEx + plugin from $RecRoomRoot" + if (Test-Path -LiteralPath $BepInExDir -PathType Container) { + Remove-Item -LiteralPath $BepInExDir -Recurse -Force + Write-OK "Removed $BepInExDir" + } + foreach ($leaf in @('winhttp.dll', 'doorstop_config.ini', '.doorstop_version', 'changelog.txt')) { + $p = Join-Path $RecRoomRoot $leaf + if (Test-Path -LiteralPath $p -PathType Leaf) { + Remove-Item -LiteralPath $p -Force + Write-OK "Removed $leaf" + } + } + # Restore the pristine global-metadata.dat from the backup we made + # before host-patching, so the watch points at *.rec.net again. + $meta = Join-Path $RecRoomPath 'il2cpp_data\Metadata\global-metadata.dat' + $backup = $meta + '.dorknet-original' + if (Test-Path -LiteralPath $backup -PathType Leaf) { + Copy-Item -LiteralPath $backup -Destination $meta -Force + Remove-Item -LiteralPath $backup -Force + Write-OK 'Restored stock global-metadata.dat from backup.' + } + Write-OK 'Plugin reverted. Rec Room is now stock.' + exit 0 +} + +if (-not (Test-Path -LiteralPath $PluginProj -PathType Container)) { + Write-Err "Plugin project missing at '$PluginProj'. Run from a DorkNet repo checkout." + exit 1 +} + +# steam_appid.txt — Steamworks SDK uses this file as "app id is +# pre-configured, don't relaunch via Steam." Without it, even a +# DRM-stripped binary still calls SteamAPI.RestartAppIfNecessary +# at startup and gets handed back to Steam.exe before the .NET +# payload (and therefore our plugin) has a chance to run. Drop it +# next to the exe before we test the DRM-strip path. +$AppIdFile = Join-Path $RecRoomRoot 'steam_appid.txt' +if (-not (Test-Path -LiteralPath $AppIdFile -PathType Leaf)) { + '471710' | Out-File -LiteralPath $AppIdFile -Encoding ASCII -NoNewline + Write-OK "Wrote $AppIdFile (Rec Room AppID 471710 — skips Steam relaunch on boot)" +} + +# DRM check + auto-strip — Steamworks DRM stub runs before BepInEx's +# winhttp.dll doorstop fires, so a stock Steam-wrapped exe would just +# exit and ask Steam to relaunch (BepInEx + plugin never get a chance). +# Detect via .bind entry-point check, then run Steamless against it. +# Original kept as Recroom_Release.drm-original.exe. +$RecRoomExe = Join-Path $RecRoomRoot 'Recroom_Release.exe' +if (Test-Path -LiteralPath $RecRoomExe -PathType Leaf) { + if (Test-SteamDrmWrapped -ExePath $RecRoomExe) { + Write-Step 'Steam DRM wrapper detected on Recroom_Release.exe — stripping with Steamless' + Invoke-DrmStrip -ExePath $RecRoomExe + # Sanity-check: if Steamless somehow left .bind in place, bail. + if (Test-SteamDrmWrapped -ExePath $RecRoomExe) { + Write-Err 'Stripped exe still reports a .bind section — Steamless may not support this build.' + Write-Host ' Manually strip with the Steamless GUI and re-run this script.' -ForegroundColor DarkGray + exit 1 + } + Write-OK 'Recroom_Release.exe is now DRM-stripped.' + } else { + Write-OK 'Recroom_Release.exe is DRM-stripped (no .bind section).' + } +} + +# Steam-without-Steam.exe handling now lives inside the plugin via +# the [Steam] FakeSteamApi=true config — no DLL replacement, no +# Goldberg AV trip. We just write the config in step 4 below. + +# ── 0b. Patch ns.rec.net / www.rec.net in IL2CPP global metadata ────────── +# The watch hard-codes the bootstrap URL "https://ns.rec.net/?v=2" as a +# string literal in IL2CPP global-metadata.dat. It builds the URL via +# BestHTTP.HTTPRequest directly with the raw string — never goes through +# `new System.Uri(string)` — so the plugin's URL-rewrite Harmony patch +# can't see it. Same for the WWW link "www.rec.net". Both literals are +# the same length as their localhost equivalents, so we can byte-replace +# in place without shifting offsets. +# +# This is the ONLY hardcoded-byte edit the install does, and it's +# auto-detected & re-applied on every install run. The original file +# is preserved as `global-metadata.dat.dorknet-original` so -Revert +# can put it back. +function Invoke-MetadataHostPatch { + param([string]$DataDir, [string]$ServerHost) + $meta = Join-Path $DataDir 'il2cpp_data\Metadata\global-metadata.dat' + if (-not (Test-Path -LiteralPath $meta -PathType Leaf)) { + Write-Warn2 "global-metadata.dat not found at $meta — skipping host byte-patch." + return + } + $backup = $meta + '.dorknet-original' + if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { + Copy-Item -LiteralPath $meta -Destination $backup + } + # Always patch from the pristine backup so re-runs are idempotent and + # don't accidentally compound substitutions if the host changes. + $bytes = [System.IO.File]::ReadAllBytes($backup) + $subs = @( + @{ Old = 'ns.rec.net'; New = "ns.$ServerHost" }, + @{ Old = 'www.rec.net'; New = "www.$ServerHost" } + ) + $totalReplacements = 0 + foreach ($s in $subs) { + $oldB = [System.Text.Encoding]::ASCII.GetBytes($s.Old) + $newB = [System.Text.Encoding]::ASCII.GetBytes($s.New) + if ($oldB.Length -ne $newB.Length) { + Write-Err "Host '$($s.Old)' (len $($oldB.Length)) and '$($s.New)' (len $($newB.Length)) differ in length — refusing to patch." + return + } + $i = 0 + while ($i -le $bytes.Length - $oldB.Length) { + $match = $true + for ($k = 0; $k -lt $oldB.Length; $k++) { + if ($bytes[$i + $k] -ne $oldB[$k]) { $match = $false; break } + } + if ($match) { + [Array]::Copy($newB, 0, $bytes, $i, $newB.Length) + $totalReplacements++ + $i += $newB.Length + } else { + $i++ + } + } + } + [System.IO.File]::WriteAllBytes($meta, $bytes) + Write-OK "global-metadata.dat host-patched ($totalReplacements substitution$( if ($totalReplacements -ne 1) { 's' }))." +} +Invoke-MetadataHostPatch -DataDir $RecRoomPath -ServerHost $ServerHost + +# ── 1. BepInEx install ──────────────────────────────────────────────────── +$bepInExPresent = Test-Path -LiteralPath $BepInExDir -PathType Container +if (-not $bepInExPresent -or $ForceBepInEx) { + # Auto-fetch the pinned build when the operator didn't supply one. + # First-run UX: just `install-plugin.ps1 -RecRoomPath ... -PhotonAppId ...` + # works without the operator having to manually download a zip. + if (-not $BepInExZip) { + $BepInExZip = Get-BepInExZip + } + if (-not (Test-Path -LiteralPath $BepInExZip -PathType Leaf)) { + Write-Err "BepInExZip '$BepInExZip' not found." + exit 1 + } + if ($ForceBepInEx -and $bepInExPresent) { + Write-Step "Removing existing BepInEx install (-ForceBepInEx)" + Remove-Item -LiteralPath $BepInExDir -Recurse -Force + } + Write-Step "Extracting BepInEx → $RecRoomRoot" + Expand-Archive -LiteralPath $BepInExZip -DestinationPath $RecRoomRoot -Force + Write-OK 'BepInEx extracted.' +} else { + Write-OK "BepInEx already installed at $BepInExDir" +} + +# ── 2. Interop assemblies ───────────────────────────────────────────────── +if (-not (Test-Path -LiteralPath $InteropDir -PathType Container)) { + Write-Warn2 'Interop assemblies not found.' + Write-Host ' Launch the client ONCE so BepInEx generates them, then re-run this script.' -ForegroundColor DarkGray + Write-Host ' (One-time step per game version — takes ~30s on first launch.)' -ForegroundColor DarkGray + exit 0 +} +Write-OK "Interop assemblies present at $InteropDir" + +# ── 3. Build plugin ─────────────────────────────────────────────────────── +$env:BepInExInterop = $InteropDir +$env:BepInExCore = Join-Path $BepInExDir 'core' +Write-Step 'Building DorkNet.ClientPatch' +$build = & dotnet build $PluginProj -c Release --nologo 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-Err 'Plugin build failed:' + Write-Host ($build -join "`n") -ForegroundColor DarkGray + exit 1 +} +$pluginDll = Join-Path $PluginProj 'bin\Release\DorkNet.ClientPatch.dll' +if (-not (Test-Path -LiteralPath $pluginDll -PathType Leaf)) { + Write-Err "Plugin DLL not produced at $pluginDll" + exit 1 +} +New-Item -ItemType Directory -Path $PluginsDir -Force | Out-Null +$oldPluginDll = Join-Path $PluginsDir ('DorkNet.' + 'Rec' + 'RoomPatch.dll') +if (Test-Path -LiteralPath $oldPluginDll -PathType Leaf) { + Remove-Item -LiteralPath $oldPluginDll -Force + Write-OK 'Removed old client patch plugin.' +} +Copy-Item -LiteralPath $pluginDll -Destination $PluginsDir -Force +Write-OK ('Plugin DLL → ' + (Join-Path $PluginsDir 'DorkNet.ClientPatch.dll')) + +# ── 4. Plugin config ────────────────────────────────────────────────────── +# Write the BepInEx config file with the operator's chosen values. +# Format mirrors what the plugin's Config.Bind defaults produce on first +# run, so editing afterwards via BepInEx config manager works as expected. +$cfgFile = Join-Path $ConfigDir 'sh.dork.clientpatch.cfg' +New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null + +$voiceId = if ($PhotonVoiceAppId) { $PhotonVoiceAppId } else { '' } +$cfg = @" +## Settings file was created by DorkNet install-plugin.ps1 +## Plugin GUID: sh.dork.clientpatch + +[Server] + +## Domain that replaces *.rec.net in every URL the watch builds. +## Empty disables the rewrite (use only if pointing at the official servers). +# Setting type: String +# Default value: localhost +Host = $ServerHost + +[Photon] + +## Photon Cloud Realtime AppId (UUID, no curly braces). +## Empty leaves the AppId baked into the build untouched. +# Setting type: String +# Default value: +AppId = $PhotonAppId + +## Photon Cloud Voice AppId. Empty falls back to AppId. +# Setting type: String +# Default value: +VoiceAppId = $voiceId + +## Attach userid + LoginLock to PhotonNetwork.AuthValues right before +## OpAuthenticate so DorkNet's /photon/customauth endpoint can identify +## the player. Disable to debug pure Photon connectivity issues. +# Setting type: Boolean +# Default value: true +InjectAuthValues = true + +[UI] + +## Replacement branding string. Any UI label containing 'RecNet' +## gets that substring replaced with this value at runtime, so error +## dialogs and the loading screen show the private-server brand. +## Empty disables the rewrite. +# Setting type: String +# Default value: DorkNet +BrandName = DorkNet + +[Steam] + +## Bypass Steamworks.SteamAPI.RestartAppIfNecessary so the watch +## doesn't relaunch itself through Steam on boot. No effect when +## launched normally from the Steam library; useful when running the +## game directly via Recroom_Release.exe (BepInEx mod-loader shims +## often need this to avoid an infinite restart loop). +# Setting type: Boolean +# Default value: true +SkipRestartAppIfNecessary = true + +## Pretend the Steam SDK initialised even when Steam.exe isn't +## running. Patches Steamworks.NET wrappers to return canned success +## values so the watch's SteamPlatformManager.Initialize succeeds +## without a live Steam process. DorkNet's auth backend uses SteamID +## as a lookup key only — no real ticket validation — so the fake +## ticket is accepted. Default off so launches via Steam itself +## behave normally. +# Setting type: Boolean +# Default value: false +FakeSteamApi = $($FakeSteamApi.IsPresent.ToString().ToLower()) + +## SteamID64 returned by SteamUser.GetSteamID when FakeSteamApi=true. +## Empty = deterministic value derived from OS user + machine name. +## Pin an explicit ID to keep the same DorkNet account across +## different machines. +# Setting type: String +# Default value: +FakeSteamId = $FakeSteamId + +## Display name returned by SteamFriends.GetPersonaName when +## FakeSteamApi=true. Empty = OS user name. +# Setting type: String +# Default value: +FakeAccountName = $FakeAccountName +"@ +$cfg | Out-File -LiteralPath $cfgFile -Encoding utf8 -Force +Write-OK "Plugin config → $cfgFile" + +Write-Host '' +Write-OK 'All done. Launch the client from Steam.' +Write-Host ' Tail BepInEx/LogOutput.log for `[auth-injector] set Photon AuthValues …`' -ForegroundColor DarkGray +Write-Host ' to confirm the plugin loaded and ran.' -ForegroundColor DarkGray diff --git a/tools/setup-cloudflared.ps1 b/tools/setup-cloudflared.ps1 new file mode 100644 index 0000000..7984c9c --- /dev/null +++ b/tools/setup-cloudflared.ps1 @@ -0,0 +1,229 @@ +<# +.SYNOPSIS + Stand up a Cloudflare Tunnel that routes every *.localhost request to + the local DorkNet server. One-shot setup script for temp testing. + +.DESCRIPTION + Performs the steps a tester would otherwise do by hand: + + 1. Installs cloudflared if missing (via winget). + 2. Runs `cloudflared tunnel login` (browser auth — one-time). + 3. Creates a named tunnel "dorknet" (idempotent — re-uses an + existing tunnel of the same name). + 4. Generates %USERPROFILE%\.cloudflared\config.yml from the + template at tools/cloudflared-config.yml.template, substituting + the tunnel id + credentials file path. + 5. Adds DNS routes for every localhost subdomain DorkNet uses + (api, auth, match, accounts, admin, feed, …). Cloudflare + creates each as a CNAME pointing at .cfargotunnel.com. + 6. Prints the next steps to start the tunnel. + + Prerequisite: localhost must be on a Cloudflare account you control, + with the nameservers pointed at Cloudflare. The CLI works against + any zone the logged-in account can manage. + +.PARAMETER TunnelName + The name to give the tunnel. Default 'dorknet'. + +.PARAMETER Domain + Apex domain to route. Default 'localhost'. + +.PARAMETER DryRun + Print every command that would run without executing. + +.EXAMPLE + PS> .\tools\setup-cloudflared.ps1 +#> + +[CmdletBinding()] +param( + [string]$TunnelName = 'dorknet', + [string]$Domain = 'localhost', + [switch]$DryRun +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Write-Step($msg) { Write-Host ('▸ ' + $msg) -ForegroundColor Cyan } +function Write-OK($msg) { Write-Host ('✓ ' + $msg) -ForegroundColor Green } +function Write-Warn2($msg) { Write-Host ('! ' + $msg) -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host ('✗ ' + $msg) -ForegroundColor Red } +function Write-DryRun($msg) { Write-Host ('[dry-run] ' + $msg) -ForegroundColor DarkGray } + +# cloudflared writes harmless warnings (e.g. "Your version is outdated") +# to stderr. With ErrorActionPreference='Stop' globally, the 2>&1 +# merge would surface those as "NativeCommandError" terminating +# errors. Wrap every cloudflared invocation in this helper so stderr +# becomes informational output and only a non-zero exit is treated +# as a failure. +function Invoke-Cloudflared { + param([Parameter(ValueFromRemainingArguments = $true)] $CfArgs) + $prev = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $output = & cloudflared @CfArgs 2>&1 | ForEach-Object { $_.ToString() } + $exit = $LASTEXITCODE + return [PSCustomObject]@{ + Output = $output + ExitCode = $exit + } + } finally { + $ErrorActionPreference = $prev + } +} + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$Template = Join-Path $PSScriptRoot 'cloudflared-config.yml.template' +$CfDir = Join-Path $env:USERPROFILE '.cloudflared' +$ConfigPath = Join-Path $CfDir 'config.yml' + +# Subdomains we want routed through the tunnel. The wildcard route in +# config.yml catches everything; this list drives the per-subdomain +# DNS CNAMEs Cloudflare needs (no automatic wildcard CNAME via the +# CLI — each one is created explicitly via `cloudflared tunnel route +# dns`). +$Subdomains = @( + '@', # apex (localhost itself) + 'admin', 'api', 'accounts', 'auth', 'cdn', 'chat', 'commerce', + 'discovery', 'econ', 'feed', 'geo', 'img', 'match', 'notify', + 'ns', 'playersettings', 'rooms', 'storage', 'strings', 'strings-cdn', + # Long-tail subdomains DorkNet's stub controllers ack — adding + # CNAMEs for these means the patched client never hits a NXDOMAIN + # for any of the URLs it might probe. + 'bugreporting', 'cards', 'clubs', 'cms', 'data', 'datacollection', + 'gamelogs', 'leaderboard', 'link', 'lists', 'moderation', + 'platformnotifications', 'roomcomments', 'roomieintegrations', + 'studio', 'thorn', 'videos' +) + +# ── 1. cloudflared install ──────────────────────────────────────────────────── +Write-Step 'Checking cloudflared' +$cf = Get-Command cloudflared -ErrorAction SilentlyContinue +if (-not $cf) { + Write-Warn2 'cloudflared not in PATH.' + $winget = Get-Command winget -ErrorAction SilentlyContinue + if ($winget) { + if ($DryRun) { + Write-DryRun 'Would run: winget install --id Cloudflare.cloudflared --silent' + } else { + Write-Step 'Installing via winget' + & winget install --id Cloudflare.cloudflared --silent --accept-package-agreements --accept-source-agreements + # winget puts it in PATH but not in the current shell session + $env:PATH = "$env:PATH;$env:LOCALAPPDATA\Microsoft\WinGet\Links" + $cf = Get-Command cloudflared -ErrorAction SilentlyContinue + if (-not $cf) { + Write-Err 'cloudflared still not found after install. Open a new shell and re-run.' + exit 1 + } + } + } else { + Write-Err 'winget not found either. Install cloudflared manually:' + Write-Host ' https://github.com/cloudflare/cloudflared/releases' -ForegroundColor DarkGray + exit 1 + } +} +$cfSource = if ($cf) { $cf.Source } else { 'just-installed' } +Write-OK ('cloudflared at ' + $cfSource) + +# ── 2. Login ────────────────────────────────────────────────────────────────── +$certPath = Join-Path $CfDir 'cert.pem' +if (-not (Test-Path -LiteralPath $certPath)) { + Write-Step 'Cloudflare login (browser will open — pick the localhost zone)' + if ($DryRun) { + Write-DryRun 'Would run: cloudflared tunnel login' + } else { + $login = Invoke-Cloudflared tunnel login + $login.Output | ForEach-Object { Write-Host $_ -ForegroundColor DarkGray } + if (-not (Test-Path -LiteralPath $certPath)) { + Write-Err 'cert.pem still missing — login was cancelled or failed.' + exit 1 + } + } +} +Write-OK ('Auth cert present at ' + $certPath) + +# ── 3. Tunnel create / look up ───────────────────────────────────────────────── +Write-Step ("Looking up tunnel '" + $TunnelName + "'") +if ($DryRun) { + Write-DryRun ('Would run: cloudflared tunnel list and create ' + $TunnelName + ' if missing') + $tunnelId = '{{TUNNEL_ID}}' + $credsFile = '{{CREDENTIALS_FILE}}' +} else { + # `cloudflared tunnel list` outputs human-readable text by default; + # parsing the table is fragile but works. We grep for our name and + # extract the UUID. If not found, create. + $list = Invoke-Cloudflared tunnel list + $existingLine = $list.Output | Select-String -Pattern ('\b' + [regex]::Escape($TunnelName) + '\b') + if ($existingLine) { + # Format: " " + $tunnelId = ($existingLine.Line -split '\s+')[0] + Write-OK ('Tunnel exists: ' + $tunnelId) + } else { + Write-Step ("Creating tunnel '" + $TunnelName + "'") + $create = Invoke-Cloudflared tunnel create $TunnelName + $create.Output | ForEach-Object { Write-Host $_ -ForegroundColor DarkGray } + $list = Invoke-Cloudflared tunnel list + $existingLine = $list.Output | Select-String -Pattern ('\b' + [regex]::Escape($TunnelName) + '\b') + if (-not $existingLine) { + Write-Err 'Tunnel create failed; cloudflared list still empty.' + exit 1 + } + $tunnelId = ($existingLine.Line -split '\s+')[0] + Write-OK ('Created tunnel ' + $tunnelId) + } + $credsFile = Join-Path $CfDir ($tunnelId + '.json') + if (-not (Test-Path -LiteralPath $credsFile)) { + Write-Warn2 ('Credentials file missing: ' + $credsFile) + } +} + +# ── 4. config.yml from template ─────────────────────────────────────────────── +Write-Step ('Writing ' + $ConfigPath) +if (-not (Test-Path -LiteralPath $Template)) { + Write-Err ('Template missing: ' + $Template) + exit 1 +} +if (-not (Test-Path -LiteralPath $CfDir)) { + if (-not $DryRun) { New-Item -ItemType Directory -Path $CfDir -Force | Out-Null } +} +$tmpl = Get-Content -LiteralPath $Template -Raw +$config = $tmpl ` + -replace '\{\{TUNNEL_ID\}\}', $tunnelId ` + -replace '\{\{CREDENTIALS_FILE\}\}', ($credsFile -replace '\\', '\\') +if ($DryRun) { + Write-DryRun ('Would write ' + $ConfigPath) + Write-Host $config -ForegroundColor DarkGray +} else { + Set-Content -LiteralPath $ConfigPath -Value $config -NoNewline + Write-OK 'Config written.' +} + +# ── 5. DNS routes ────────────────────────────────────────────────────────────── +Write-Step ('Adding DNS CNAMEs on ' + $Domain) +foreach ($sub in $Subdomains) { + $hostname = if ($sub -eq '@') { $Domain } else { "$sub.$Domain" } + if ($DryRun) { + Write-DryRun ("Would run: cloudflared tunnel route dns $TunnelName $hostname") + continue + } + Write-Host (' ' + $hostname) -ForegroundColor DarkGray + $null = Invoke-Cloudflared tunnel route dns $TunnelName $hostname + # cloudflared returns nonzero if the CNAME already exists, but the + # net result is fine — proceed. +} +Write-OK 'DNS routes registered.' + +# ── 6. Done ─────────────────────────────────────────────────────────────────── +Write-Host '' +Write-OK 'Cloudflare Tunnel ready.' +Write-Host '' +Write-Host ' Start the tunnel (keep running while testing):' -ForegroundColor White +Write-Host (' cloudflared tunnel run ' + $TunnelName) -ForegroundColor Cyan +Write-Host '' +Write-Host ' Or install as a Windows service so it auto-starts:' -ForegroundColor White +Write-Host ' cloudflared service install' -ForegroundColor Cyan +Write-Host '' +Write-Host ' Then: visit https://admin.localhost / https://feed.localhost / play the' -ForegroundColor DarkGray +Write-Host ' patched client. Make sure the local DorkNet.Server is running' -ForegroundColor DarkGray +Write-Host ' and listening on 0.0.0.0:443 with the *.localhost-covering cert.' -ForegroundColor DarkGray