mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94781fd11a | |||
| 99f8c2e016 | |||
| d4ce92f1b4 | |||
| c7a087eea1 | |||
| 5bfa463528 | |||
| 891ec89899 | |||
| f0e4679887 | |||
| 260f6605fc | |||
| 12da61407d | |||
| 5b93af4ba9 | |||
| 51104464f0 | |||
| 35768c05ed | |||
| 0a5c3a9a64 | |||
| fd93b4b028 | |||
| 8be0f90dbc | |||
| d0d26c9821 | |||
| 503d838fd1 | |||
| bb22675a91 | |||
| c1000c82aa | |||
| 9076fb9fae | |||
| cf7a021898 | |||
| e29707deeb | |||
| 3b332a6476 | |||
| d295fd5c37 | |||
| 8e12db660b | |||
| bafb380fc8 | |||
| 78c64d117a | |||
| e7c0b076f2 | |||
| 0a3bad7c6c | |||
| 1718912a07 | |||
| 5acad4c9e2 | |||
| ec88a39750 | |||
| 6e5b011059 | |||
| 2e09042344 | |||
| 9c5b574d6e | |||
| 29aca0e88c | |||
| eb73f3c484 | |||
| cc7f77b7de | |||
| 19402459a9 | |||
| 3266ce47f3 | |||
| cf3e6ea025 | |||
| bf0f09ce84 | |||
| fd2153d82b | |||
| 0839d932ce | |||
| 9523525cb8 | |||
| 3880745efd | |||
| 31d5a022c8 | |||
| 83b896ffaf | |||
| fcc734726a | |||
| b1c1239165 | |||
| afad9bbc5a | |||
| e33145307f | |||
| 38335c06a9 | |||
| 5ab28b1d4f | |||
| d60b0642a8 | |||
| a7879d7ede | |||
| 6272844445 | |||
| 2dbd03b9ae | |||
| f4b4836375 | |||
| 93b4e43316 | |||
| 8373e592f4 |
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Install protobuf compiler
|
||||
run: |
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
||||
dotnet DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
|
||||
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
|
||||
|
||||
- name: Wait for server readiness
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Run endpoint contract
|
||||
run: |
|
||||
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
|
||||
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
|
||||
--server-assembly DorkNet.Server/bin/Debug/net10.0/DorkNet.Server.dll \
|
||||
--base-url http://127.0.0.1:18080 \
|
||||
--report "$RUNNER_TEMP/endpoint-contract-report.json"
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
# ── Server self-contained publish ────────────────────────────────
|
||||
- name: Publish DorkNet.Server (win-x64, self-contained)
|
||||
@@ -43,10 +43,10 @@ jobs:
|
||||
Compress-Archive -Path publish/server/* `
|
||||
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
|
||||
|
||||
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
|
||||
# ── Client patcher zip (built via tools/build_pc.py) ─────────────
|
||||
- name: Build client patcher zip
|
||||
run: |
|
||||
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
|
||||
python tools/build_pc.py patcher --version-key ${{ env.VERSION_KEY }}
|
||||
|
||||
# ── Publish both as Release assets ───────────────────────────────
|
||||
- name: Upload Release assets
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ dist/
|
||||
# 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/
|
||||
DorkNet.Server/bin/Debug/net10.0/data/
|
||||
DorkNet.Server/bin/Release/net10.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.
|
||||
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
# 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.
|
||||
# slimmer aspnet:10.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 DOTNET_VERSION=10.0
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
# ── admin SPA build stage ──────────────────────────────────────────
|
||||
@@ -76,13 +76,13 @@ 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
|
||||
# both have to be present. The aspnet:10.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.
|
||||
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
|
||||
# Coolify mounts persistent volumes with write permission for this UID.
|
||||
USER app
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG DOTNET_VERSION=10.0
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||
|
||||
@@ -156,6 +156,14 @@ public class Mod : MelonMod
|
||||
// "N"). Empty = no hotkey. A hotkey also arms the feature when
|
||||
// RecRoyaleNightMode is false, so it can be switched on in-game.
|
||||
public static string RecRoyaleNightToggleKey = "";
|
||||
|
||||
// A client with no remembered account for this machine. false: the
|
||||
// title screen opens the sign-in page so the player logs into their
|
||||
// existing account. true: Rec Room's own behaviour, which creates a
|
||||
// new account automatically -- only useful when the server allows
|
||||
// sign-ups (otherwise the title screen keeps retrying and failing).
|
||||
// See TitleScreenLoginPatches.
|
||||
public static bool AutoCreateAccount = false;
|
||||
}
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
@@ -225,6 +233,14 @@ public class Mod : MelonMod
|
||||
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
|
||||
}
|
||||
|
||||
// Title-screen auto-login: with no remembered account, open the
|
||||
// sign-in page instead of creating a new account.
|
||||
if (!Cfg.AutoCreateAccount)
|
||||
{
|
||||
TryPatchByName("RRUI.Data.TitleScreenFlowModel", "IAKHIJIHAPJ",
|
||||
prefix: nameof(TitleScreenLoginPatches.AutoLogin_Prefix));
|
||||
}
|
||||
|
||||
// Rec Royale night mode: armed when enabled OR when a hotkey is set
|
||||
// (so it can be switched on in-game). Scene detection + mood apply
|
||||
// run from OnSceneWasLoaded / OnUpdate below.
|
||||
@@ -457,6 +473,7 @@ public class Mod : MelonMod
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "AutoCreateAccount", out v)) Cfg.AutoCreateAccount = v.GetBoolean();
|
||||
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
@@ -662,7 +679,7 @@ public class Mod : MelonMod
|
||||
private MethodInfo GetPatchMethod(string name)
|
||||
{
|
||||
// Look in the core + feature patch holder classes.
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight) })
|
||||
foreach (var holder in new[] { typeof(UriPatches), typeof(AntiCheatPatches), typeof(AntiTamperPatches), typeof(FileHashCheckerPatches), typeof(ToxModPatches), typeof(TlsPatches), typeof(ScreenSharePatches), typeof(QuestTeamSize), typeof(RecRoyaleNight), typeof(TitleScreenLoginPatches) })
|
||||
{
|
||||
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
|
||||
if (m is not null) return m;
|
||||
@@ -903,6 +920,52 @@ internal static class FileHashCheckerPatches
|
||||
return false;
|
||||
}
|
||||
}
|
||||
internal static class TitleScreenLoginPatches
|
||||
{
|
||||
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time
|
||||
// auto-login: the title screen downloads this machine's remembered
|
||||
// accounts (CFILOMKCAPL), picks one (BFHOLLPGBPB) and logs into it here.
|
||||
// With none (null) it creates a brand-new account instead (PJMGFADOEEM ->
|
||||
// grant_type create_account). A server with sign-ups off refuses that, the
|
||||
// error handler (IBDFENJNMAA) shows the connection-error page and the flow
|
||||
// tries again, forever. Open the sign-in page instead (GoToLogin, what the
|
||||
// "Log in" button does). ManuallyLogin carries the player into the game
|
||||
// from there on its own, so the boot step gets a promise that never
|
||||
// settles (MAMJDBJDFAF, the game's non-generic promise).
|
||||
private static Type? _promiseType;
|
||||
|
||||
public static bool AutoLogin_Prefix(object __instance, object? __0, MethodBase __originalMethod, ref object? __result)
|
||||
{
|
||||
if (__0 is not null || Mod.Cfg.AutoCreateAccount) return true;
|
||||
try
|
||||
{
|
||||
_promiseType ??= Mod.ResolveType("MAMJDBJDFAF");
|
||||
var goToLogin = __instance.GetType().GetMethod("GoToLogin", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null);
|
||||
if (_promiseType is null || goToLogin is null)
|
||||
{
|
||||
Mod.Log.Warning("[title] no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
|
||||
return true;
|
||||
}
|
||||
// Il2CppInterop wraps the interface return type (LDGADANDBIO) as
|
||||
// its own class, so hand back the new promise's native object
|
||||
// wrapped in that type.
|
||||
var pending = (Il2CppObjectBase)Activator.CreateInstance(_promiseType)!;
|
||||
var returnType = ((MethodInfo)__originalMethod).ReturnType;
|
||||
var result = Activator.CreateInstance(returnType, pending.Pointer);
|
||||
goToLogin.Invoke(__instance, null);
|
||||
__result = result;
|
||||
Mod.Log.Msg("[title] no remembered account; opened the sign-in page instead of creating a new account");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// goToLogin.Invoke wraps the real failure in TargetInvocationException.
|
||||
var cause = ex is TargetInvocationException { InnerException: not null } tie ? tie.InnerException! : ex;
|
||||
Mod.Log.Warning($"[title] sign-in redirect failed, creating an account as Rec Room does: {cause.GetType().Name}: {cause.Message}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
internal static class ToxModPatches
|
||||
{
|
||||
public static bool CanInitializeToxMod_Prefix(ref bool __result)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"ServerHost": "{HOST}",
|
||||
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS},
|
||||
"AutoCreateAccount": false,
|
||||
"EnableDebugConsole": false,
|
||||
"DebugConsoleToggleKey": "BackQuote",
|
||||
"DevCommands": [],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
+40
-18
@@ -30,7 +30,8 @@ Photon shards with PC DorkNet players with no server-side version change.
|
||||
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
|
||||
| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash |
|
||||
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
|
||||
| Oculus `ovr_*` platform calls | (no-Meta build only) steer the entitlement/login path to the client's own credential screen | — |
|
||||
| Oculus `ovr_*` platform calls | (no-Meta build only) pass the entitlement / user-proof / logged-in-user requests Meta refuses for the renamed app (including the SDK's second check on the inner native message), answering the logged-in user with the placeholder user id `1` | — |
|
||||
| title-screen auto-login (`TitleScreenFlowModel`) | a headset with no remembered account opens the **sign-in page** instead of auto-creating an account (`AutoCreateAccount = true` restores Rec Room's behaviour) | — |
|
||||
|
||||
### Two builds, selected by `OculusAppId`
|
||||
|
||||
@@ -60,7 +61,7 @@ Photon shards with PC DorkNet players with no server-side version change.
|
||||
and its matching **OBB**.
|
||||
|
||||
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
|
||||
> can't write under `Documents`. `build.ps1` builds in `%TEMP%` by default;
|
||||
> can't write under `Documents`. `build.py` builds in `%TEMP%` by default;
|
||||
> keep the work dir outside protected folders.
|
||||
|
||||
## Build
|
||||
@@ -76,20 +77,28 @@ dotnet Il2CppDumper.dll `
|
||||
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
|
||||
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
|
||||
|
||||
# 3. Build the signed APK:
|
||||
# 3. Build the signed APK (and, with --obb, the release package):
|
||||
$env:ANDROID_SDK_ROOT = 'C:\path\to\android-sdk'
|
||||
$env:ANDROID_NDK = 'C:\path\to\android-sdk\ndk\27.2.12479018'
|
||||
$env:APKTOOL_JAR = 'C:\path\to\apktool.jar'
|
||||
DorkNet.Quest\scripts\build.ps1 `
|
||||
-Apk C:\path\to\RecRoom-store-2128210.apk `
|
||||
-DumpCs <scratch>\dump\dump.cs `
|
||||
-Obb C:\path o\main.2128210.com.AgainstGravity.RecRoom.obb
|
||||
py -3 DorkNet.Quest\scripts\build.py build `
|
||||
--apk C:\path\to\RecRoom-store-2128210.apk `
|
||||
--dump-cs <scratch>\dump\dump.cs `
|
||||
--obb C:\path\to\main.2128210.com.AgainstGravity.RecRoom.obb `
|
||||
--keystore C:\path\to\dorknet-debug.keystore
|
||||
# -> %TEMP%\dorknet-quest-build\dorknet-recroom-20230317.apk
|
||||
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with -Obb)
|
||||
# -> %TEMP%\dorknet-quest-build\DorkNet-Quest-20230317\ (with --obb)
|
||||
```
|
||||
|
||||
With `-Obb`, the build also runs `scripts/package.ps1`, which writes a release
|
||||
folder ready to hand to players:
|
||||
The same command works on Linux and macOS (`python3 DorkNet.Quest/scripts/build.py build …`).
|
||||
|
||||
For a build whose target table is already committed under `targets/`, pass
|
||||
`--targets-json DorkNet.Quest/targets/20230317-2128210.json` instead of
|
||||
`--dump-cs`. No Il2CppDumper is needed then: the build checks the table
|
||||
against the APK's `libil2cpp.so` and stops on a mismatch. This is what the
|
||||
Jenkins job does (`ci/jenkins/README.md`).
|
||||
|
||||
With `--obb`, the build also writes a release folder ready to hand to players:
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
@@ -98,15 +107,16 @@ folder ready to hand to players:
|
||||
| `install.sh` | one-command install on macOS / Linux |
|
||||
| `README.txt` | player instructions |
|
||||
|
||||
To package an APK you already built, run `scripts/package.ps1 -Apk <apk> -Obb <obb>`.
|
||||
To package an APK you already built, run `build.py package --apk <apk> --obb <obb>`.
|
||||
The package contains game binaries from your store copy, so only share it with
|
||||
people who own the game.
|
||||
|
||||
`build.ps1` chains: resolve targets → generate headers → compile
|
||||
`build.py build` chains: resolve targets → generate headers → compile
|
||||
`libdorknet.so` → apktool decode → package rename + LAUNCHER + manifest →
|
||||
app name (`AppName`) → drop the library in + `DT_NEEDED` it onto `libmain.so` → apktool build →
|
||||
zipalign → apksigner (v2+v3). It generates a fresh debug keystore in the work
|
||||
dir on first run. **Keep that keystore** — Meta ties tokens to app id + package
|
||||
zipalign → apksigner (v2+v3). Without `--keystore` it generates a fresh debug
|
||||
keystore in the work dir on first run. **Keep that keystore** and pass it with
|
||||
`--keystore` from then on — Meta ties tokens to app id + package
|
||||
+ signing cert, so re-signing with a different key breaks any Meta registration.
|
||||
It is never committed.
|
||||
|
||||
@@ -129,7 +139,18 @@ the running `libil2cpp.so`, so a table can never be applied to the wrong build.
|
||||
|
||||
## Install
|
||||
|
||||
From a release folder, one command does everything (finds adb and the headset,
|
||||
**From the browser (easiest).** Open the **Quest** page of your DorkNet site
|
||||
(`https://<apex>/quest`) in Chrome or Edge on a computer, plug the headset in
|
||||
over USB, pick the `.xapk`, click **Connect**, accept "Allow USB debugging" in
|
||||
the headset, then **Install**. The page talks to the headset over WebUSB
|
||||
(Tango, `@yume-chan/adb`): it installs the APK, copies the OBB to its install
|
||||
path (skipped if an identical one is already there) and launches the app. The
|
||||
`.xapk` never leaves the player's computer; the server hosts no game files.
|
||||
Close SideQuest / Meta Quest Developer Hub, or run `adb kill-server`, first:
|
||||
only one program can hold the headset's USB connection. Firefox, Safari and
|
||||
phones don't support WebUSB.
|
||||
|
||||
**From a release folder**, one command does everything (finds adb and the headset,
|
||||
installs the APK, copies the OBB to the right path, skips the OBB if it is
|
||||
already there, and launches the app):
|
||||
|
||||
@@ -178,12 +199,14 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
|
||||
| Symptom | Likely cause |
|
||||
| --- | --- |
|
||||
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
|
||||
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.ps1`, don't hand-zip |
|
||||
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.py`, don't hand-zip |
|
||||
| boots to an empty world | OBB missing or named for the wrong versionCode |
|
||||
| `[integrity] report reason=... NetworkCertificateMismatch` then a quit | TLS bypass off, or a pin hook didn't arm |
|
||||
| `[http<-]` shows a Cloudflare challenge page | edge WAF blocked the request before your origin |
|
||||
| multiplayer never connects | Photon app ids not set in the cfg |
|
||||
| Quest and PC players never see each other's rooms | `PhotonRegion` differs from the PC ClientMod `PhotonCloudRegion` |
|
||||
| "Unable to load Oculus user account" | old build (before the GetLoggedInUser bypass), or a Meta build whose app id the account isn't entitled to |
|
||||
| game restarts while "creating a new account" | `AutoCreateAccount = true` on a server with sign-ups off — set it to `false` so the sign-in page opens |
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -198,8 +221,7 @@ every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<hos
|
||||
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
|
||||
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
|
||||
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
|
||||
| `scripts/build.ps1` | one-shot: store APK → signed patched APK (+ release package with `-Obb`) |
|
||||
| `scripts/package.ps1` | patched APK + OBB → `.xapk` + installers |
|
||||
| `scripts/build.py` | `build`: store APK → signed patched APK (+ release package with `--obb`); `package`: patched APK + OBB → `.xapk` + installers |
|
||||
| `scripts/installer/` | `install.ps1`, `install.cmd`, `install.sh` copied into each package |
|
||||
| `dorknet-quest.cfg.example` | config template; copy to `dorknet-quest.cfg` (git-ignored) |
|
||||
|
||||
|
||||
@@ -65,6 +65,12 @@ EnableTlsTrustBypass = true
|
||||
# Accept image Content-Signatures the server signs with its own key.
|
||||
AcceptUnsignedImages = true
|
||||
|
||||
# A headset with no remembered account. false: open the sign-in page so the
|
||||
# player logs into their existing DorkNet account. true: Rec Room's own
|
||||
# behaviour, which creates a new account automatically -- only useful when the
|
||||
# server allows sign-ups (otherwise the game keeps retrying and failing).
|
||||
AutoCreateAccount = false
|
||||
|
||||
# ---- diagnostics (adb logcat -s DORKNET) ----
|
||||
|
||||
LogHttp = true
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build a DorkNet-patched Rec Room Quest APK from a pristine store APK.
|
||||
|
||||
.DESCRIPTION
|
||||
End to end:
|
||||
1. resolve_targets.py -> per-build target table (RVA + fingerprint)
|
||||
2. gen_targets_header.py -> targets_generated.h
|
||||
3. gen_config_header.py -> config_generated.h (from your cfg)
|
||||
4. NDK clang++ -> libdorknet.so (arm64)
|
||||
5. apktool decode -> working project
|
||||
6. package rename + manifest/version edits
|
||||
7. drop libdorknet.so in, DT_NEEDED it into libmain.so (LIEF)
|
||||
8. apktool build -> zipalign -> apksigner -> the signed output APK
|
||||
|
||||
Nothing game-derived is written into the repo. The work directory defaults to
|
||||
a temp path because Windows Controlled Folder Access blocks the NDK linker and
|
||||
apksigner from writing under Documents; override with -WorkDir if needed.
|
||||
|
||||
You supply the store APK and (for install) the matching OBB. No game binaries
|
||||
ship with this tool.
|
||||
|
||||
.PARAMETER Apk
|
||||
Path to the pristine store APK (com.AgainstGravity.RecRoom, versionCode
|
||||
2128210 / version 20230317).
|
||||
|
||||
.PARAMETER Config
|
||||
dorknet-quest.cfg with your ServerHost / Photon ids / package name. Defaults
|
||||
to dorknet-quest.cfg next to the repo folder; falls back to the .example.
|
||||
|
||||
.PARAMETER WorkDir
|
||||
Scratch build directory (must be outside Controlled-Folder-Access-protected
|
||||
locations). Defaults to $env:TEMP\dorknet-quest-build.
|
||||
|
||||
.PARAMETER OutApk
|
||||
Where to write the finished APK. Default: <WorkDir>\dorknet-recroom-<ver>.apk.
|
||||
|
||||
.PARAMETER NdkRoot
|
||||
Android NDK root. Default: $env:ANDROID_NDK, else the NDK under $env:ANDROID_SDK_ROOT\ndk.
|
||||
|
||||
.PARAMETER BuildToolsDir
|
||||
Android SDK build-tools dir holding zipalign + apksigner. Default: resolved
|
||||
from $env:ANDROID_SDK_ROOT.
|
||||
|
||||
.PARAMETER Obb
|
||||
The store OBB matching the APK. When given, package.ps1 also bundles the
|
||||
result into an installable .xapk with one-command installers.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Apk,
|
||||
[string]$Config,
|
||||
[string]$WorkDir = (Join-Path $env:TEMP 'dorknet-quest-build'),
|
||||
[string]$OutApk,
|
||||
[string]$NdkRoot,
|
||||
[string]$BuildToolsDir,
|
||||
[string]$ApktoolJar,
|
||||
[string]$DumpCs,
|
||||
[string]$ApiLevel = '24',
|
||||
[string]$Obb
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$RepoDir = Split-Path -Parent $PSScriptRoot
|
||||
$Scripts = $PSScriptRoot
|
||||
$OldPkg = 'com.AgainstGravity.RecRoom'
|
||||
$SoName = 'libdorknet.so'
|
||||
|
||||
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
|
||||
|
||||
# ---- resolve tools ------------------------------------------------------
|
||||
if (-not $NdkRoot) {
|
||||
$NdkRoot = $env:ANDROID_NDK
|
||||
if (-not $NdkRoot -and $env:ANDROID_SDK_ROOT) {
|
||||
$cand = Join-Path $env:ANDROID_SDK_ROOT 'ndk'
|
||||
if (Test-Path $cand) { $NdkRoot = (Get-ChildItem $cand -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName }
|
||||
}
|
||||
}
|
||||
if (-not $NdkRoot) { throw "Android NDK not found. Pass -NdkRoot or set ANDROID_NDK / ANDROID_SDK_ROOT." }
|
||||
$Cxx = Join-Path $NdkRoot "toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android$ApiLevel-clang++.cmd"
|
||||
$ObjDump = Join-Path $NdkRoot 'toolchains\llvm\prebuilt\windows-x86_64\bin\llvm-objdump.exe'
|
||||
Need $Cxx 'NDK aarch64 clang++'
|
||||
|
||||
if (-not $BuildToolsDir) {
|
||||
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
|
||||
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
|
||||
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
|
||||
}
|
||||
$ZipAlign = Join-Path $BuildToolsDir 'zipalign.exe'
|
||||
$ApkSigner = Join-Path $BuildToolsDir 'apksigner.bat'
|
||||
Need $ZipAlign 'zipalign'
|
||||
Need $ApkSigner 'apksigner'
|
||||
|
||||
if (-not $ApktoolJar) {
|
||||
$ApktoolJar = if ($env:APKTOOL_JAR) { $env:APKTOOL_JAR } elseif ($env:ANDROID_SDK_ROOT) { Join-Path $env:ANDROID_SDK_ROOT 'apktool.jar' } else { '' }
|
||||
}
|
||||
Need $ApktoolJar 'apktool.jar'
|
||||
Need $Apk 'store APK'
|
||||
|
||||
if (-not $Config) {
|
||||
$c = Join-Path $RepoDir 'dorknet-quest.cfg'
|
||||
$Config = if (Test-Path $c) { $c } else { Join-Path $RepoDir 'dorknet-quest.cfg.example' }
|
||||
}
|
||||
Need $Config 'config'
|
||||
Write-Host "Using config: $Config"
|
||||
|
||||
$PkgName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*PackageName\s*=' } | Select-Object -First 1)
|
||||
$PkgName = if ($PkgName) { ($PkgName -split '=', 2)[1].Trim() } else { 'net.dorknet.RecRoom' }
|
||||
$AppName = ((Get-Content $Config) | Where-Object { $_ -match '^\s*AppName\s*=' } | Select-Object -First 1)
|
||||
$AppName = if ($AppName) { ($AppName -split '=', 2)[1].Split('#')[0].Trim() } else { '' }
|
||||
if (-not $AppName) { $AppName = 'DorkNet' }
|
||||
|
||||
# Resolve the launcher to its FULL exe path -- "& py ..." would otherwise match
|
||||
# the (case-insensitive) function name and recurse. Prefer py -3.14, else python.
|
||||
$PyCmd = (Get-Command py.exe -ErrorAction SilentlyContinue)
|
||||
if ($PyCmd) { $PyExe = $PyCmd.Source; $PyPre = @('-3.14', '-I') }
|
||||
else { $PyExe = (Get-Command python.exe -ErrorAction Stop).Source; $PyPre = @('-I') }
|
||||
function Invoke-Py { param([Parameter(ValueFromRemainingArguments = $true)][string[]]$a)
|
||||
$pa = $PyPre + $a
|
||||
& $PyExe @pa
|
||||
if ($LASTEXITCODE) { throw "python failed: $($a -join ' ')" } }
|
||||
|
||||
# ---- work dirs ----------------------------------------------------------
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
||||
$Gen = Join-Path $WorkDir 'gen'
|
||||
$Project = Join-Path $WorkDir 'project'
|
||||
$Extract = Join-Path $WorkDir 'extract'
|
||||
New-Item -ItemType Directory -Force -Path $Gen, $Extract | Out-Null
|
||||
|
||||
# ---- 1. extract + dump + resolve targets --------------------------------
|
||||
Step 'extracting libil2cpp.so + global-metadata.dat'
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $Apk))
|
||||
try {
|
||||
foreach ($e in @('lib/arm64-v8a/libil2cpp.so', 'assets/bin/Data/Managed/Metadata/global-metadata.dat', 'lib/arm64-v8a/libmain.so')) {
|
||||
$entry = $zip.GetEntry($e); if (-not $entry) { throw "APK missing $e" }
|
||||
$dest = Join-Path $Extract (Split-Path $e -Leaf)
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true)
|
||||
}
|
||||
} finally { $zip.Dispose() }
|
||||
$Il2Cpp = Join-Path $Extract 'libil2cpp.so'
|
||||
$LibMain = Join-Path $Extract 'libmain.so'
|
||||
|
||||
if (-not $DumpCs) {
|
||||
# Prefer a dump.cs the operator points at; otherwise require one. Dumping
|
||||
# is left to Il2CppDumper (see README) to keep this script toolchain-light.
|
||||
throw "Pass -DumpCs <dump.cs> from Il2CppDumper for this APK (see DorkNet.Quest/README.md)."
|
||||
}
|
||||
Need $DumpCs 'dump.cs'
|
||||
|
||||
Step 'resolving hook targets'
|
||||
$verName = '20230317'; $verCode = '2128210'
|
||||
# Write the resolved table into the work dir (Controlled Folder Access can
|
||||
# block writes under Documents). The repo keeps a committed copy under targets/.
|
||||
$table = Join-Path $WorkDir "$verName-$verCode.json"
|
||||
Invoke-Py @((Join-Path $Scripts 'resolve_targets.py'), '--dump', $DumpCs, '--il2cpp', $Il2Cpp,
|
||||
'--version-name', $verName, '--version-code', $verCode, '--objdump', $ObjDump, '--out', $table)
|
||||
|
||||
Step 'generating headers'
|
||||
Invoke-Py @((Join-Path $Scripts 'gen_targets_header.py'), $table, (Join-Path $Gen 'targets_generated.h'))
|
||||
Invoke-Py @((Join-Path $Scripts 'gen_config_header.py'), $Config, $PkgName, (Join-Path $Gen 'config_generated.h'))
|
||||
|
||||
# ---- 4. compile libdorknet.so -------------------------------------------
|
||||
Step 'compiling libdorknet.so (arm64)'
|
||||
$src = @(
|
||||
(Join-Path $RepoDir 'src\dorknet_quest.cpp'),
|
||||
(Join-Path $RepoDir 'src\config.cpp'),
|
||||
(Join-Path $RepoDir 'src\third_party\And64InlineHook\And64InlineHook.cpp')
|
||||
)
|
||||
$So = Join-Path $WorkDir $SoName
|
||||
& $Cxx '-std=c++17' '-fPIC' '-shared' '-O2' '-fvisibility=hidden' '-static-libstdc++' `
|
||||
'-ffunction-sections' '-fdata-sections' '-Wl,--gc-sections' `
|
||||
"-I$Gen" "-I$(Join-Path $RepoDir 'src')" $src '-llog' '-o' $So
|
||||
if ($LASTEXITCODE) { throw 'compile failed' }
|
||||
Need $So 'libdorknet.so'
|
||||
|
||||
# ---- 5. apktool decode --------------------------------------------------
|
||||
Step 'apktool decode'
|
||||
if (Test-Path $Project) { Remove-Item -Recurse -Force $Project }
|
||||
& java -jar $ApktoolJar decode -f -o $Project (Resolve-Path $Apk)
|
||||
if ($LASTEXITCODE) { throw 'apktool decode failed' }
|
||||
|
||||
# ---- 6. package rename + manifest -------------------------------------
|
||||
Step "renaming package $OldPkg -> $PkgName"
|
||||
$manifest = Join-Path $Project 'AndroidManifest.xml'
|
||||
$m = Get-Content -Raw $manifest
|
||||
$m = $m.Replace($OldPkg, $PkgName)
|
||||
if ($m -notmatch 'android.intent.category.LAUNCHER') {
|
||||
$m = $m.Replace('<category android:name="android.intent.category.INFO"/>',
|
||||
'<category android:name="android.intent.category.INFO"/><category android:name="android.intent.category.LAUNCHER"/>')
|
||||
}
|
||||
Set-Content -NoNewline -Path $manifest -Value $m
|
||||
|
||||
# The launcher label comes from @string/app_name; set it in every locale.
|
||||
Step "naming the app '$AppName'"
|
||||
$labelXml = [System.Security.SecurityElement]::Escape($AppName).Replace("'", "\'")
|
||||
foreach ($sx in Get-ChildItem (Join-Path $Project 'res') -Filter strings.xml -Recurse) {
|
||||
$s = Get-Content -Raw -Encoding utf8 $sx.FullName
|
||||
$n = [regex]::Replace($s, '<string name="app_name">[^<]*</string>', "<string name=`"app_name`">$labelXml</string>")
|
||||
if ($n -ne $s) { Set-Content -NoNewline -Encoding utf8 -Path $sx.FullName -Value $n }
|
||||
}
|
||||
|
||||
# ---- 7. inject the hook library ----------------------------------------
|
||||
Step 'injecting libdorknet.so'
|
||||
Copy-Item $So (Join-Path $Project 'lib\arm64-v8a' $SoName) -Force
|
||||
$projMain = Join-Path $Project 'lib\arm64-v8a\libmain.so'
|
||||
Invoke-Py @((Join-Path $Scripts 'inject_needed.py'), $projMain, $SoName)
|
||||
|
||||
# ---- 8. rebuild + align + sign -----------------------------------------
|
||||
Step 'apktool build'
|
||||
$unsigned = Join-Path $WorkDir 'unsigned.apk'
|
||||
& java -jar $ApktoolJar build -f -o $unsigned $Project
|
||||
if ($LASTEXITCODE) { throw 'apktool build failed' }
|
||||
|
||||
$Keystore = Join-Path $WorkDir 'dorknet-debug.keystore'
|
||||
if (-not (Test-Path $Keystore)) {
|
||||
Step 'generating a fresh debug keystore (kept in the work dir, never committed)'
|
||||
& keytool -genkeypair -v -keystore $Keystore -alias dorknet -keyalg RSA -keysize 2048 `
|
||||
-validity 10000 -storepass dorknet -keypass dorknet -dname 'CN=DorkNet Quest, O=DorkNet'
|
||||
if ($LASTEXITCODE) { throw 'keytool failed' }
|
||||
}
|
||||
|
||||
Step 'zipalign'
|
||||
$aligned = Join-Path $WorkDir 'aligned.apk'
|
||||
& $ZipAlign -f -p 4 $unsigned $aligned
|
||||
if ($LASTEXITCODE) { throw 'zipalign failed' }
|
||||
|
||||
if (-not $OutApk) { $OutApk = Join-Path $WorkDir "dorknet-recroom-$verName.apk" }
|
||||
Step "apksigner -> $OutApk"
|
||||
& $ApkSigner sign --ks $Keystore --ks-key-alias dorknet --ks-pass pass:dorknet --key-pass pass:dorknet `
|
||||
--v2-signing-enabled true --v3-signing-enabled true --out $OutApk $aligned
|
||||
if ($LASTEXITCODE) { throw 'apksigner failed' }
|
||||
& $ApkSigner verify --print-certs $OutApk | Out-Null
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "DONE. Patched APK: $OutApk" -ForegroundColor Green
|
||||
Write-Host "Package: $PkgName versionCode: $verCode" -ForegroundColor Green
|
||||
if ($Obb) {
|
||||
Write-Host ""
|
||||
& (Join-Path $Scripts 'package.ps1') -Apk $OutApk -Obb $Obb -BuildToolsDir $BuildToolsDir
|
||||
} else {
|
||||
Write-Host "Install it, then push the matching OBB to:" -ForegroundColor Green
|
||||
Write-Host " /sdcard/Android/obb/$PkgName/main.$verCode.$PkgName.obb" -ForegroundColor Green
|
||||
Write-Host "(or pass -Obb to bundle both into an installable .xapk)" -ForegroundColor Green
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a DorkNet-patched Rec Room Quest APK (and optionally an .xapk release).
|
||||
|
||||
From a pristine store APK, end to end:
|
||||
|
||||
1. hook targets resolve them from an Il2CppDumper dump.cs
|
||||
(resolve_targets.py), or use a committed table checked
|
||||
against the APK's libil2cpp.so
|
||||
2. headers targets_generated.h + config_generated.h
|
||||
3. compile libdorknet.so (arm64) with the Android NDK
|
||||
4. apktool decode
|
||||
5. rename package rename, launcher category, app label
|
||||
6. inject libdorknet.so in, DT_NEEDED into libmain.so (LIEF)
|
||||
7. sign apktool build -> zipalign -> apksigner
|
||||
8. package with --obb: .xapk + one-command installers (or run
|
||||
`package` on an APK you already built)
|
||||
|
||||
Nothing game-derived is written into the repo. You supply the store APK and,
|
||||
for a release package, the matching OBB. The output contains game binaries
|
||||
from your own store copy: share it only with people who own the game.
|
||||
|
||||
Runs on Windows, Linux and macOS. Needs Python 3.9+ with LIEF, a JDK (java,
|
||||
keytool), the Android NDK, Android build-tools and apktool.
|
||||
|
||||
py -3 build.py build --apk recroom.apk --obb main.2128210.com.AgainstGravity.RecRoom.obb \\
|
||||
--targets-json ../targets/20230317-2128210.json --keystore dorknet.keystore
|
||||
py -3 build.py package --apk dorknet-recroom-20230317.apk --obb main.2128210....obb
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parent
|
||||
REPO = SCRIPTS.parent # DorkNet.Quest/
|
||||
OLD_PKG = "com.AgainstGravity.RecRoom"
|
||||
SO_NAME = "libdorknet.so"
|
||||
ON_WINDOWS = os.name == "nt"
|
||||
|
||||
|
||||
def step(msg: str) -> None:
|
||||
print(f"==> {msg}", flush=True)
|
||||
|
||||
|
||||
def fail(msg: str) -> NoReturn:
|
||||
raise SystemExit(f"error: {msg}")
|
||||
|
||||
|
||||
def need(path: Path | str, what: str) -> Path:
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
fail(f"{what} not found: {p}")
|
||||
return p
|
||||
|
||||
|
||||
def run(*cmd: object, cwd: Path | None = None) -> None:
|
||||
args = [str(c) for c in cmd]
|
||||
r = subprocess.run(args, cwd=cwd)
|
||||
if r.returncode:
|
||||
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
|
||||
|
||||
|
||||
def capture(*cmd: object) -> str:
|
||||
args = [str(c) for c in cmd]
|
||||
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
if r.returncode:
|
||||
sys.stderr.write(r.stderr)
|
||||
fail(f"{Path(args[0]).name} failed (exit {r.returncode})")
|
||||
return r.stdout
|
||||
|
||||
|
||||
def run_py(script: str, *args: object) -> None:
|
||||
"""Run a sibling helper script with this interpreter (isolated mode)."""
|
||||
run(sys.executable, "-I", SCRIPTS / script, *args)
|
||||
|
||||
|
||||
def exe(name: str) -> str:
|
||||
return name + (".exe" if ON_WINDOWS else "")
|
||||
|
||||
|
||||
# ---- tool resolution ------------------------------------------------------
|
||||
|
||||
def sdk_root() -> Path | None:
|
||||
v = os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME")
|
||||
return Path(v) if v else None
|
||||
|
||||
|
||||
def newest_subdir(parent: Path) -> Path | None:
|
||||
if not parent.is_dir():
|
||||
return None
|
||||
dirs = sorted((d for d in parent.iterdir() if d.is_dir()), key=lambda d: d.name, reverse=True)
|
||||
return dirs[0] if dirs else None
|
||||
|
||||
|
||||
def resolve_build_tools(arg: str | None) -> Path:
|
||||
if arg:
|
||||
return need(arg, "build-tools dir")
|
||||
root = sdk_root()
|
||||
found = newest_subdir(root / "build-tools") if root else None
|
||||
if not found:
|
||||
fail("Android build-tools not found. Pass --build-tools-dir or set ANDROID_SDK_ROOT.")
|
||||
return found
|
||||
|
||||
|
||||
def resolve_ndk(arg: str | None) -> Path:
|
||||
if arg:
|
||||
return need(arg, "NDK")
|
||||
if os.environ.get("ANDROID_NDK"):
|
||||
return need(os.environ["ANDROID_NDK"], "NDK (ANDROID_NDK)")
|
||||
root = sdk_root()
|
||||
found = newest_subdir(root / "ndk") if root else None
|
||||
if not found:
|
||||
fail("Android NDK not found. Pass --ndk-root or set ANDROID_NDK / ANDROID_SDK_ROOT.")
|
||||
return found
|
||||
|
||||
|
||||
def ndk_bin(ndk: Path) -> Path:
|
||||
if ON_WINDOWS:
|
||||
tag = "windows-x86_64"
|
||||
elif sys.platform == "darwin":
|
||||
tag = "darwin-x86_64"
|
||||
else:
|
||||
tag = "linux-x86_64"
|
||||
return ndk / "toolchains" / "llvm" / "prebuilt" / tag / "bin"
|
||||
|
||||
|
||||
def resolve_apktool(arg: str | None) -> Path:
|
||||
if arg:
|
||||
return need(arg, "apktool.jar")
|
||||
if os.environ.get("APKTOOL_JAR"):
|
||||
return need(os.environ["APKTOOL_JAR"], "apktool.jar (APKTOOL_JAR)")
|
||||
root = sdk_root()
|
||||
if root and (root / "apktool.jar").exists():
|
||||
return root / "apktool.jar"
|
||||
raise SystemExit("error: apktool.jar not found. Pass --apktool-jar or set APKTOOL_JAR.")
|
||||
|
||||
|
||||
def cfg_value(config: Path, key: str) -> str | None:
|
||||
for line in config.read_text(encoding="utf-8").splitlines():
|
||||
m = re.match(rf"^\s*{re.escape(key)}\s*=(.*)$", line)
|
||||
if m:
|
||||
return m.group(1).split("#", 1)[0].strip()
|
||||
return None
|
||||
|
||||
|
||||
# ---- build ----------------------------------------------------------------
|
||||
|
||||
def cmd_build(a: argparse.Namespace) -> None:
|
||||
ndk = resolve_ndk(a.ndk_root)
|
||||
bin_dir = ndk_bin(ndk)
|
||||
cxx = need(bin_dir / (f"aarch64-linux-android{a.api_level}-clang++" + (".cmd" if ON_WINDOWS else "")),
|
||||
"NDK aarch64 clang++")
|
||||
objdump = bin_dir / exe("llvm-objdump")
|
||||
build_tools = resolve_build_tools(a.build_tools_dir)
|
||||
zipalign = need(build_tools / exe("zipalign"), "zipalign")
|
||||
apksigner = need(build_tools / ("apksigner.bat" if ON_WINDOWS else "apksigner"), "apksigner")
|
||||
apktool = resolve_apktool(a.apktool_jar)
|
||||
apk = need(a.apk, "store APK").resolve()
|
||||
|
||||
config = Path(a.config) if a.config else (
|
||||
REPO / "dorknet-quest.cfg" if (REPO / "dorknet-quest.cfg").exists() else REPO / "dorknet-quest.cfg.example")
|
||||
need(config, "config")
|
||||
print(f"Using config: {config}")
|
||||
pkg_name = cfg_value(config, "PackageName") or "net.dorknet.RecRoom"
|
||||
app_name = cfg_value(config, "AppName") or "DorkNet"
|
||||
|
||||
work = Path(a.work_dir)
|
||||
gen, project, extract = work / "gen", work / "project", work / "extract"
|
||||
for d in (work, gen, extract):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. extract + targets
|
||||
step("extracting libil2cpp.so + global-metadata.dat")
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
for entry in ("lib/arm64-v8a/libil2cpp.so",
|
||||
"assets/bin/Data/Managed/Metadata/global-metadata.dat",
|
||||
"lib/arm64-v8a/libmain.so"):
|
||||
try:
|
||||
data = z.read(entry)
|
||||
except KeyError:
|
||||
fail(f"APK missing {entry}")
|
||||
(extract / Path(entry).name).write_bytes(data)
|
||||
il2cpp = extract / "libil2cpp.so"
|
||||
|
||||
ver_name, ver_code = "20230317", "2128210"
|
||||
if a.targets_json:
|
||||
# A committed, already-resolved table. It is only valid for the exact
|
||||
# libil2cpp.so it was resolved against, so check that first.
|
||||
table = need(a.targets_json, "target table")
|
||||
step(f"using target table {table}")
|
||||
data = json.loads(table.read_text(encoding="utf-8"))
|
||||
sha = hashlib.sha256(il2cpp.read_bytes()).hexdigest()
|
||||
want = str(data.get("il2cppSha256") or "").lower()
|
||||
if want and sha != want:
|
||||
fail(f"libil2cpp.so in this APK ({sha}) is not the build the target table was resolved "
|
||||
f"for ({want}). Resolve a table for it with --dump-cs.")
|
||||
ver_name, ver_code = str(data["versionName"]), str(data["versionCode"])
|
||||
else:
|
||||
if not a.dump_cs:
|
||||
# Dumping is left to Il2CppDumper (see README) to keep this
|
||||
# script toolchain-light.
|
||||
fail("Pass --dump-cs <dump.cs> from Il2CppDumper for this APK, or --targets-json with a "
|
||||
"resolved table (see DorkNet.Quest/README.md).")
|
||||
need(a.dump_cs, "dump.cs")
|
||||
step("resolving hook targets")
|
||||
# Written into the work dir (Controlled Folder Access can block writes
|
||||
# under Documents). The repo keeps a committed copy under targets/.
|
||||
table = work / f"{ver_name}-{ver_code}.json"
|
||||
run_py("resolve_targets.py", "--dump", a.dump_cs, "--il2cpp", il2cpp,
|
||||
"--version-name", ver_name, "--version-code", ver_code,
|
||||
"--objdump", objdump, "--out", table)
|
||||
|
||||
# 2. headers
|
||||
step("generating headers")
|
||||
run_py("gen_targets_header.py", table, gen / "targets_generated.h")
|
||||
run_py("gen_config_header.py", config, pkg_name, gen / "config_generated.h")
|
||||
|
||||
# 3. compile
|
||||
step("compiling libdorknet.so (arm64)")
|
||||
src = REPO / "src"
|
||||
so = work / SO_NAME
|
||||
run(cxx, "-std=c++17", "-fPIC", "-shared", "-O2", "-fvisibility=hidden", "-static-libstdc++",
|
||||
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
|
||||
f"-I{gen}", f"-I{src}",
|
||||
src / "dorknet_quest.cpp", src / "config.cpp",
|
||||
src / "third_party" / "And64InlineHook" / "And64InlineHook.cpp",
|
||||
"-llog", "-o", so)
|
||||
need(so, "libdorknet.so")
|
||||
|
||||
# 4. decode
|
||||
step("apktool decode")
|
||||
if project.exists():
|
||||
shutil.rmtree(project)
|
||||
run("java", "-jar", apktool, "decode", "-f", "-o", project, apk)
|
||||
|
||||
# 5. package rename + manifest + label
|
||||
step(f"renaming package {OLD_PKG} -> {pkg_name}")
|
||||
manifest = project / "AndroidManifest.xml"
|
||||
m = manifest.read_text(encoding="utf-8").replace(OLD_PKG, pkg_name)
|
||||
if "android.intent.category.LAUNCHER" not in m:
|
||||
m = m.replace('<category android:name="android.intent.category.INFO"/>',
|
||||
'<category android:name="android.intent.category.INFO"/>'
|
||||
'<category android:name="android.intent.category.LAUNCHER"/>')
|
||||
manifest.write_text(m, encoding="utf-8", newline="")
|
||||
|
||||
# The launcher label comes from @string/app_name; set it in every locale.
|
||||
step(f"naming the app '{app_name}'")
|
||||
# XML entities for & < > " ' (the PowerShell version used
|
||||
# SecurityElement.Escape), so no raw quote reaches Android's string parser.
|
||||
label = xml_escape(app_name, {'"': """, "'": "'"})
|
||||
for sx in (project / "res").rglob("strings.xml"):
|
||||
s = sx.read_text(encoding="utf-8")
|
||||
n = re.sub(r'<string name="app_name">[^<]*</string>', lambda _: f'<string name="app_name">{label}</string>', s)
|
||||
if n != s:
|
||||
sx.write_text(n, encoding="utf-8", newline="")
|
||||
|
||||
# 6. inject
|
||||
step(f"injecting {SO_NAME}")
|
||||
shutil.copy2(so, project / "lib" / "arm64-v8a" / SO_NAME)
|
||||
run_py("inject_needed.py", project / "lib" / "arm64-v8a" / "libmain.so", SO_NAME)
|
||||
|
||||
# 7. build + align + sign
|
||||
step("apktool build")
|
||||
unsigned = work / "unsigned.apk"
|
||||
run("java", "-jar", apktool, "build", "-f", "-o", unsigned, project)
|
||||
|
||||
if a.keystore:
|
||||
keystore = need(a.keystore, "keystore")
|
||||
else:
|
||||
keystore = work / "dorknet-debug.keystore"
|
||||
if not keystore.exists():
|
||||
step("generating a fresh debug keystore (kept in the work dir, never committed)")
|
||||
run("keytool", "-genkeypair", "-v", "-keystore", keystore, "-alias", a.key_alias,
|
||||
"-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
|
||||
"-storepass", a.keystore_pass, "-keypass", a.keystore_pass,
|
||||
"-dname", "CN=DorkNet Quest, O=DorkNet")
|
||||
|
||||
step("zipalign")
|
||||
aligned = work / "aligned.apk"
|
||||
run(zipalign, "-f", "-p", "4", unsigned, aligned)
|
||||
|
||||
out_apk = Path(a.out_apk) if a.out_apk else work / f"dorknet-recroom-{ver_name}.apk"
|
||||
out_apk.parent.mkdir(parents=True, exist_ok=True)
|
||||
step(f"apksigner -> {out_apk}")
|
||||
run(apksigner, "sign", "--ks", keystore, "--ks-key-alias", a.key_alias,
|
||||
"--ks-pass", f"pass:{a.keystore_pass}", "--key-pass", f"pass:{a.keystore_pass}",
|
||||
"--v2-signing-enabled", "true", "--v3-signing-enabled", "true",
|
||||
"--out", out_apk, aligned)
|
||||
capture(apksigner, "verify", "--print-certs", out_apk)
|
||||
|
||||
print()
|
||||
print(f"DONE. Patched APK: {out_apk}")
|
||||
print(f"Package: {pkg_name} versionCode: {ver_code}")
|
||||
if a.obb:
|
||||
print()
|
||||
package(out_apk, Path(a.obb), Path(a.package_dir) if a.package_dir else None, build_tools)
|
||||
else:
|
||||
print("Install it, then push the matching OBB to:")
|
||||
print(f" /sdcard/Android/obb/{pkg_name}/main.{ver_code}.{pkg_name}.obb")
|
||||
print("(or pass --obb to bundle both into an installable .xapk)")
|
||||
|
||||
|
||||
# ---- package --------------------------------------------------------------
|
||||
|
||||
README_TXT = """{name} {ver} - Rec Room for DorkNet (Meta Quest)
|
||||
============================================================
|
||||
|
||||
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
|
||||
in over USB and accept "Allow USB debugging" inside the headset.
|
||||
2. Install. Easiest: open the Quest page of your DorkNet server's website
|
||||
(https://<server>/quest) in Chrome or Edge, pick this .xapk and follow the
|
||||
steps. Or use the scripts here:
|
||||
Windows: double-click install.cmd
|
||||
macOS / Linux: ./install.sh
|
||||
The installer needs adb (Android platform-tools) on PATH or in
|
||||
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
|
||||
offers to launch it.
|
||||
3. In the headset: Library > Unknown Sources > {name}.
|
||||
|
||||
The app installs as {pkg} alongside any store copy of Rec Room.
|
||||
SideQuest users can also drop the .xapk onto SideQuest instead.
|
||||
"""
|
||||
|
||||
|
||||
def package(apk: Path, obb: Path, out_dir: Path | None, build_tools: Path) -> Path:
|
||||
"""Bundle a patched APK and its OBB into an .xapk plus installers.
|
||||
|
||||
The XAPK is the usual APKPure layout (manifest.json + APK + OBB + icon), so
|
||||
XAPK-aware installers (SideQuest's file drop, SAI, APKPure) can open it too.
|
||||
The APK and OBB are stored uncompressed: they are already compressed, and
|
||||
storing them keeps packaging and install fast."""
|
||||
apk, obb = need(apk, "patched APK").resolve(), need(obb, "OBB").resolve()
|
||||
aapt2 = need(build_tools / exe("aapt2"), "aapt2")
|
||||
|
||||
step("reading APK metadata")
|
||||
badging = capture(aapt2, "dump", "badging", apk).splitlines()
|
||||
|
||||
def badge(pattern: str) -> str | None:
|
||||
for line in badging:
|
||||
mm = re.search(pattern, line)
|
||||
if mm:
|
||||
return mm.group(1)
|
||||
return None
|
||||
|
||||
pkg = badge(r"^package: name='([^']+)'")
|
||||
ver_code = badge(r"versionCode='([^']+)'")
|
||||
ver_name = badge(r"versionName='([^']+)'")
|
||||
min_sdk = badge(r"^(?:minSdkVersion|sdkVersion):'([^']+)'")
|
||||
tgt_sdk = badge(r"^targetSdkVersion:'([^']+)'")
|
||||
label = badge(r"^application-label:'([^']*)'")
|
||||
if not pkg or not ver_code:
|
||||
fail("could not read package name / versionCode from the APK")
|
||||
perms = [mm.group(1) for line in badging if (mm := re.match(r"^uses-permission: name='([^']+)'", line))]
|
||||
icons = sorted(((int(mm.group(1)), mm.group(2)) for line in badging
|
||||
if (mm := re.match(r"^application-icon-(\d+):'([^']+)'", line))), reverse=True)
|
||||
|
||||
# The launcher icon may be an adaptive-icon XML; XAPK installers want a
|
||||
# PNG, so fall back to the same-named PNG from the densest mipmap/drawable.
|
||||
icon_path = None
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
names = set(z.namelist())
|
||||
if icons:
|
||||
ref = icons[0][1]
|
||||
if ref.endswith(".png") and ref in names:
|
||||
icon_path = ref
|
||||
else:
|
||||
base = Path(ref).stem
|
||||
for d in ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi", "ldpi"):
|
||||
for kind in ("mipmap", "drawable"):
|
||||
cand = f"res/{kind}-{d}/{base}.png"
|
||||
if icon_path is None and cand in names:
|
||||
icon_path = cand
|
||||
icon_bytes = z.read(icon_path) if icon_path else None
|
||||
print(f" {pkg} version {ver_name} ({ver_code})")
|
||||
|
||||
obb_entry = f"Android/obb/{pkg}/main.{ver_code}.{pkg}.obb"
|
||||
out_dir = out_dir or apk.parent / f"DorkNet-Quest-{ver_name}"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
xapk = out_dir / f"DorkNet-{ver_name}.xapk"
|
||||
xapk.unlink(missing_ok=True)
|
||||
|
||||
manifest = {
|
||||
"xapk_version": 2,
|
||||
"package_name": pkg,
|
||||
"name": label or "DorkNet",
|
||||
"version_code": ver_code,
|
||||
"version_name": ver_name,
|
||||
"min_sdk_version": min_sdk,
|
||||
"target_sdk_version": tgt_sdk,
|
||||
"permissions": perms,
|
||||
"total_size": apk.stat().st_size + obb.stat().st_size,
|
||||
"expansions": [{"file": obb_entry, "install_location": "EXTERNAL_STORAGE", "install_path": obb_entry}],
|
||||
}
|
||||
if icon_bytes:
|
||||
manifest["icon"] = "icon.png"
|
||||
|
||||
step(f"writing {xapk} (the OBB is large; this takes a minute)")
|
||||
with zipfile.ZipFile(xapk, "w", allowZip64=True) as z:
|
||||
z.writestr(zipfile.ZipInfo("manifest.json"), json.dumps(manifest, indent=2), compress_type=zipfile.ZIP_DEFLATED)
|
||||
if icon_bytes:
|
||||
z.writestr("icon.png", icon_bytes, compress_type=zipfile.ZIP_STORED)
|
||||
z.write(apk, f"{pkg}.apk", compress_type=zipfile.ZIP_STORED)
|
||||
z.write(obb, obb_entry, compress_type=zipfile.ZIP_STORED)
|
||||
|
||||
step("adding installers")
|
||||
for f in ("install.ps1", "install.cmd", "install.sh"):
|
||||
shutil.copy2(SCRIPTS / "installer" / f, out_dir / f)
|
||||
(out_dir / "install.sh").chmod(0o755)
|
||||
(out_dir / "README.txt").write_text(
|
||||
README_TXT.format(name=manifest["name"], ver=ver_name, pkg=pkg), encoding="utf-8")
|
||||
|
||||
print()
|
||||
print(f"DONE. Package folder: {out_dir}")
|
||||
print(f" {xapk.name} ({xapk.stat().st_size / 2**30:.2f} GB)")
|
||||
print(f"Install with: {out_dir / 'install.cmd'}")
|
||||
return out_dir
|
||||
|
||||
|
||||
def cmd_package(a: argparse.Namespace) -> None:
|
||||
package(Path(a.apk), Path(a.obb), Path(a.out_dir) if a.out_dir else None,
|
||||
resolve_build_tools(a.build_tools_dir))
|
||||
|
||||
|
||||
# ---- cli ------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
b = sub.add_parser("build", help="store APK -> signed patched APK (+ .xapk with --obb)")
|
||||
b.add_argument("--apk", required=True, help="pristine store APK (com.AgainstGravity.RecRoom)")
|
||||
b.add_argument("--obb", help="matching store OBB; also writes the .xapk release package")
|
||||
b.add_argument("--config", help="dorknet-quest.cfg (default: DorkNet.Quest/dorknet-quest.cfg, else the .example)")
|
||||
b.add_argument("--dump-cs", help="Il2CppDumper dump.cs for this APK, to resolve the hook targets")
|
||||
b.add_argument("--targets-json", help="a resolved target table to use instead of --dump-cs; must match the APK's libil2cpp.so")
|
||||
b.add_argument("--keystore", help="signing keystore; reuse the same one for every build so updates install in place")
|
||||
b.add_argument("--keystore-pass", default="dorknet", help="store and key password (default: dorknet)")
|
||||
b.add_argument("--key-alias", default="dorknet", help="key alias (default: dorknet)")
|
||||
b.add_argument("--work-dir", default=str(Path(tempfile.gettempdir()) / "dorknet-quest-build"),
|
||||
help="scratch directory (on Windows keep it outside Controlled Folder Access, e.g. %%TEMP%%)")
|
||||
b.add_argument("--out-apk", help="where to write the APK (default: <work-dir>/dorknet-recroom-<ver>.apk)")
|
||||
b.add_argument("--package-dir", help="release folder for the .xapk (default: <apk folder>/DorkNet-Quest-<ver>)")
|
||||
b.add_argument("--ndk-root", help="Android NDK root (default: ANDROID_NDK, else newest under ANDROID_SDK_ROOT/ndk)")
|
||||
b.add_argument("--build-tools-dir", help="Android build-tools dir (default: newest under ANDROID_SDK_ROOT/build-tools)")
|
||||
b.add_argument("--apktool-jar", help="apktool.jar (default: APKTOOL_JAR, else ANDROID_SDK_ROOT/apktool.jar)")
|
||||
b.add_argument("--api-level", default="24", help="NDK API level (default: 24)")
|
||||
b.set_defaults(func=cmd_build)
|
||||
|
||||
k = sub.add_parser("package", help="patched APK + OBB -> .xapk + installers")
|
||||
k.add_argument("--apk", required=True)
|
||||
k.add_argument("--obb", required=True)
|
||||
k.add_argument("--out-dir", help="release folder (default: <apk folder>/DorkNet-Quest-<ver>)")
|
||||
k.add_argument("--build-tools-dir")
|
||||
k.set_defaults(func=cmd_package)
|
||||
|
||||
a = p.parse_args()
|
||||
a.func(a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -23,6 +23,7 @@ KEYS = {
|
||||
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
|
||||
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
|
||||
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
|
||||
"AutoCreateAccount": ("DN_AUTO_CREATE_ACCOUNT", "bool"),
|
||||
"LogHttp": ("DN_LOG_HTTP", "bool"),
|
||||
"Debug": ("DN_DEBUG_HTTP", "bool"),
|
||||
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bundle a patched Quest APK and its OBB into one installable package.
|
||||
|
||||
.DESCRIPTION
|
||||
Writes a release folder holding:
|
||||
DorkNet-<ver>.xapk standard XAPK (manifest.json + APK + OBB + icon)
|
||||
install.cmd / install.ps1 one-command installer for Windows
|
||||
install.sh one-command installer for macOS / Linux
|
||||
README.txt what to do with it
|
||||
|
||||
The XAPK is the usual APKPure layout, so XAPK-aware installers (SideQuest's
|
||||
file drop, SAI, APKPure) can also open it. The OBB is stored uncompressed:
|
||||
it is already compressed, and storing it keeps packaging and install fast.
|
||||
|
||||
The output contains game binaries from your own store copy. Share it only
|
||||
with people who own the game.
|
||||
|
||||
.PARAMETER Apk
|
||||
The signed, patched APK produced by build.ps1.
|
||||
|
||||
.PARAMETER Obb
|
||||
The store OBB matching the APK's versionCode (main.<code>.com.AgainstGravity.RecRoom.obb).
|
||||
|
||||
.PARAMETER OutDir
|
||||
Release folder to create. Default: <APK folder>\DorkNet-Quest-<ver>.
|
||||
|
||||
.PARAMETER BuildToolsDir
|
||||
Android SDK build-tools dir holding aapt2. Default: resolved from $env:ANDROID_SDK_ROOT.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Apk,
|
||||
[Parameter(Mandatory = $true)][string]$Obb,
|
||||
[string]$OutDir,
|
||||
[string]$BuildToolsDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
|
||||
|
||||
function Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Need($path, $what) { if (-not (Test-Path -LiteralPath $path)) { throw "$what not found: $path" } }
|
||||
|
||||
Need $Apk 'patched APK'
|
||||
Need $Obb 'OBB'
|
||||
$Apk = (Resolve-Path -LiteralPath $Apk).Path
|
||||
$Obb = (Resolve-Path -LiteralPath $Obb).Path
|
||||
|
||||
if (-not $BuildToolsDir) {
|
||||
if (-not $env:ANDROID_SDK_ROOT) { throw "Pass -BuildToolsDir or set ANDROID_SDK_ROOT." }
|
||||
$bt = Join-Path $env:ANDROID_SDK_ROOT 'build-tools'
|
||||
$BuildToolsDir = (Get-ChildItem $bt -Directory | Sort-Object Name -Descending | Select-Object -First 1).FullName
|
||||
}
|
||||
$Aapt2 = Join-Path $BuildToolsDir 'aapt2.exe'
|
||||
Need $Aapt2 'aapt2'
|
||||
|
||||
# ---- read the APK's identity --------------------------------------------
|
||||
Step 'reading APK metadata'
|
||||
$badging = & $Aapt2 dump badging $Apk 2>$null
|
||||
if ($LASTEXITCODE) { throw 'aapt2 dump badging failed' }
|
||||
function Badge($pattern) { foreach ($l in $badging) { if ($l -match $pattern) { return $Matches[1] } } return $null }
|
||||
$pkg = Badge "^package: name='([^']+)'"
|
||||
$verCode = Badge "versionCode='([^']+)'"
|
||||
$verName = Badge "versionName='([^']+)'"
|
||||
$minSdk = Badge "^(?:minSdkVersion|sdkVersion):'([^']+)'"
|
||||
$tgtSdk = Badge "^targetSdkVersion:'([^']+)'"
|
||||
$label = Badge "^application-label:'([^']*)'"
|
||||
if (-not $pkg -or -not $verCode) { throw 'could not read package name / versionCode from the APK' }
|
||||
$perms = @($badging | Where-Object { $_ -match "^uses-permission: name='([^']+)'" } | ForEach-Object { $_ -replace "^uses-permission: name='([^']+)'.*", '$1' })
|
||||
$iconRef = @($badging | Where-Object { $_ -match "^application-icon-\d+:'([^']+)'" } |
|
||||
ForEach-Object { [pscustomobject]@{ Density = [int]($_ -replace "^application-icon-(\d+):.*", '$1'); Path = ($_ -replace "^application-icon-\d+:'([^']+)'.*", '$1') } } |
|
||||
Sort-Object Density -Descending | Select-Object -First 1 | ForEach-Object { $_.Path })
|
||||
|
||||
# The launcher icon may be an adaptive-icon XML; XAPK installers want a PNG, so
|
||||
# fall back to the same-named PNG from the densest mipmap/drawable folder.
|
||||
$iconPath = $null
|
||||
if ($iconRef) {
|
||||
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
|
||||
try {
|
||||
if ($iconRef -like '*.png' -and $src.GetEntry($iconRef)) { $iconPath = $iconRef }
|
||||
else {
|
||||
$base = [System.IO.Path]::GetFileNameWithoutExtension($iconRef)
|
||||
foreach ($d in 'xxxhdpi', 'xxhdpi', 'xhdpi', 'hdpi', 'mdpi', 'ldpi') {
|
||||
foreach ($kind in 'mipmap', 'drawable') {
|
||||
if (-not $iconPath -and $src.GetEntry("res/$kind-$d/$base.png")) { $iconPath = "res/$kind-$d/$base.png" }
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { $src.Dispose() }
|
||||
}
|
||||
Write-Host " $pkg version $verName ($verCode)"
|
||||
|
||||
$obbName = "main.$verCode.$pkg.obb"
|
||||
$obbEntry = "Android/obb/$pkg/$obbName"
|
||||
$apkEntry = "$pkg.apk"
|
||||
|
||||
if (-not $OutDir) { $OutDir = Join-Path (Split-Path -Parent $Apk) "DorkNet-Quest-$verName" }
|
||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
$xapk = Join-Path $OutDir "DorkNet-$verName.xapk"
|
||||
if (Test-Path $xapk) { Remove-Item -Force $xapk }
|
||||
|
||||
# ---- manifest.json -------------------------------------------------------
|
||||
$manifest = [ordered]@{
|
||||
xapk_version = 2
|
||||
package_name = $pkg
|
||||
name = if ($label) { $label } else { 'DorkNet' }
|
||||
version_code = $verCode
|
||||
version_name = $verName
|
||||
min_sdk_version = $minSdk
|
||||
target_sdk_version = $tgtSdk
|
||||
permissions = $perms
|
||||
total_size = (Get-Item $Apk).Length + (Get-Item $Obb).Length
|
||||
expansions = @([ordered]@{ file = $obbEntry; install_location = 'EXTERNAL_STORAGE'; install_path = $obbEntry })
|
||||
}
|
||||
if ($iconPath) { $manifest.icon = 'icon.png' }
|
||||
|
||||
# ---- write the XAPK ------------------------------------------------------
|
||||
Step "writing $xapk (the OBB is large; this takes a minute)"
|
||||
$zip = [System.IO.Compression.ZipFile]::Open($xapk, [System.IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
$e = $zip.CreateEntry('manifest.json', [System.IO.Compression.CompressionLevel]::Optimal)
|
||||
$w = New-Object System.IO.StreamWriter($e.Open(), (New-Object System.Text.UTF8Encoding($false)))
|
||||
$w.Write(($manifest | ConvertTo-Json -Depth 5)); $w.Dispose()
|
||||
|
||||
if ($iconPath) {
|
||||
$src = [System.IO.Compression.ZipFile]::OpenRead($Apk)
|
||||
try {
|
||||
$ie = $src.GetEntry($iconPath)
|
||||
if ($ie) {
|
||||
$dst = $zip.CreateEntry('icon.png', [System.IO.Compression.CompressionLevel]::NoCompression)
|
||||
$in = $ie.Open(); $out = $dst.Open(); $in.CopyTo($out); $out.Dispose(); $in.Dispose()
|
||||
}
|
||||
} finally { $src.Dispose() }
|
||||
}
|
||||
|
||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Apk, $apkEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
|
||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $Obb, $obbEntry, [System.IO.Compression.CompressionLevel]::NoCompression) | Out-Null
|
||||
} finally { $zip.Dispose() }
|
||||
|
||||
# ---- installers + readme -------------------------------------------------
|
||||
Step 'adding installers'
|
||||
foreach ($f in 'install.ps1', 'install.cmd', 'install.sh') {
|
||||
Copy-Item (Join-Path $PSScriptRoot "installer\$f") (Join-Path $OutDir $f) -Force
|
||||
}
|
||||
$readme = @"
|
||||
$($manifest.name) $verName - Rec Room for DorkNet (Meta Quest)
|
||||
============================================================
|
||||
|
||||
1. Turn on developer mode for your headset (Meta Horizon phone app), plug it
|
||||
in over USB and accept "Allow USB debugging" inside the headset.
|
||||
2. Install:
|
||||
Windows: double-click install.cmd
|
||||
macOS / Linux: ./install.sh
|
||||
The installer needs adb (Android platform-tools) on PATH or in
|
||||
ANDROID_SDK_ROOT. It installs the app, copies the game data (~1.4 GB) and
|
||||
offers to launch it.
|
||||
3. In the headset: Library > Unknown Sources > $($manifest.name).
|
||||
|
||||
The app installs as $pkg alongside any store copy of Rec Room.
|
||||
SideQuest users can also drop the .xapk onto SideQuest instead.
|
||||
"@
|
||||
Set-Content -Path (Join-Path $OutDir 'README.txt') -Value $readme -Encoding utf8
|
||||
|
||||
$sizeGb = '{0:N2}' -f ((Get-Item $xapk).Length / 1GB)
|
||||
Write-Host ""
|
||||
Write-Host "DONE. Package folder: $OutDir" -ForegroundColor Green
|
||||
Write-Host " $(Split-Path -Leaf $xapk) ($sizeGb GB)" -ForegroundColor Green
|
||||
Write-Host "Install with: $(Join-Path $OutDir 'install.cmd')" -ForegroundColor Green
|
||||
Binary file not shown.
@@ -57,6 +57,11 @@
|
||||
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
|
||||
# define DN_ACCEPT_UNSIGNED_IMAGES true
|
||||
#endif
|
||||
// A headset with no remembered account: false opens the sign-in page, true
|
||||
// keeps Rec Room's behaviour of creating a new account automatically.
|
||||
#ifndef DN_AUTO_CREATE_ACCOUNT
|
||||
# define DN_AUTO_CREATE_ACCOUNT false
|
||||
#endif
|
||||
#ifndef DN_LOG_HTTP
|
||||
# define DN_LOG_HTTP true
|
||||
#endif
|
||||
@@ -82,7 +87,7 @@ void dn_load_runtime_config(char *server_host, size_t server_host_cap,
|
||||
char *photon_realtime, char *photon_voice, char *photon_chat,
|
||||
char *photon_server, size_t photon_server_cap, int *photon_port,
|
||||
char *photon_region, size_t photon_region_cap,
|
||||
bool *tls_bypass, bool *accept_images,
|
||||
bool *tls_bypass, bool *accept_images, bool *auto_create_account,
|
||||
bool *log_http, bool *debug_http, int *body_max);
|
||||
|
||||
#endif // DORKNET_QUEST_CONFIG_H
|
||||
|
||||
@@ -59,6 +59,7 @@ static int g_photon_port = DN_PHOTON_PORT;
|
||||
static char g_photon_region[16] = DN_PHOTON_REGION;
|
||||
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
|
||||
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
|
||||
static bool g_auto_create_account = DN_AUTO_CREATE_ACCOUNT;
|
||||
static bool g_log_http = DN_LOG_HTTP;
|
||||
static bool g_debug_http = DN_DEBUG_HTTP;
|
||||
static int g_body_max = DN_LOG_BODY_MAX;
|
||||
@@ -89,8 +90,12 @@ static const size_t kArrayDataOff = 0x20;
|
||||
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
|
||||
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
|
||||
static const unsigned kMsgUserGetUserProof = 578880643u;
|
||||
static const unsigned kMsgUserGetLoggedInUser = 1131361373u;
|
||||
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
|
||||
static const unsigned long long kNoPlatformUserId = 1ULL;
|
||||
// Stand-in ovrUserHandle for a GetLoggedInUser that Meta refused. Only our
|
||||
// hooked ovr_User_* accessors ever see it; they never pass it to the SDK.
|
||||
static void *const kPlaceholderUser = (void *)0x2;
|
||||
|
||||
static const size_t kLogChunk = 1500;
|
||||
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
|
||||
@@ -103,12 +108,22 @@ typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *na
|
||||
typedef void *(*il2cpp_object_new_t)(void *klass);
|
||||
typedef void *(*il2cpp_string_new_t)(const char *str);
|
||||
typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc);
|
||||
typedef void *(*il2cpp_domain_get_t)();
|
||||
typedef void **(*il2cpp_domain_get_assemblies_t)(void *domain, size_t *size);
|
||||
typedef void *(*il2cpp_assembly_get_image_t)(void *assembly);
|
||||
typedef const char *(*il2cpp_image_get_name_t)(void *image);
|
||||
typedef void *(*il2cpp_class_from_name_t)(void *image, const char *ns, const char *name);
|
||||
|
||||
static il2cpp_object_get_class_t il2cpp_object_get_class;
|
||||
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
|
||||
static il2cpp_object_new_t il2cpp_object_new;
|
||||
static il2cpp_string_new_t il2cpp_string_new;
|
||||
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
|
||||
static il2cpp_domain_get_t il2cpp_domain_get;
|
||||
static il2cpp_domain_get_assemblies_t il2cpp_domain_get_assemblies;
|
||||
static il2cpp_assembly_get_image_t il2cpp_assembly_get_image;
|
||||
static il2cpp_image_get_name_t il2cpp_image_get_name;
|
||||
static il2cpp_class_from_name_t il2cpp_class_from_name;
|
||||
|
||||
static uintptr_t g_il2cpp_base;
|
||||
|
||||
@@ -139,6 +154,30 @@ static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *
|
||||
return (const unsigned char *)((char *)arr + kArrayDataOff);
|
||||
}
|
||||
|
||||
static bool invoke_void(void *obj, const char *method) {
|
||||
if (!obj) return false;
|
||||
void *cls = il2cpp_object_get_class(obj);
|
||||
if (!cls) return false;
|
||||
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||
if (!m) return false;
|
||||
void *exc = nullptr;
|
||||
il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||
return !exc;
|
||||
}
|
||||
|
||||
static void *find_class(const char *image_name, const char *ns, const char *name) {
|
||||
if (!il2cpp_domain_get || !il2cpp_domain_get_assemblies || !il2cpp_assembly_get_image ||
|
||||
!il2cpp_image_get_name || !il2cpp_class_from_name) return nullptr;
|
||||
size_t n = 0;
|
||||
void **assemblies = il2cpp_domain_get_assemblies(il2cpp_domain_get(), &n);
|
||||
for (size_t i = 0; assemblies && i < n; i++) {
|
||||
void *image = il2cpp_assembly_get_image(assemblies[i]);
|
||||
const char *iname = image ? il2cpp_image_get_name(image) : nullptr;
|
||||
if (iname && !strcmp(iname, image_name)) return il2cpp_class_from_name(image, ns, name);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (!obj) return;
|
||||
@@ -438,14 +477,73 @@ typedef unsigned (*ovr_GetType_t)(void *m);
|
||||
static ovr_IsError_t orig_ovr_IsError;
|
||||
static ovr_GetType_t ovr_GetType;
|
||||
|
||||
static bool is_bypassed_type(unsigned t) {
|
||||
return t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof ||
|
||||
t == kMsgUserGetLoggedInUser;
|
||||
}
|
||||
|
||||
// The SDK's Message constructor checks IsError twice: on the message, and --
|
||||
// when that passes -- on ovr_Message_GetNativeMessage(message). The inner
|
||||
// message carries the platform's refusal too but doesn't report the outer
|
||||
// type, so remember the inner handle of a bypassed message (same thread, the
|
||||
// constructor asks about it immediately) and pass it as well.
|
||||
static __thread void *t_bypassed_native;
|
||||
static __thread unsigned t_bypassed_type;
|
||||
|
||||
static bool hk_ovr_IsError(void *m) {
|
||||
bool real = orig_ovr_IsError(m);
|
||||
if (!real || !ovr_GetType) return real;
|
||||
unsigned t = ovr_GetType(m);
|
||||
if (t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof) return false;
|
||||
if (!real) return real;
|
||||
if (m && m == t_bypassed_native) return false;
|
||||
if (ovr_GetType && is_bypassed_type(ovr_GetType(m))) return false;
|
||||
return real;
|
||||
}
|
||||
|
||||
typedef void *(*ovr_GetNativeMessage_t)(void *m);
|
||||
static ovr_GetNativeMessage_t orig_ovr_GetNativeMessage;
|
||||
static void *hk_ovr_GetNativeMessage(void *m) {
|
||||
void *native = orig_ovr_GetNativeMessage(m);
|
||||
unsigned t = ovr_GetType ? ovr_GetType(m) : 0;
|
||||
t_bypassed_native = is_bypassed_type(t) ? native : nullptr;
|
||||
t_bypassed_type = t;
|
||||
return native;
|
||||
}
|
||||
|
||||
// OculusPlatformManager.LoadUser asks for the logged-in user; on a headset
|
||||
// that reaches Meta, Meta refuses it for this re-packaged app, LoadUser
|
||||
// rejects ("GetLoggedInUser failed") and Initialize shows "Unable to load
|
||||
// Oculus user account". A headset that can't reach Meta gets a cached user
|
||||
// and never hits this. Answer a refused request with a placeholder user whose
|
||||
// ID is kNoPlatformUserId -- the same placeholder the server already treats
|
||||
// as "no platform account" (PlayerService.IsPlaceholderPlatformId).
|
||||
static void *(*orig_ovr_Message_GetUser)(void *m);
|
||||
static void *hk_ovr_Message_GetUser(void *m) {
|
||||
void *u = orig_ovr_Message_GetUser ? orig_ovr_Message_GetUser(m) : nullptr;
|
||||
if (u) return u;
|
||||
// The SDK reads the user from the inner native message
|
||||
// (GetUser(GetNativeMessage(msg))), so match that handle as well.
|
||||
bool logged_in_user = (m && m == t_bypassed_native && t_bypassed_type == kMsgUserGetLoggedInUser) ||
|
||||
(ovr_GetType && ovr_GetType(m) == kMsgUserGetLoggedInUser);
|
||||
if (!logged_in_user) return u;
|
||||
LOGI("GetLoggedInUser refused by the platform; using the no-platform placeholder user");
|
||||
return kPlaceholderUser;
|
||||
}
|
||||
|
||||
// Accessors the SDK's User model reads. Real users pass straight through; the
|
||||
// placeholder gets empty strings and zero values.
|
||||
#define DN_USER_STR_ACCESSOR(name) static const char *(*orig_##name)(void *u); static const char *hk_##name(void *u) { if (u == kPlaceholderUser) return ""; return orig_##name ? orig_##name(u) : ""; }
|
||||
#define DN_USER_VAL_ACCESSOR(name) static uintptr_t (*orig_##name)(void *u); static uintptr_t hk_##name(void *u) { if (u == kPlaceholderUser) return 0; return orig_##name ? orig_##name(u) : 0; }
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetDisplayName)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetOculusID)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetImageUrl)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetSmallImageUrl)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetInviteToken)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresence)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDeeplinkMessage)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceDestinationApiName)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceLobbySessionId)
|
||||
DN_USER_STR_ACCESSOR(ovr_User_GetPresenceMatchSessionId)
|
||||
DN_USER_VAL_ACCESSOR(ovr_User_GetPresenceStatus) // ovrUserPresenceStatus: 0 = Unknown
|
||||
|
||||
static void *(*orig_ovr_GetUserProof)(void *m);
|
||||
static void *hk_ovr_GetUserProof(void *m) {
|
||||
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
|
||||
@@ -469,10 +567,46 @@ static unsigned long long hk_ovr_GetLoggedInUserID() {
|
||||
|
||||
static unsigned long long (*orig_ovr_User_GetID)(void *u);
|
||||
static unsigned long long hk_ovr_User_GetID(void *u) {
|
||||
if (u == kPlaceholderUser) return kNoPlatformUserId;
|
||||
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
|
||||
return real ? real : kNoPlatformUserId;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ title screen --
|
||||
|
||||
// TitleScreenFlowModel.IAKHIJIHAPJ(cachedLogin) is the boot-time auto-login:
|
||||
// the title screen downloads the headset's remembered accounts and logs into
|
||||
// one; with none (null) it creates a brand-new account instead
|
||||
// (PJMGFADOEEM -> grant_type create_account). A server with sign-ups off
|
||||
// refuses that, the error handler shows the connection-error page and the
|
||||
// flow tries again, forever. Unless AutoCreateAccount is on, open the sign-in
|
||||
// page instead (TitleScreenFlowModel.GoToLogin, what the "Log in" button
|
||||
// does). ManuallyLogin carries the player into the game from there on its own,
|
||||
// so the boot step gets a promise that never settles.
|
||||
static void *(*orig_TitleAutoLogin)(void *thiz, void *cached, void *method);
|
||||
static void *new_pending_promise() {
|
||||
// MAMJDBJDFAF: the game's non-generic promise (RecRoom.Promises.Runtime);
|
||||
// a fresh one stays pending.
|
||||
static void *cls = find_class("RecRoom.Promises.Runtime.dll", "", "MAMJDBJDFAF");
|
||||
if (!cls) return nullptr;
|
||||
void *ctor = il2cpp_class_get_method_from_name(cls, ".ctor", 0);
|
||||
if (!ctor) return nullptr;
|
||||
void *p = il2cpp_object_new(cls);
|
||||
void *exc = nullptr;
|
||||
if (p) il2cpp_runtime_invoke(ctor, p, nullptr, &exc);
|
||||
return exc ? nullptr : p;
|
||||
}
|
||||
static void *hk_TitleAutoLogin(void *thiz, void *cached, void *method) {
|
||||
if (cached || g_auto_create_account) return orig_TitleAutoLogin(thiz, cached, method);
|
||||
void *pending = new_pending_promise();
|
||||
if (!pending || !invoke_void(thiz, "GoToLogin")) {
|
||||
LOGE("no remembered account, but the sign-in page could not be opened; creating one as Rec Room does");
|
||||
return orig_TitleAutoLogin(thiz, cached, method);
|
||||
}
|
||||
LOGI("no remembered account on this headset; opened the sign-in page instead of creating a new account");
|
||||
return pending;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- init ------
|
||||
|
||||
static uintptr_t module_base(const char *name) {
|
||||
@@ -516,6 +650,12 @@ static bool resolve_il2cpp(void *h) {
|
||||
SYM(il2cpp_string_new)
|
||||
SYM(il2cpp_runtime_invoke)
|
||||
#undef SYM
|
||||
// Optional: only the title-screen sign-in redirect needs these.
|
||||
il2cpp_domain_get = (il2cpp_domain_get_t)dlsym(h, "il2cpp_domain_get");
|
||||
il2cpp_domain_get_assemblies = (il2cpp_domain_get_assemblies_t)dlsym(h, "il2cpp_domain_get_assemblies");
|
||||
il2cpp_assembly_get_image = (il2cpp_assembly_get_image_t)dlsym(h, "il2cpp_assembly_get_image");
|
||||
il2cpp_image_get_name = (il2cpp_image_get_name_t)dlsym(h, "il2cpp_image_get_name");
|
||||
il2cpp_class_from_name = (il2cpp_class_from_name_t)dlsym(h, "il2cpp_class_from_name");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -537,6 +677,8 @@ static void arm_oculus_platform_bypass() {
|
||||
return;
|
||||
}
|
||||
void *a;
|
||||
if ((a = dlsym(ovr, "ovr_Message_GetNativeMessage")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetNativeMessage, (void **)&orig_ovr_GetNativeMessage);
|
||||
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
|
||||
if ((a = dlsym(ovr, "ovr_User_GetID")))
|
||||
@@ -545,6 +687,21 @@ static void arm_oculus_platform_bypass() {
|
||||
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
|
||||
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce);
|
||||
if ((a = dlsym(ovr, "ovr_Message_GetUser")))
|
||||
A64HookFunction(a, (void *)hk_ovr_Message_GetUser, (void **)&orig_ovr_Message_GetUser);
|
||||
#define DN_HOOK_USER(name) if ((a = dlsym(ovr, #name))) A64HookFunction(a, (void *)hk_##name, (void **)&orig_##name);
|
||||
DN_HOOK_USER(ovr_User_GetDisplayName)
|
||||
DN_HOOK_USER(ovr_User_GetOculusID)
|
||||
DN_HOOK_USER(ovr_User_GetImageUrl)
|
||||
DN_HOOK_USER(ovr_User_GetSmallImageUrl)
|
||||
DN_HOOK_USER(ovr_User_GetInviteToken)
|
||||
DN_HOOK_USER(ovr_User_GetPresence)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceDeeplinkMessage)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceDestinationApiName)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceLobbySessionId)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceMatchSessionId)
|
||||
DN_HOOK_USER(ovr_User_GetPresenceStatus)
|
||||
#undef DN_HOOK_USER
|
||||
}
|
||||
|
||||
static void *init_thread(void *) {
|
||||
@@ -562,7 +719,7 @@ static void *init_thread(void *) {
|
||||
g_photon_realtime, g_photon_voice, g_photon_chat,
|
||||
g_photon_server, sizeof(g_photon_server), &g_photon_port,
|
||||
g_photon_region, sizeof(g_photon_region),
|
||||
&g_tls_bypass, &g_accept_images, &g_log_http, &g_debug_http, &g_body_max);
|
||||
&g_tls_bypass, &g_accept_images, &g_auto_create_account, &g_log_http, &g_debug_http, &g_body_max);
|
||||
|
||||
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
|
||||
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
|
||||
@@ -586,6 +743,8 @@ static void *init_thread(void *) {
|
||||
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
|
||||
}
|
||||
|
||||
arm(&dn_targets.title_auto_login, (void *)hk_TitleAutoLogin, (void **)&orig_TitleAutoLogin);
|
||||
|
||||
if (g_accept_images)
|
||||
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
|
||||
|
||||
|
||||
@@ -111,6 +111,12 @@
|
||||
"length": 412,
|
||||
"fingerprint": "0xb12b0044ac4f844f",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
},
|
||||
"title_auto_login": {
|
||||
"rva": 58892048,
|
||||
"length": 668,
|
||||
"fingerprint": "0x83bebb9a30983b33",
|
||||
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,12 @@
|
||||
"class": "public class SessionManager ",
|
||||
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
},
|
||||
{
|
||||
"id": "title_auto_login",
|
||||
"class": "public class TitleScreenFlowModel ",
|
||||
"method": "private LDGADANDBIO IAKHIJIHAPJ(LBNJFPOLCDL.MDGJJCFGJNF AJCKAHHJDEN)",
|
||||
"why": "Title-screen auto-login. With no remembered account (null) it creates a new account; unless AutoCreateAccount is on, open the sign-in page instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Hubs;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for the notify hub that records every push instead of sending it,
|
||||
/// so tests can assert on the exact frames the game client would receive.
|
||||
/// A frame is the hub method plus its single string argument, parsed back into
|
||||
/// the <c>{Id, Msg}</c> envelope.
|
||||
/// </summary>
|
||||
public sealed class CapturingHubContext : IHubContext<NotifyHub>
|
||||
{
|
||||
private readonly ConcurrentQueue<SentFrame> _sent = new();
|
||||
|
||||
public IHubClients Clients => new CapturingClients(this);
|
||||
public IGroupManager Groups { get; } = new NoopGroups();
|
||||
|
||||
public IReadOnlyList<SentFrame> Sent => _sent.ToArray();
|
||||
|
||||
/// <summary>Frames sent to a target such as <c>group:player:12</c>, in
|
||||
/// order, optionally filtered by envelope <c>Id</c>.</summary>
|
||||
public IReadOnlyList<SentFrame> To(string target, string? id = null) =>
|
||||
_sent.Where(f => f.Target == target && (id is null || f.Id == id)).ToList();
|
||||
|
||||
public static string PlayerGroup(long playerId) => $"group:{NotifyHub.GroupForPlayer(playerId)}";
|
||||
|
||||
internal void Record(string target, IReadOnlyList<string>? excluded, string method, object?[] args) =>
|
||||
_sent.Enqueue(new SentFrame(target, excluded ?? Array.Empty<string>(), method,
|
||||
args.Length == 1 && args[0] is string s ? s : JsonSerializer.Serialize(args)));
|
||||
|
||||
public sealed record SentFrame(string Target, IReadOnlyList<string> Excluded, string Method, string Payload)
|
||||
{
|
||||
public JsonElement Envelope => JsonDocument.Parse(Payload).RootElement;
|
||||
public string? Id => Envelope.TryGetProperty("Id", out var id) ? id.GetString() : null;
|
||||
public JsonElement Msg => Envelope.GetProperty("Msg");
|
||||
}
|
||||
|
||||
private sealed class CapturingClients(CapturingHubContext owner) : IHubClients
|
||||
{
|
||||
private IClientProxy Proxy(string target, IReadOnlyList<string>? excluded = null) =>
|
||||
new CapturingProxy(owner, target, excluded);
|
||||
|
||||
public IClientProxy All => Proxy("all");
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy("all", excludedConnectionIds);
|
||||
public IClientProxy Client(string connectionId) => Proxy($"conn:{connectionId}");
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy($"conns:{string.Join(",", connectionIds)}");
|
||||
public IClientProxy Group(string groupName) => Proxy($"group:{groupName}");
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) =>
|
||||
Proxy($"group:{groupName}", excludedConnectionIds);
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => new FanOutProxy(groupNames.Select(g => Proxy($"group:{g}")).ToList());
|
||||
public IClientProxy User(string userId) => Proxy($"user:{userId}");
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy($"users:{string.Join(",", userIds)}");
|
||||
}
|
||||
|
||||
private sealed class CapturingProxy(CapturingHubContext owner, string target, IReadOnlyList<string>? excluded) : IClientProxy
|
||||
{
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
owner.Record(target, excluded, method, args);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FanOutProxy(IReadOnlyList<IClientProxy> proxies) : IClientProxy
|
||||
{
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) =>
|
||||
Task.WhenAll(proxies.Select(p => p.SendCoreAsync(method, args, cancellationToken)));
|
||||
}
|
||||
|
||||
private sealed class NoopGroups : IGroupManager
|
||||
{
|
||||
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using DorkNet.Server.Controllers.Site;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>The site's downloads proxy: off without config, only on the site's
|
||||
/// own host, serves only files the manifest lists, and passes Range through so
|
||||
/// the browser Quest installer can read an .xapk in place.</summary>
|
||||
public sealed class DownloadsControllerTests
|
||||
{
|
||||
private const string Apex = "dorknet.example.com";
|
||||
|
||||
private const string Manifest = """
|
||||
{"releases":[{"id":"2023.03","title":"March 2023","files":[
|
||||
{"id":"quest","platform":"quest","name":"DorkNet-2023.03-Quest.xapk","path":"2023.03/DorkNet-2023.03-Quest.xapk","size":10,"sha256":"x"}]}]}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_base_url_the_manifest_is_404()
|
||||
{
|
||||
var (controller, _) = Create(baseUrl: null, _ => throw new InvalidOperationException("no request expected"));
|
||||
|
||||
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("api.dorknet.example.com")]
|
||||
[InlineData("cdn.dorknet.example.com")]
|
||||
public async Task Other_hosts_get_404_without_touching_the_bucket(string host)
|
||||
{
|
||||
var (controller, ctx) = Create("http://bucket.internal:3902", _ => throw new InvalidOperationException("no request expected"));
|
||||
ctx.Request.Host = new HostString(host);
|
||||
|
||||
Assert.IsType<NotFoundObjectResult>(await controller.GetManifest(CancellationToken.None));
|
||||
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
|
||||
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_listed_paths_are_found()
|
||||
{
|
||||
var manifest = (JsonObject)JsonNode.Parse(Manifest)!;
|
||||
|
||||
Assert.NotNull(DownloadsService.FindFile(manifest, "2023.03/DorkNet-2023.03-Quest.xapk"));
|
||||
Assert.Null(DownloadsService.FindFile(manifest, "manifest.json"));
|
||||
Assert.Null(DownloadsService.FindFile(manifest, "2023.03/../manifest.json"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlisted_file_is_404_and_never_fetched()
|
||||
{
|
||||
var requested = new List<string>();
|
||||
var (controller, ctx) = Create("http://bucket.internal:3902", req =>
|
||||
{
|
||||
requested.Add(req.RequestUri!.AbsolutePath);
|
||||
return Json(Manifest);
|
||||
});
|
||||
|
||||
await controller.GetFile("index.html", CancellationToken.None);
|
||||
|
||||
Assert.Equal(StatusCodes.Status404NotFound, ctx.Response.StatusCode);
|
||||
Assert.Equal(["/manifest.json"], requested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Range_is_forwarded_and_the_partial_response_streamed()
|
||||
{
|
||||
HttpRequestMessage? fileRequest = null;
|
||||
var (controller, ctx) = Create("http://bucket.internal:3902/", req =>
|
||||
{
|
||||
if (req.RequestUri!.AbsolutePath == "/manifest.json") return Json(Manifest);
|
||||
fileRequest = req;
|
||||
var res = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent("abcd"u8.ToArray()) };
|
||||
res.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 5, 10);
|
||||
return res;
|
||||
}, host: "downloads.example.com");
|
||||
ctx.Request.Headers.Range = "bytes=2-5";
|
||||
|
||||
await controller.GetFile("2023.03/DorkNet-2023.03-Quest.xapk", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(fileRequest);
|
||||
Assert.Equal("/2023.03/DorkNet-2023.03-Quest.xapk", fileRequest!.RequestUri!.AbsolutePath);
|
||||
Assert.Equal("bytes=2-5", fileRequest.Headers.Range!.ToString());
|
||||
Assert.Equal("downloads.example.com", fileRequest.Headers.Host);
|
||||
Assert.Equal(StatusCodes.Status206PartialContent, ctx.Response.StatusCode);
|
||||
Assert.Equal("bytes 2-5/10", ctx.Response.Headers.ContentRange.ToString());
|
||||
Assert.Equal("bytes", ctx.Response.Headers.AcceptRanges.ToString());
|
||||
Assert.Equal("abcd", Encoding.ASCII.GetString(((MemoryStream)ctx.Response.Body).ToArray()));
|
||||
}
|
||||
|
||||
private static (DownloadsController, HttpContext) Create(
|
||||
string? baseUrl, Func<HttpRequestMessage, HttpResponseMessage> handler, string? host = null)
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["DorkNet:Downloads:BaseUrl"] = baseUrl,
|
||||
["DorkNet:Downloads:Host"] = host,
|
||||
}).Build();
|
||||
var service = new DownloadsService(config, new StubFactory(handler), NullLogger<DownloadsService>.Instance);
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Host = new HostString(Apex);
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
var controller = new DownloadsController(service, new DomainConfig(Apex))
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = ctx },
|
||||
};
|
||||
return (controller, ctx);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string body) =>
|
||||
new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||
|
||||
private sealed class StubFactory(Func<HttpRequestMessage, HttpResponseMessage> handler) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new(new StubHandler(handler));
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
=> Task.FromResult(handler(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Website / admin-panel sign-in with an external OpenID Connect provider.
|
||||
/// The provider round-trip can't run in-process, so each test starts where the
|
||||
/// provider callback leaves off: a <c>DorkNet.External</c> cookie holding the
|
||||
/// provider identity, encrypted with the app's own cookie format. Everything
|
||||
/// from there (link, session, admin SSO, unlink, limits) is the real code.
|
||||
/// </summary>
|
||||
public sealed class ExternalSignInTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private const string Issuer = "https://idp.example.com";
|
||||
// Test player ids: sequential, in a range no other test uses.
|
||||
private static long _nextPlayerId = 9_600_000;
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ExternalSignInTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Linking_with_the_account_password_signs_in_and_the_next_sign_in_needs_no_password()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
var subject = Guid.NewGuid().ToString("N");
|
||||
|
||||
using var site = Client();
|
||||
var link = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(subject, "player@example.com"));
|
||||
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
|
||||
var session = SessionCookie(link);
|
||||
Assert.NotNull(session);
|
||||
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.True(me.GetProperty("signedIn").GetBoolean());
|
||||
Assert.Equal(player.Username, me.GetProperty("player").GetProperty("username").GetString());
|
||||
Assert.Equal("player@example.com", me.GetProperty("player").GetProperty("email").GetString());
|
||||
|
||||
// Next time the provider identity alone is enough.
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var signIn = scope.ServiceProvider.GetRequiredService<ExternalSignInService>();
|
||||
var stored = await signIn.FindAsync(Issuer, subject);
|
||||
Assert.Equal(player.Id, stored?.PlayerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_passwords_are_refused_then_rate_limited()
|
||||
{
|
||||
var (player, _) = await PlayerAsync();
|
||||
var external = ExternalCookie(Guid.NewGuid().ToString("N"), "guess@example.com");
|
||||
using var site = Client();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
using var r = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password = "wrong-" + i }, external);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode);
|
||||
}
|
||||
using var blocked = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password = "still-wrong" }, external);
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_identity_linked_to_one_account_cannot_be_linked_to_another()
|
||||
{
|
||||
var (first, firstPassword) = await PlayerAsync();
|
||||
var (second, secondPassword) = await PlayerAsync();
|
||||
var subject = Guid.NewGuid().ToString("N");
|
||||
using var site = Client();
|
||||
|
||||
using var ok = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = first.Username, password = firstPassword }, ExternalCookie(subject, "a@example.com"));
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
using var refused = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = second.Username, password = secondPassword }, ExternalCookie(subject, "a@example.com"));
|
||||
Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_sso_issues_tokens_only_to_admins()
|
||||
{
|
||||
var (admin, adminPassword) = await PlayerAsync(isAdmin: true);
|
||||
var (regular, regularPassword) = await PlayerAsync();
|
||||
using var admins = Client("admin");
|
||||
|
||||
var adminSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
|
||||
new { username = admin.Username, password = adminPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
using var tokens = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", adminSession);
|
||||
Assert.Equal(HttpStatusCode.OK, tokens.StatusCode);
|
||||
var body = JsonDocument.Parse(await tokens.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(string.IsNullOrEmpty(body.GetProperty("access_token").GetString()));
|
||||
Assert.Equal(admin.Id, body.GetProperty("account_id").GetInt64());
|
||||
|
||||
var regularSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
|
||||
new { username = regular.Username, password = regularPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
using var refused = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", regularSession);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
|
||||
|
||||
using var anonymous = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", null);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlinking_ends_the_session()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
using var site = Client();
|
||||
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
|
||||
using var unlink = await SendAsync(site, HttpMethod.Post, "/api/site/v1/account/unlink", session);
|
||||
Assert.Equal(HttpStatusCode.OK, unlink.StatusCode);
|
||||
|
||||
// The old cookie is still in the browser, but the link behind it is gone.
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.False(me.GetProperty("signedIn").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_banned_player_cannot_link_and_loses_an_existing_session()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
using var site = Client();
|
||||
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
await db.Players.Where(p => p.Id == player.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.BannedUntil, DateTime.UtcNow.AddDays(1)));
|
||||
}
|
||||
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.False(me.GetProperty("signedIn").GetBoolean());
|
||||
using var relink = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, relink.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cookie_keys_are_stored_encrypted()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
using var site = Client();
|
||||
using var link = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
|
||||
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var stored = await db.DataProtectionKeys.Select(k => k.Xml).ToListAsync();
|
||||
Assert.NotEmpty(stored);
|
||||
Assert.All(stored, xml =>
|
||||
{
|
||||
Assert.Contains("dorknetEncryptedKey", xml);
|
||||
Assert.DoesNotContain("masterKey", xml);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_encryption_round_trips_and_rejects_another_secret()
|
||||
{
|
||||
var element = new System.Xml.Linq.XElement("key", new System.Xml.Linq.XElement("masterKey", "secret-bytes"));
|
||||
var encrypted = new DataProtectionKeyEncryption("first-secret").Encrypt(element).EncryptedElement;
|
||||
Assert.DoesNotContain("secret-bytes", encrypted.ToString());
|
||||
Assert.Equal(element.ToString(), new DataProtectionKeyEncryption("first-secret").DecryptElement(encrypted).ToString());
|
||||
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(
|
||||
() => new DataProtectionKeyEncryption("other-secret").DecryptElement(encrypted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Downloads_can_require_sign_in()
|
||||
{
|
||||
await using var factory = _factory.WithWebHostBuilder(b =>
|
||||
{
|
||||
b.UseSetting("DorkNet:Downloads:BaseUrl", "http://127.0.0.1:9");
|
||||
b.UseSetting("DorkNet:Downloads:RequireSignIn", "true");
|
||||
});
|
||||
using var site = factory.CreateClient(new() { BaseAddress = new Uri($"http://{_factory.ApexDomain}") });
|
||||
using var manifest = await site.GetAsync("/api/site/v1/downloads");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, manifest.StatusCode);
|
||||
using var file = await site.GetAsync("/api/site/v1/downloads/files/2023.03/DorkNet-2023.03-Quest.xapk");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, file.StatusCode);
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(PlayerEntity Player, string Password)> PlayerAsync(bool isAdmin = false)
|
||||
{
|
||||
var password = "pw-" + Guid.NewGuid().ToString("N")[..12];
|
||||
var player = new PlayerEntity
|
||||
{
|
||||
Id = Interlocked.Increment(ref _nextPlayerId),
|
||||
Username = "sso" + Guid.NewGuid().ToString("N")[..10],
|
||||
DisplayName = "SSO test",
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||
IsAdmin = isAdmin,
|
||||
};
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Players.Add(player);
|
||||
await db.SaveChangesAsync();
|
||||
return (player, password);
|
||||
}
|
||||
|
||||
/// <summary>The cookie the provider callback leaves behind: the provider
|
||||
/// identity in the DorkNet.External scheme, in the app's cookie format.</summary>
|
||||
private string ExternalCookie(string subject, string email)
|
||||
{
|
||||
var options = _factory.Services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
|
||||
.Get(ExternalSignIn.ExternalScheme);
|
||||
var identity = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("iss", Issuer), new Claim("sub", subject), new Claim("email", email),
|
||||
}, "test");
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), ExternalSignIn.ExternalScheme);
|
||||
return $"{options.Cookie.Name}={options.TicketDataFormat.Protect(ticket)}";
|
||||
}
|
||||
|
||||
private static string? SessionCookie(HttpResponseMessage response) =>
|
||||
response.Headers.TryGetValues("Set-Cookie", out var values)
|
||||
? values.Select(v => v.Split(';')[0]).FirstOrDefault(v => v.StartsWith("dorknet_session=") && v.Length > "dorknet_session=".Length)
|
||||
: null;
|
||||
|
||||
private HttpClient Client(string? subdomain = null)
|
||||
{
|
||||
var host = subdomain is null ? _factory.ApexDomain : $"{subdomain}.{_factory.ApexDomain}";
|
||||
return _factory.CreateClient(new() { BaseAddress = new Uri($"http://{host}"), HandleCookies = false });
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, string? cookie)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, path);
|
||||
if (cookie is not null) request.Headers.Add("Cookie", cookie);
|
||||
return client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient client, string path, object body, string? cookie)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
|
||||
if (cookie is not null) request.Headers.Add("Cookie", cookie);
|
||||
return client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path, string? cookie)
|
||||
{
|
||||
using var response = await SendAsync(client, HttpMethod.Get, path, cookie);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.API.Inventory;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Hubs;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Push frames checked against the 2023-03-21 client's notification readers
|
||||
/// (RecNet.Runtime). Every push is the hub method "Notification" with one
|
||||
/// JSON string <c>{"Id":"<key>","Msg":{…}}</c>; the client looks the Id
|
||||
/// up case-sensitively and parses Msg strictly into the handler's DTO, so a
|
||||
/// wrong key or shape drops the push without any error.
|
||||
/// </summary>
|
||||
public sealed class NotificationContractTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public NotificationContractTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Friend_request_pushes_each_side_its_own_relationship_and_an_inbox_message()
|
||||
{
|
||||
var (factory, hub) = WithCapturingHub();
|
||||
using var setup = ApiClient(factory);
|
||||
var sender = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
using var client = ApiClient(factory, sender);
|
||||
(await client.PostAsync($"/api/relationships/v2/sendfriendrequest?id={target.PlayerId}", null))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
// RelationshipChanged "1" = the Relationship row from each side's view.
|
||||
var toTarget = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1"));
|
||||
Assert.Equal("Notification", toTarget.Method);
|
||||
Assert.Equal(sender.PlayerId, toTarget.Msg.GetProperty("PlayerID").GetInt64());
|
||||
Assert.Equal(2, toTarget.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestReceived
|
||||
Assert.False(toTarget.Msg.TryGetProperty("Reason", out _));
|
||||
|
||||
var toSender = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1"));
|
||||
Assert.Equal(target.PlayerId, toSender.Msg.GetProperty("PlayerID").GetInt64());
|
||||
Assert.Equal(1, toSender.Msg.GetProperty("RelationshipType").GetInt32()); // FriendRequestSent
|
||||
|
||||
// MessageReceived "2" — the "X sent you a friend request" inbox row.
|
||||
var invite = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "2"));
|
||||
Assert.Equal(4, invite.Msg.GetProperty("Type").GetInt32()); // FriendInvite
|
||||
Assert.Equal(sender.PlayerId, invite.Msg.GetProperty("FromPlayerId").GetInt64());
|
||||
|
||||
using var targetClient = ApiClient(factory, target);
|
||||
(await targetClient.PostAsync($"/api/relationships/v2/acceptfriendrequest?id={sender.PlayerId}", null))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "1")[^1]
|
||||
.Msg.GetProperty("RelationshipType").GetInt32());
|
||||
Assert.Equal(3, hub.To(CapturingHubContext.PlayerGroup(target.PlayerId), "1")[^1]
|
||||
.Msg.GetProperty("RelationshipType").GetInt32());
|
||||
var accepted = hub.To(CapturingHubContext.PlayerGroup(sender.PlayerId), "2")[^1];
|
||||
Assert.Equal(40, accepted.Msg.GetProperty("Type").GetInt32()); // FriendRequestAccepted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Favorite_pushes_the_callers_updated_row_to_the_caller_only()
|
||||
{
|
||||
var (factory, hub) = WithCapturingHub();
|
||||
using var setup = ApiClient(factory);
|
||||
var me = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var other = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
using var client = ApiClient(factory, me);
|
||||
(await client.GetAsync($"/api/relationships/v1/favorite?id={other.PlayerId}")).EnsureSuccessStatusCode();
|
||||
|
||||
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(me.PlayerId), "1"));
|
||||
Assert.Equal(other.PlayerId, frame.Msg.GetProperty("PlayerID").GetInt64());
|
||||
Assert.Equal(1, frame.Msg.GetProperty("Favorited").GetInt32());
|
||||
Assert.Empty(hub.To(CapturingHubContext.PlayerGroup(other.PlayerId), "1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Kick_is_the_flat_moderation_block_with_the_reason_in_VoteKickReason()
|
||||
{
|
||||
var (factory, hub) = WithCapturingHub();
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||
|
||||
await notifications.KickPlayerAsync(42, "Too loud", gameSessionId: 777);
|
||||
|
||||
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(42), "22"));
|
||||
var msg = frame.Msg;
|
||||
// The handler only shows a reason for ReportCategory 10 + !IsHostKick.
|
||||
Assert.Equal(10, msg.GetProperty("ReportCategory").GetInt32());
|
||||
Assert.False(msg.GetProperty("IsHostKick").GetBoolean());
|
||||
Assert.Equal("Too loud", msg.GetProperty("VoteKickReason").GetString());
|
||||
Assert.Equal(777, msg.GetProperty("GameSessionId").GetInt64());
|
||||
Assert.Equal(0, msg.GetProperty("Duration").GetInt32());
|
||||
Assert.False(msg.TryGetProperty("Reason", out _)); // the old nested 2020 shape
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationService.LogoutReason.SessionTakeover, 2)]
|
||||
[InlineData(NotificationService.LogoutReason.ForciblyLoggedOut, 3)]
|
||||
[InlineData(NotificationService.LogoutReason.Banned, 4)]
|
||||
public async Task Logout_reason_is_the_numeric_enum(NotificationService.LogoutReason reason, int wire)
|
||||
{
|
||||
var (factory, hub) = WithCapturingHub();
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||
|
||||
await notifications.LogoutAsync(43, reason);
|
||||
|
||||
var frame = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(43), "6"));
|
||||
var value = frame.Msg.GetProperty("Reason");
|
||||
Assert.Equal(JsonValueKind.Number, value.ValueKind);
|
||||
Assert.Equal(wire, value.GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Presence_for_subscribers_goes_to_the_subjects_presence_group_minus_excluded_connections()
|
||||
{
|
||||
var (factory, hub) = WithCapturingHub();
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var notifications = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
||||
|
||||
await notifications.PresenceToSubscribersAsync(55, room: null, isOnline: true, statusVisibility: 0,
|
||||
excludeConnectionIds: ["self-conn", "friend-conn"]);
|
||||
|
||||
var frame = Assert.Single(hub.To($"group:{NotifyHub.PresenceGroupForPlayer(55)}", "PresenceUpdate"));
|
||||
Assert.Equal(["self-conn", "friend-conn"], frame.Excluded);
|
||||
Assert.Equal(55, frame.Msg.GetProperty("playerId").GetInt32());
|
||||
Assert.True(frame.Msg.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.String, frame.Msg.GetProperty("appVersion").ValueKind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"PlayerIds":[1,2,3]}""", new long[] { 1, 2, 3 })]
|
||||
[InlineData("""{"playerIds":["4",5]}""", new long[] { 4, 5 })]
|
||||
[InlineData("""[6,7]""", new long[] { 6, 7 })]
|
||||
[InlineData("""{"PlayerIds":[]}""", new long[0])]
|
||||
public void SubscribeToPlayers_payload_is_read_in_any_casing(string json, long[] expected)
|
||||
{
|
||||
Assert.True(NotifyHub.TryReadPlayerIds(JsonDocument.Parse(json).RootElement, out var ids));
|
||||
Assert.Equal(expected.OrderBy(x => x), ids.OrderBy(x => x));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unreadable_SubscribeToPlayers_payload_is_rejected_not_read_as_empty()
|
||||
{
|
||||
Assert.False(NotifyHub.TryReadPlayerIds(JsonDocument.Parse("""{"Channels":[]}""").RootElement, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consumable_mapping_push_has_exactly_the_clients_keys()
|
||||
{
|
||||
var wire = JsonSerializer.SerializeToElement(InventoryController.ConsumableMappingWire(new PlayerInventoryEntity
|
||||
{
|
||||
Id = 9,
|
||||
ItemSlug = "some-consumable",
|
||||
Quantity = 3,
|
||||
}));
|
||||
|
||||
var keys = wire.EnumerateObject().Select(p => p.Name).ToHashSet();
|
||||
Assert.Equal(
|
||||
new HashSet<string> { "Id", "ConsumableItemDesc", "Count", "InitialCount", "CreatedAt", "ActiveDurationMinutes", "IsActive", "IsTransferable" },
|
||||
keys);
|
||||
Assert.Equal(JsonValueKind.String, wire.GetProperty("ConsumableItemDesc").ValueKind);
|
||||
Assert.Equal(3, wire.GetProperty("Count").GetInt32());
|
||||
}
|
||||
|
||||
private (WebApplicationFactory<Program> Factory, CapturingHubContext Hub) WithCapturingHub()
|
||||
{
|
||||
var hub = new CapturingHubContext();
|
||||
var factory = _factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IHubContext<NotifyHub>>();
|
||||
services.AddSingleton<IHubContext<NotifyHub>>(hub);
|
||||
}));
|
||||
return (factory, hub);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(WebApplicationFactory<Program> factory, GameClientSession? session = null)
|
||||
{
|
||||
var client = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -257,6 +257,46 @@ public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Base_avatar_items_are_only_the_custom_clothing_templates()
|
||||
{
|
||||
// The client flags every defaultbaseavataritems row as a custom-
|
||||
// clothing template and leaves it out of the owned rebuild, so a
|
||||
// normal item listed here showed a price even when owned.
|
||||
using var anonymous = ApiClient();
|
||||
var rows = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultbaseavataritems");
|
||||
var byDesc = rows.EnumerateArray().ToDictionary(
|
||||
r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0],
|
||||
r => r);
|
||||
Assert.Equal(2, byDesc.Count);
|
||||
Assert.Equal(2184, byDesc["c5d70cb4-71dd-4fe4-b719-34fe2073c611"].GetProperty("AvatarItemId").GetInt32());
|
||||
Assert.Equal(2918, byDesc["95a519de-f2cb-429c-b014-508477f20d42"].GetProperty("AvatarItemId").GetInt32());
|
||||
Assert.All(byDesc.Values, r => Assert.True(r.GetProperty("IsBaseAvatarItem").GetBoolean()));
|
||||
|
||||
// Templates aren't sold or listed as owned clothing.
|
||||
var shop = await GetJsonAsync(anonymous, "/api/storefronts/v3/giftdropstore/3");
|
||||
var shopDescs = shop.GetProperty("StoreItems").EnumerateArray()
|
||||
.Select(r => r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString() ?? "");
|
||||
Assert.DoesNotContain(shopDescs, d => byDesc.ContainsKey(d.Split(',')[0]));
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||
.SetAllAvatarItemsOwnedEnabledAsync(true);
|
||||
try
|
||||
{
|
||||
var owned = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
|
||||
var ownedDescs = owned.EnumerateArray().Select(r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]).ToList();
|
||||
Assert.DoesNotContain(ownedDescs, byDesc.ContainsKey);
|
||||
Assert.Contains("3c2ed30c-ca2a-4f9f-9bfa-ffe561c4a686", ownedDescs); // 80sHipHopJacket
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Beard_Close: one of the client's defaultUnlockedVariants.
|
||||
private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f";
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ public sealed class PrivateRoomInstanceTests : IClassFixture<DorkNetServerFactor
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"{(int)resp.StatusCode}: {text}");
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var cloneId = json.RootElement.GetProperty("RoomId").GetInt64();
|
||||
var cloneId = json.RootElement.GetProperty("value").GetProperty("RoomId").GetInt64();
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
@@ -10,13 +10,15 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and deserialises the
|
||||
/// reply into <c>FGCPNAACHIK</c> — the SAME type <c>GET rooms/{id}</c> returns
|
||||
/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
|
||||
/// The 2023 "copy room" flow POSTs <c>rooms/{id}/clone</c> and reads the reply
|
||||
/// as the <c>{success, value, error}</c> envelope (the same RecNet rooms helper
|
||||
/// as "save room"), whose <c>value</c> is <c>FGCPNAACHIK</c> — the SAME type
|
||||
/// <c>GET rooms/{id}</c> returns (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
|
||||
/// <c>Task<FGCPNAACHIK> GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
|
||||
/// with the route literal on :4802).
|
||||
/// with the route literal on :4802). A bare room with no <c>success</c> is
|
||||
/// rejected with an empty error.
|
||||
///
|
||||
/// So the two responses must be shape-identical. When they drift, the server
|
||||
/// So <c>value</c> and the GET response must be shape-identical. When they drift, the server
|
||||
/// still answers 200 and the client throws while reading it — and because the
|
||||
/// clone promise's exception is never observed, it surfaces from the finalizer
|
||||
/// thread seconds later as a bare "Failed to copy room: Exception of type
|
||||
@@ -63,8 +65,12 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
||||
}
|
||||
|
||||
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
|
||||
var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||
var cloneEnvelope = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||
// The client reads clone through the {success, value, error} envelope.
|
||||
Assert.True(cloneEnvelope.GetProperty("success").GetBoolean(), cloneEnvelope.ToString());
|
||||
Assert.Equal("", cloneEnvelope.GetProperty("error").GetString());
|
||||
var cloneBody = cloneEnvelope.GetProperty("value");
|
||||
|
||||
var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
@@ -157,7 +163,7 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
||||
|
||||
var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", $"Copy{Guid.NewGuid():N}"[..20])]));
|
||||
var cloneId = clone.GetProperty("RoomId").GetInt64();
|
||||
var cloneId = clone.GetProperty("value").GetProperty("RoomId").GetInt64();
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
@@ -203,6 +209,24 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
||||
Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_refusal_is_a_200_envelope_with_a_message()
|
||||
{
|
||||
using var client = Client();
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
// No room has this id, so the clone is refused; the client shows
|
||||
// `error`, which a bare 404 would leave empty.
|
||||
var envelope = await ReadJsonAsync(client, HttpMethod.Post, "/rooms/987654321987/clone",
|
||||
new FormUrlEncodedContent([new KeyValuePair<string, string>("name", "NoSuchSource")]));
|
||||
|
||||
Assert.False(envelope.GetProperty("success").GetBoolean());
|
||||
Assert.False(string.IsNullOrEmpty(envelope.GetProperty("error").GetString()));
|
||||
Assert.Equal(JsonValueKind.Null, envelope.GetProperty("value").ValueKind);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ReadJsonAsync(
|
||||
HttpClient client, HttpMethod method, string path, HttpContent? content = null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// "Publish room" in the 2023 client: RoomModel.TryPublishRoom →
|
||||
/// <c>PUT roomserver/rooms/{id}/accessibility</c> with form
|
||||
/// <c>accessibility=1</c> (boxed Int32, NLDBPDCNNCF_NestedType_OMHIIEAKHCH),
|
||||
/// typed <c>Task<FGCPNAACHIK></c>. The button only exists when
|
||||
/// get_CanPublishRoom holds: role ≥ CoOwner, not a dorm, <b>not IsRRO</b>,
|
||||
/// not already public. A clone of a Rec Room Original template used to be
|
||||
/// advertised as IsRRO and so could never be published.
|
||||
/// </summary>
|
||||
public sealed class RoomPublish2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomPublish2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public void Only_system_owned_originals_are_RRO_on_the_wire()
|
||||
{
|
||||
Assert.True(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = PlayerService.SystemAccountId }));
|
||||
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = true, CreatorPlayerId = 4242 }));
|
||||
Assert.False(RoomService.WireIsRRO(new RoomEntity { IsAGRoom = false, CreatorPlayerId = PlayerService.SystemAccountId }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_of_an_original_is_not_RRO_and_can_be_published_by_owner_and_co_owner()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var coOwner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var moderator = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
var templateId = 9_100_000 + Random.Shared.Next(1, 99_999);
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = templateId,
|
||||
Name = $"Tmpl{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = PlayerService.SystemAccountId,
|
||||
IsAGRoom = true,
|
||||
Accessibility = 1,
|
||||
CloningAllowed = false,
|
||||
ImageName = "image_RecCenter.png",
|
||||
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = templateId, OrderIndex = 0, Name = "Home" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var template = await GetJsonAsync(ownerRooms, $"/roomserver/rooms/{templateId}");
|
||||
Assert.True(template.GetProperty("IsRRO").GetBoolean());
|
||||
|
||||
using var cloneResp = await ownerRooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Pub{Guid.NewGuid():N}"[..20] }));
|
||||
var cloneText = await cloneResp.Content.ReadAsStringAsync();
|
||||
Assert.True(cloneResp.IsSuccessStatusCode, cloneText);
|
||||
var clone = JsonDocument.Parse(cloneText).RootElement.GetProperty("value").Clone();
|
||||
var cloneId = clone.GetProperty("RoomId").GetInt64();
|
||||
Assert.False(clone.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.Equal(0, clone.GetProperty("Accessibility").GetInt32());
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true });
|
||||
db.RoomRoles.Add(new RoomRoleEntity { RoomId = cloneId, PlayerId = moderator.PlayerId, Role = RoomRoleRules.StoredModerator, Accepted = true });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Publish as the owner: full room details back, now public, still not RRO.
|
||||
var published = await PutAccessibilityAsync(ownerRooms, cloneId, "1");
|
||||
Assert.Equal(1, published.GetProperty("Accessibility").GetInt32());
|
||||
Assert.False(published.GetProperty("IsRRO").GetBoolean());
|
||||
Assert.True(published.TryGetProperty("SubRooms", out _));
|
||||
|
||||
// A moderator may not change accessibility; a co-owner may.
|
||||
using var modRooms = Client("rooms", moderator);
|
||||
using (var denied = await modRooms.PutAsync($"/roomserver/rooms/{cloneId}/accessibility",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = "0" })))
|
||||
{
|
||||
// Refusals are 200 {success:false, error} so the client can show them.
|
||||
using var doc = JsonDocument.Parse(await denied.Content.ReadAsStringAsync());
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
}
|
||||
using var coRooms = Client("rooms", coOwner);
|
||||
var unpublished = await PutAccessibilityAsync(coRooms, cloneId, "0");
|
||||
Assert.Equal(0, unpublished.GetProperty("Accessibility").GetInt32());
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PutAccessibilityAsync(HttpClient client, long roomId, string value)
|
||||
{
|
||||
using var response = await client.PutAsync($"/roomserver/rooms/{roomId}/accessibility",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = value }));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {body}");
|
||||
// "change room accessibility" goes through the {success, value, error}
|
||||
// helper (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>), same as clone.
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), body);
|
||||
return doc.RootElement.GetProperty("value").Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 room roles, the co-owner invite flow and the sub-room role-permission
|
||||
/// matrix, against the client contract:
|
||||
/// <list type="bullet">
|
||||
/// <item>Role enum OMMBGJMJJPN: None 0, Host 10, Moderator 20, CoOwner 30,
|
||||
/// TemporaryCoOwner 31, Creator 255. The creator must be listed as 255 —
|
||||
/// the client takes the local role from the Roles list and only lets a
|
||||
/// player edit roles strictly below their own, Creator excepted
|
||||
/// (OMJKHJLFOCO.DIIMLMKEJLC).</item>
|
||||
/// <item>Invite: owner <c>PUT rooms/{id}/roles/{target}/invite</c> role=30;
|
||||
/// invitee gets message Type 62 with RoomId; accepts with
|
||||
/// <c>PUT rooms/{id}/roles/{me}</c> role=30, rejects with
|
||||
/// <c>PUT rooms/{id}/roles/{me}/invite</c> role=0.</item>
|
||||
/// <item>Permissions: <c>PUT rooms/{id}/subrooms/{sub}/permissions</c> raw
|
||||
/// JSON array of {Permission, Role, Override, Type, Value}; read back from
|
||||
/// <c>GET photon_access_token</c>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RoomRoles2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
// ── pure rules ──────────────────────────────────────────────────────
|
||||
|
||||
private static RoomEntity Room(long creator) => new() { Id = 42, CreatorPlayerId = creator };
|
||||
|
||||
private static RoomRoleEntity Row(long player, int stored, bool accepted = true) =>
|
||||
new() { RoomId = 42, PlayerId = player, Role = stored, Accepted = accepted };
|
||||
|
||||
[Fact]
|
||||
public void Creator_is_255_and_listed_once()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var wires = RoomRoleRules.BuildWires(room, new[]
|
||||
{
|
||||
Row(7, RoomRoleRules.StoredCoOwner), // a stray row for the creator
|
||||
Row(8, RoomRoleRules.StoredModerator),
|
||||
Row(9, RoomRoleRules.StoredCoOwner, accepted: false),
|
||||
});
|
||||
|
||||
Assert.Single(wires, w => w.AccountId == 7);
|
||||
Assert.Equal(255, wires.Single(w => w.AccountId == 7).Role);
|
||||
Assert.Equal(20, wires.Single(w => w.AccountId == 8).Role);
|
||||
var invited = wires.Single(w => w.AccountId == 9);
|
||||
Assert.Equal(0, invited.Role);
|
||||
Assert.Equal(30, invited.InvitedRole);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Creator_may_edit_every_role_including_co_owner_and_creator_permissions()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var rows = Array.Empty<RoomRoleEntity>();
|
||||
foreach (var role in new[] { 0, 10, 20, 30, 31, 255 })
|
||||
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 7, role), $"creator role {role}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Co_owner_may_edit_only_roles_below_their_own()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var rows = new[] { Row(8, RoomRoleRules.StoredCoOwner), Row(9, RoomRoleRules.StoredModerator) };
|
||||
|
||||
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 20));
|
||||
Assert.True(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 0));
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 30));
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 31));
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 8, 255));
|
||||
// A moderator edits nothing.
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 9, 0));
|
||||
// Nor does a stranger.
|
||||
Assert.False(RoomRoleRules.CanEditRolePermissions(room, rows, 99, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Role_changes_follow_the_ladder()
|
||||
{
|
||||
var room = Room(creator: 7);
|
||||
var rows = new List<RoomRoleEntity>
|
||||
{
|
||||
Row(8, RoomRoleRules.StoredCoOwner),
|
||||
Row(9, RoomRoleRules.StoredModerator),
|
||||
Row(10, RoomRoleRules.StoredCoOwner, accepted: false),
|
||||
};
|
||||
|
||||
// Creator: anything on anyone but themselves.
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 8, 0, false, out var k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 7, 11, 30, true, out k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.Invite, k);
|
||||
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 7, 7, 0, false, out _));
|
||||
Assert.Equal("cannot_change_creator_role", RoomRoleRules.CheckChange(room, rows, 8, 7, 0, false, out _));
|
||||
|
||||
// Co-owner: only below 30, on targets below 30.
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 11, 20, false, out _));
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 8, 9, 10, false, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 11, 30, true, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 8, 10, 0, true, out _)); // invited co-owner
|
||||
// …and cannot remove a peer co-owner.
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(
|
||||
room, new List<RoomRoleEntity> { Row(8, 0), Row(12, 0) }, 8, 12, 0, false, out _));
|
||||
|
||||
// Moderator and strangers manage nobody.
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 11, 10, false, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 99, 11, 10, false, out _));
|
||||
|
||||
// Self-service: accept exactly the invited role, decline, leave.
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 30, false, out k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.AcceptInvite, k);
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 10, 10, 0, true, out k));
|
||||
Assert.Equal(RoomRoleRules.ChangeKind.Clear, k);
|
||||
Assert.Null(RoomRoleRules.CheckChange(room, rows, 9, 9, 0, false, out _));
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 9, 9, 30, false, out _)); // no invite
|
||||
Assert.Equal("forbidden", RoomRoleRules.CheckChange(room, rows, 10, 10, 20, false, out _)); // not the invited role
|
||||
|
||||
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 255, false, out _));
|
||||
Assert.Equal("invalid_role", RoomRoleRules.CheckChange(room, rows, 7, 11, 31, false, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Permission_matrix_merges_stored_cells_over_defaults()
|
||||
{
|
||||
var merged = RoomPermissionMatrix.Merge(new[]
|
||||
{
|
||||
new RoomRolePermissionEntity { Id = 1, Role = 30, Permission = "13", Type = 0, Override = true, Value = "false" },
|
||||
new RoomRolePermissionEntity { Id = 2, Role = 20, Permission = "13", Type = 0, Override = true, Value = "true" },
|
||||
});
|
||||
|
||||
Assert.Equal("false", merged.Single(c => c.Role == 30 && c.Permission == "13").Value);
|
||||
Assert.Equal("true", merged.Single(c => c.Role == 20 && c.Permission == "13").Value);
|
||||
Assert.Equal("true", merged.Single(c => c.Role == 255 && c.Permission == "13").Value);
|
||||
Assert.Equal(RoomPermissionMatrix.Defaults.Count + 1, merged.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Permission_body_accepts_numbers_strings_and_enum_names()
|
||||
{
|
||||
var cells = RoomPermissionMatrix.Parse(
|
||||
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},""" +
|
||||
"""{"permission":"CAN_USE_MAKER_PEN","role":"Moderator","override":"true","type":"Bool","value":true},""" +
|
||||
"""{"Permission":"20","Role":"255","Override":false,"Type":1,"Value":5}]""");
|
||||
|
||||
Assert.NotNull(cells);
|
||||
Assert.Equal(3, cells!.Count);
|
||||
Assert.Equal(new RoomPermissionMatrix.Cell("13", 30, true, 0, "false"), cells[0]);
|
||||
Assert.Equal(new RoomPermissionMatrix.Cell("CAN_USE_MAKER_PEN", 20, true, 0, "true"), cells[1]);
|
||||
Assert.Equal(new RoomPermissionMatrix.Cell("20", 255, false, 1, "5"), cells[2]);
|
||||
Assert.Null(RoomPermissionMatrix.Parse("role=30"));
|
||||
}
|
||||
|
||||
// ── over HTTP ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Roles_list_reports_the_creator_as_Creator_255()
|
||||
{
|
||||
var (owner, _, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var rooms = Client("rooms", owner);
|
||||
var list = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles");
|
||||
var me = list.EnumerateArray().Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
|
||||
Assert.Equal(255, me.GetProperty("Role").GetInt32());
|
||||
|
||||
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
|
||||
var detailsRole = details.GetProperty("Roles").EnumerateArray()
|
||||
.Single(e => e.GetProperty("AccountId").GetInt64() == owner.PlayerId);
|
||||
Assert.Equal(255, detailsRole.GetProperty("Role").GetInt32());
|
||||
|
||||
var single = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}/roles/{owner.PlayerId}");
|
||||
Assert.Equal(255, single.GetProperty("Role").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Co_owner_invite_is_notified_then_accepted_by_the_invitee()
|
||||
{
|
||||
var (owner, invitee, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var invited = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
var inviteeRow = RoleOf(invited, invitee.PlayerId);
|
||||
Assert.Equal(0, inviteeRow.GetProperty("Role").GetInt32());
|
||||
Assert.Equal(30, inviteeRow.GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
// The invitee's inbox carries message Type 62 (RoomCoOwnerInvited) with RoomId.
|
||||
long messageId;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var msg = await db.Messages.SingleAsync(m =>
|
||||
m.RecipientPlayerId == invitee.PlayerId && m.RoomId == roomId
|
||||
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited);
|
||||
Assert.Equal(owner.PlayerId, msg.SenderPlayerId);
|
||||
Assert.Null(msg.ReadAt);
|
||||
messageId = msg.Id;
|
||||
}
|
||||
|
||||
// Accept: the invitee PUTs their own role to 30.
|
||||
using var inviteeRooms = Client("rooms", invitee);
|
||||
var accepted = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
|
||||
var acceptedRow = RoleOf(accepted, invitee.PlayerId);
|
||||
Assert.Equal(30, acceptedRow.GetProperty("Role").GetInt32());
|
||||
Assert.Equal(0, acceptedRow.GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.NotNull((await db.Messages.SingleAsync(m => m.Id == messageId)).ReadAt);
|
||||
Assert.True(await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == invitee.PlayerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invitee_rejects_through_the_invite_route_and_cannot_self_promote()
|
||||
{
|
||||
var (owner, invitee, stranger) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
|
||||
using var inviteeRooms = Client("rooms", invitee);
|
||||
var rejected = await PutFormAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "0"));
|
||||
Assert.DoesNotContain(rejected.GetProperty("Roles").EnumerateArray(),
|
||||
e => e.GetProperty("AccountId").GetInt64() == invitee.PlayerId);
|
||||
|
||||
// With the invite gone, the same accept is refused.
|
||||
using var again = await PutFormRawAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
|
||||
await AssertRefusedAsync(again);
|
||||
|
||||
// A stranger can neither promote themselves nor invite anyone.
|
||||
using var strangerRooms = Client("rooms", stranger);
|
||||
using var self = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{stranger.PlayerId}", ("role", "20"));
|
||||
await AssertRefusedAsync(self);
|
||||
using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
await AssertRefusedAsync(other);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Co_owner_manages_roles_below_but_not_co_owners()
|
||||
{
|
||||
var (owner, coOwner, target) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
|
||||
|
||||
using var coRooms = Client("rooms", coOwner);
|
||||
var granted = await PutFormAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "20"));
|
||||
Assert.Equal(20, RoleOf(granted, target.PlayerId).GetProperty("Role").GetInt32());
|
||||
|
||||
using var inviteCo = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}/invite", ("role", "30"));
|
||||
await AssertRefusedAsync(inviteCo);
|
||||
|
||||
// The creator can directly add and remove a co-owner.
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var promoted = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "30"));
|
||||
Assert.Equal(30, RoleOf(promoted, target.PlayerId).GetProperty("Role").GetInt32());
|
||||
|
||||
// …and now the co-owner can't touch their peer.
|
||||
using var demote = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
|
||||
await AssertRefusedAsync(demote);
|
||||
|
||||
var removed = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
|
||||
Assert.DoesNotContain(removed.GetProperty("Roles").EnumerateArray(),
|
||||
e => e.GetProperty("AccountId").GetInt64() == target.PlayerId);
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
|
||||
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerAdded));
|
||||
Assert.True(await db.Messages.AnyAsync(m => m.RecipientPlayerId == target.PlayerId && m.RoomId == roomId
|
||||
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerRemoved));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creator_saves_co_owner_and_creator_permissions_and_reads_them_back()
|
||||
{
|
||||
var (owner, coOwner, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, (coOwner.PlayerId, RoomRoleRules.StoredCoOwner));
|
||||
|
||||
using var ownerRooms = Client("rooms", owner);
|
||||
var body = """[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"false"},{"Permission":"20","Role":255,"Override":true,"Type":0,"Value":"false"}]""";
|
||||
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions", body))
|
||||
{
|
||||
var text = await put.Content.ReadAsStringAsync();
|
||||
Assert.True(put.IsSuccessStatusCode, $"{(int)put.StatusCode}: {text}");
|
||||
// {success, value: full room details, error}
|
||||
var value = Envelope(text);
|
||||
Assert.Equal(roomId, value.GetProperty("RoomId").GetInt64());
|
||||
}
|
||||
|
||||
// The owner enters the room, then the matrix comes back with the token.
|
||||
using var match = Client("match", owner);
|
||||
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
|
||||
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
|
||||
var token = await GetJsonAsync(ownerRooms, "/roomserver/photon_access_token");
|
||||
var cells = token.GetProperty("Permissions").EnumerateArray().ToList();
|
||||
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 30
|
||||
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
|
||||
Assert.Equal("false", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
|
||||
&& c.GetProperty("Permission").GetString() == "20").GetProperty("Value").GetString());
|
||||
Assert.Equal("true", cells.Single(c => c.GetProperty("Role").GetInt32() == 255
|
||||
&& c.GetProperty("Permission").GetString() == "13").GetProperty("Value").GetString());
|
||||
|
||||
// A co-owner may not edit the Co-Owner row, but may edit Moderator.
|
||||
using var coRooms = Client("rooms", coOwner);
|
||||
using (var denied = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||
await AssertRefusedAsync(denied);
|
||||
using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||
"""[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||
Envelope(await allowed.Content.ReadAsStringAsync());
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var stored = await db.RoomRolePermissions.Where(p => p.RoomId == roomId).ToListAsync();
|
||||
Assert.Equal(3, stored.Count);
|
||||
Assert.All(stored, p => Assert.Equal(0, p.SubRoomId)); // wire 1 → OrderIndex 0
|
||||
Assert.Equal("false", stored.Single(p => p.Role == 30).Value); // the denied write changed nothing
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invite_pushes_the_message_and_room_and_permission_saves_push_the_access_token()
|
||||
{
|
||||
var hub = new CapturingHubContext();
|
||||
var factory = _factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions
|
||||
.RemoveAll<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(services);
|
||||
services.AddSingleton<Microsoft.AspNetCore.SignalR.IHubContext<DorkNet.Server.Hubs.NotifyHub>>(hub);
|
||||
}));
|
||||
|
||||
HttpClient FactoryClient(string sub, GameClientSession? session = null)
|
||||
{
|
||||
var c = factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
c.BaseAddress = new Uri($"http://{sub}.{_factory.ApexDomain}");
|
||||
c.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
c.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return c;
|
||||
}
|
||||
|
||||
using var setup = FactoryClient("api");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var invitee = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
|
||||
using var ownerRooms = FactoryClient("rooms", owner);
|
||||
await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
|
||||
var message = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "2"));
|
||||
Assert.Equal(62, message.Msg.GetProperty("Type").GetInt32());
|
||||
Assert.Equal(roomId, message.Msg.GetProperty("RoomId").GetInt64());
|
||||
Assert.Equal((int)owner.PlayerId, message.Msg.GetProperty("FromPlayerId").GetInt32());
|
||||
var roomPush = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(invitee.PlayerId), "RoomUpdate"));
|
||||
Assert.Equal(30, RoleOf(roomPush.Msg, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
using var match = FactoryClient("match", owner);
|
||||
using (var join = await match.PostAsync($"/matchmake/room/{roomId}", new FormUrlEncodedContent(new Dictionary<string, string>())))
|
||||
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
|
||||
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
|
||||
"""[{"Permission":"21","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
|
||||
Envelope(await put.Content.ReadAsStringAsync());
|
||||
|
||||
var token = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(owner.PlayerId), "PhotonAccessToken"));
|
||||
Assert.True(token.Msg.GetProperty("RoomInstanceId").GetInt64() > 0);
|
||||
Assert.Contains(token.Msg.GetProperty("Permissions").EnumerateArray(),
|
||||
c => c.GetProperty("Role").GetInt32() == 20 && c.GetProperty("Permission").GetString() == "21");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_filter_wraps_success_and_turns_refusals_into_messages()
|
||||
{
|
||||
static JsonElement Body(Microsoft.AspNetCore.Mvc.IActionResult r) =>
|
||||
JsonDocument.Parse(JsonSerializer.Serialize(((Microsoft.AspNetCore.Mvc.ObjectResult)r).Value)).RootElement.Clone();
|
||||
|
||||
var ok = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.OkObjectResult(new { RoomId = 5 })));
|
||||
Assert.True(ok.GetProperty("success").GetBoolean());
|
||||
Assert.Equal(5, ok.GetProperty("value").GetProperty("RoomId").GetInt32());
|
||||
Assert.Equal("", ok.GetProperty("error").GetString());
|
||||
|
||||
var forbid = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.ForbidResult()));
|
||||
Assert.False(forbid.GetProperty("success").GetBoolean());
|
||||
Assert.False(string.IsNullOrWhiteSpace(forbid.GetProperty("error").GetString()));
|
||||
|
||||
var bad = Body(Controllers.RecNetEnvelopeAttribute.Wrap(
|
||||
new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(new { error = "cannot_change_creator_role" })));
|
||||
Assert.Equal("cannot_change_creator_role", bad.GetProperty("error").GetString());
|
||||
|
||||
// An envelope the handler built itself is left alone.
|
||||
var already = new Microsoft.AspNetCore.Mvc.OkObjectResult(new { success = false, value = (object?)null, error = "x" });
|
||||
Assert.Same(already, Controllers.RecNetEnvelopeAttribute.Wrap(already));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Room_mutations_answer_the_success_value_error_envelope_on_bare_and_roomserver_paths()
|
||||
{
|
||||
var (owner, invitee, _) = await PlayersAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId);
|
||||
using var rooms = Client("rooms", owner);
|
||||
|
||||
// The exact request the client sent in production (bare path, form role=30).
|
||||
var invited = await PutFormAsync(rooms, $"/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
|
||||
Assert.Equal(30, RoleOf(invited, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
|
||||
|
||||
foreach (var (path, key, value) in new[]
|
||||
{
|
||||
($"/rooms/{roomId}/description", "description", "hello"),
|
||||
($"/roomserver/rooms/{roomId}/accessibility", "accessibility", "0"),
|
||||
($"/roomserver/rooms/{roomId}/subrooms/1/name", "name", "Lobby"),
|
||||
($"/roomserver/rooms/{roomId}/subrooms/1/maxplayers", "maxPlayers", "10"),
|
||||
})
|
||||
{
|
||||
var details = await PutFormAsync(rooms, path, (key, value));
|
||||
Assert.Equal(roomId, details.GetProperty("RoomId").GetInt64());
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(GameClientSession A, GameClientSession B, GameClientSession C)> PlayersAsync()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
return (
|
||||
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
|
||||
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain),
|
||||
await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain));
|
||||
}
|
||||
|
||||
private async Task<long> SeedRoomAsync(long creatorId, params (long PlayerId, int StoredRole)[] roles)
|
||||
{
|
||||
var roomId = 9_300_000 + Random.Shared.Next(1, 99_999);
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Roles{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = creatorId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = 0,
|
||||
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||
});
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = 0, Name = "Home" });
|
||||
foreach (var (pid, role) in roles)
|
||||
db.RoomRoles.Add(new RoomRoleEntity { RoomId = roomId, PlayerId = pid, Role = role, Accepted = true });
|
||||
await db.SaveChangesAsync();
|
||||
return roomId;
|
||||
}
|
||||
|
||||
private static JsonElement RoleOf(JsonElement details, long playerId) =>
|
||||
details.GetProperty("Roles").EnumerateArray()
|
||||
.Single(e => e.GetProperty("AccountId").GetInt64() == playerId);
|
||||
|
||||
private static Task<HttpResponseMessage> PutFormRawAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
|
||||
=> client.PutAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
|
||||
|
||||
private static async Task<JsonElement> PutFormAsync(HttpClient client, string path, params (string Key, string Value)[] fields)
|
||||
{
|
||||
using var response = await PutFormRawAsync(client, path, fields);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"PUT {path} -> {(int)response.StatusCode}: {body}");
|
||||
return Envelope(body);
|
||||
}
|
||||
|
||||
/// <summary>Every room mutation answers the {success, value, error}
|
||||
/// envelope (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>): assert success and
|
||||
/// return the room details in <c>value</c>.</summary>
|
||||
private static JsonElement Envelope(string body)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.GetProperty("success").GetBoolean(), body);
|
||||
Assert.Equal(string.Empty, root.GetProperty("error").GetString());
|
||||
return root.GetProperty("value").Clone();
|
||||
}
|
||||
|
||||
/// <summary>A refusal is HTTP 200 with success=false and a message: the
|
||||
/// client shows the message, while a 403/404 surfaces only as the
|
||||
/// message-less "Exception of type 'CEMNLBKJABA' was thrown".</summary>
|
||||
private static async Task AssertRefusedAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.False(doc.RootElement.GetProperty("success").GetBoolean(), body);
|
||||
Assert.False(string.IsNullOrWhiteSpace(doc.RootElement.GetProperty("error").GetString()), body);
|
||||
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("value").ValueKind);
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PutJsonRawAsync(HttpClient client, string path, string json)
|
||||
=> client.PutAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -267,8 +267,10 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var root = json.RootElement;
|
||||
// Full room-details shape (FGCPNAACHIK), not a status wrapper.
|
||||
// {success, value, error} envelope around the full room-details
|
||||
// shape (FGCPNAACHIK).
|
||||
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), text);
|
||||
var root = json.RootElement.GetProperty("value");
|
||||
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
|
||||
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
|
||||
return root.GetProperty("RoomId").GetInt64();
|
||||
|
||||
@@ -416,9 +416,10 @@ public sealed class RroRoomBlobTests : IClassFixture<DorkNetServerFactory>
|
||||
}
|
||||
}
|
||||
|
||||
// The entry sub-room (OrderIndex 0) is wire SubRoomId 1 (RoomSceneIds).
|
||||
private static JsonElement SubRoomZero(JsonElement details) =>
|
||||
details.GetProperty("SubRooms").EnumerateArray()
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 0);
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
|
||||
@@ -58,7 +58,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ── in-game: the owner's slider on sub-room 1 only ──────────────────
|
||||
// ── in-game: the owner's slider on the second sub-room only ─────────
|
||||
// The client addresses it by its wire SubRoomId, OrderIndex + 1 = 2
|
||||
// (RoomSceneIds — the client will not load a sub-room whose id is 0).
|
||||
// Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
|
||||
// Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
|
||||
// "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
|
||||
@@ -66,19 +68,23 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
||||
// real client's body went unread.
|
||||
using var ownerClient = Client("rooms", owner);
|
||||
using var slider = await ownerClient.PutAsJsonAsync(
|
||||
$"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 });
|
||||
$"/rooms/{roomId}/subrooms/2/maxplayers", new { maxPlayers = 24 });
|
||||
var sliderBody = await slider.Content.ReadAsStringAsync();
|
||||
Assert.True(slider.IsSuccessStatusCode,
|
||||
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
|
||||
|
||||
// The client deserialises the reply as FGCPNAACHIK — the room-details
|
||||
// shape — so the slider's own response has to carry the new value.
|
||||
var echoed = JsonDocument.Parse(sliderBody).RootElement
|
||||
// The client reads the reply through the {success, value, error}
|
||||
// helper with value = FGCPNAACHIK, so the slider's own response has to
|
||||
// carry the new value inside the envelope.
|
||||
var envelope = JsonDocument.Parse(sliderBody).RootElement;
|
||||
Assert.True(envelope.GetProperty("success").GetBoolean(), sliderBody);
|
||||
var echoed = envelope.GetProperty("value")
|
||||
.GetProperty("SubRooms").EnumerateArray()
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
|
||||
.First(e => e.GetProperty("SubRoomId").GetInt64() == 2);
|
||||
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
|
||||
|
||||
// ── admin: reads what the owner just set, then edits sub-room 0 ─────
|
||||
// (the admin API addresses sub-rooms by OrderIndex)
|
||||
using var adminClient = Client("admin", admin);
|
||||
var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
|
||||
var subRooms = listed.EnumerateArray()
|
||||
@@ -99,8 +105,9 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
|
||||
.ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
|
||||
e => e.GetProperty("MaxPlayers").GetInt32());
|
||||
|
||||
Assert.Equal(40, wire[0]);
|
||||
Assert.Equal(24, wire[1]);
|
||||
// Wire ids are OrderIndex + 1.
|
||||
Assert.Equal(40, wire[1]);
|
||||
Assert.Equal(24, wire[2]);
|
||||
|
||||
// The room-level figure is the room's advertised capacity, NOT a copy
|
||||
// of sub-room 0 — otherwise the admin edit above would have moved it.
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Sub-room ids and the room instance browser for the 2023 client.
|
||||
/// <list type="bullet">
|
||||
/// <item>The client will not load a sub-room whose id is not positive
|
||||
/// (SubroomModel.DOKCPLJOKIE: <c>RoomId > 0 && SubroomId > 0</c>),
|
||||
/// so the wire SubRoomId is OrderIndex + 1 everywhere the client sees it:
|
||||
/// room details, sub-room routes, matchmaking and the instance browser.</item>
|
||||
/// <item>A new room always owns a real, named entry sub-room, and a startup
|
||||
/// pass repairs rooms that lack one.</item>
|
||||
/// <item><c>GET room/{id}/instances</c> lists live instances with their real
|
||||
/// players, including the private instance of a private room for the people
|
||||
/// allowed into it.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class SubRoomsAndInstances2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public SubRoomsAndInstances2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public void Wire_sub_room_ids_are_positive_and_round_trip()
|
||||
{
|
||||
Assert.Equal(1, RoomSceneIds.ToWire(0));
|
||||
Assert.Equal(3, RoomSceneIds.ToWire(2));
|
||||
Assert.Equal(0, RoomSceneIds.ToOrderIndex(1));
|
||||
Assert.Equal(2, RoomSceneIds.ToOrderIndex(3));
|
||||
// 0 (cached client state, bare matchmake) is the entry sub-room.
|
||||
Assert.Equal(0, RoomSceneIds.ToOrderIndex(0));
|
||||
Assert.Equal(0, RoomSceneIds.ToOrderIndex(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_matchmaking_and_sub_room_routes_share_the_wire_id()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 1, scenes: new[] { "Home", "Arena" });
|
||||
|
||||
using var rooms = Client("rooms", owner);
|
||||
var details = await GetJsonAsync(rooms, $"/roomserver/rooms/{roomId}");
|
||||
var subRooms = details.GetProperty("SubRooms").EnumerateArray().ToList();
|
||||
Assert.Equal(new long[] { 1, 2 }, subRooms.Select(s => s.GetProperty("SubRoomId").GetInt64()));
|
||||
Assert.Equal("Home", subRooms[0].GetProperty("Name").GetString());
|
||||
|
||||
// matchmake/room/{id}/{sub} takes the wire id and echoes it.
|
||||
using var match = Client("match", owner);
|
||||
var arena = await PostFormAsync(match, $"/matchmake/room/{roomId}/2");
|
||||
Assert.Equal(2, arena.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
|
||||
var lobby = await PostFormAsync(match, $"/matchmake/room/{roomId}");
|
||||
Assert.Equal(1, lobby.GetProperty("roomInstance").GetProperty("subRoomId").GetInt64());
|
||||
|
||||
// A sub-room route addressed by wire id 2 edits OrderIndex 1.
|
||||
using (var rename = await rooms.PutAsync($"/roomserver/rooms/{roomId}/subrooms/2/name",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = "Basement" })))
|
||||
Assert.True(rename.IsSuccessStatusCode, await rename.Content.ReadAsStringAsync());
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
Assert.Equal("Basement", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 1)).Name);
|
||||
Assert.Equal("Home", (await db.RoomScenes.SingleAsync(s => s.RoomId == roomId && s.OrderIndex == 0)).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cloning_a_template_without_scene_rows_creates_a_named_home_sub_room()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var templateId = await SeedRoomAsync(1, accessibility: 1, scenes: Array.Empty<string>(), isAg: true);
|
||||
|
||||
using var rooms = Client("rooms", owner);
|
||||
using var resp = await rooms.PostAsync($"/roomserver/rooms/{templateId}/clone",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string> { ["name"] = $"Fresh{Guid.NewGuid():N}"[..20] }));
|
||||
var text = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, text);
|
||||
using var json = JsonDocument.Parse(text);
|
||||
var value = json.RootElement.GetProperty("value");
|
||||
var cloneId = value.GetProperty("RoomId").GetInt64();
|
||||
var sub = value.GetProperty("SubRooms").EnumerateArray().Single();
|
||||
Assert.Equal(1, sub.GetProperty("SubRoomId").GetInt64());
|
||||
Assert.Equal("Home", sub.GetProperty("Name").GetString());
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var scene = await db.RoomScenes.SingleAsync(s => s.RoomId == cloneId);
|
||||
Assert.Equal(0, scene.OrderIndex);
|
||||
Assert.Equal("Home", scene.Name);
|
||||
Assert.Equal("a75f7547-79eb-47c6-8986-6767abcb4f92", scene.RoomSceneLocationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Startup_repair_gives_scene_less_rooms_a_home_and_names_blank_rows()
|
||||
{
|
||||
var bare = await SeedRoomAsync(1, accessibility: 0, scenes: Array.Empty<string>());
|
||||
var blank = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "", " " });
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||
var (added, renamed) = await service.RepairRoomScenesAsync();
|
||||
Assert.True(added >= 1);
|
||||
Assert.True(renamed >= 2);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var home = await db.RoomScenes.SingleAsync(s => s.RoomId == bare);
|
||||
Assert.Equal((0, "Home"), (home.OrderIndex, home.Name));
|
||||
var names = await db.RoomScenes.Where(s => s.RoomId == blank).OrderBy(s => s.OrderIndex).Select(s => s.Name).ToListAsync();
|
||||
Assert.Equal(new[] { "Home", "Subroom 2" }, names);
|
||||
|
||||
// Idempotent.
|
||||
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Room_level_restore_also_restores_the_entry_sub_room()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home", "Back" });
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
await db.RoomScenes.Where(s => s.RoomId == roomId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.DataBlobName, "blob-old"));
|
||||
db.RoomDataBlobs.Add(new RoomDataBlobEntity { RoomId = roomId, BlobName = "blob-new", UploadedByPlayerId = owner.PlayerId });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var api = Client("api", owner);
|
||||
using var response = await api.PostAsync("/api/rooms/v1/restore", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["roomId"] = roomId.ToString(), ["blobName"] = "blob-new",
|
||||
}));
|
||||
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
|
||||
|
||||
await using var check = _factory.Services.CreateAsyncScope();
|
||||
var checkDb = check.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var blobs = await checkDb.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
|
||||
.OrderBy(s => s.OrderIndex).Select(s => s.DataBlobName).ToListAsync();
|
||||
// The entry sub-room follows the restore; other sub-rooms keep theirs.
|
||||
Assert.Equal(new[] { "blob-new", "blob-old" }, blobs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Startup_repair_restores_a_missing_entry_sub_room_next_to_others()
|
||||
{
|
||||
var roomId = await SeedRoomAsync(1, accessibility: 0, scenes: new[] { "Home", "Back" });
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
await db.RoomScenes.Where(s => s.RoomId == roomId && s.OrderIndex == 0).ExecuteDeleteAsync();
|
||||
|
||||
var service = scope.ServiceProvider.GetRequiredService<RoomService>();
|
||||
var (added, _) = await service.RepairRoomScenesAsync();
|
||||
Assert.True(added >= 1);
|
||||
|
||||
var scenes = await db.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId)
|
||||
.OrderBy(s => s.OrderIndex).Select(s => new { s.OrderIndex, s.Name }).ToListAsync();
|
||||
Assert.Equal(new[] { (0, "Home"), (1, "Back") }, scenes.Select(s => (s.OrderIndex, s.Name)));
|
||||
Assert.Equal((0, 0), await service.RepairRoomScenesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Instance_browser_lists_the_private_instance_with_its_players_for_allowed_callers()
|
||||
{
|
||||
var owner = await PlayerAsync();
|
||||
var coOwner = await PlayerAsync();
|
||||
var stranger = await PlayerAsync();
|
||||
var roomId = await SeedRoomAsync(owner.PlayerId, accessibility: 0, scenes: new[] { "Home" });
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = roomId, PlayerId = coOwner.PlayerId, Role = RoomRoleRules.StoredCoOwner, Accepted = true,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ownerMatch = Client("match", owner);
|
||||
var joined = await PostFormAsync(ownerMatch, $"/matchmake/room/{roomId}");
|
||||
var instanceId = joined.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64();
|
||||
|
||||
foreach (var viewer in new[] { owner, coOwner })
|
||||
{
|
||||
using var match = Client("match", viewer);
|
||||
var list = await GetJsonAsync(match, $"/room/{roomId}/instances");
|
||||
var row = list.EnumerateArray().Single();
|
||||
Assert.Equal(instanceId, row.GetProperty("roomInstanceId").GetInt64());
|
||||
Assert.Equal(roomId, row.GetProperty("roomId").GetInt64());
|
||||
Assert.Equal(1, row.GetProperty("subRoomId").GetInt64());
|
||||
Assert.False(row.GetProperty("isFull").GetBoolean());
|
||||
Assert.Equal(new[] { (int)owner.PlayerId },
|
||||
row.GetProperty("playerIds").EnumerateArray().Select(p => p.GetInt32()));
|
||||
}
|
||||
|
||||
// Someone with no way into the private room is not shown it.
|
||||
using var strangerMatch = Client("match", stranger);
|
||||
var hidden = await GetJsonAsync(strangerMatch, $"/room/{roomId}/instances");
|
||||
Assert.Empty(hidden.EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Private_instance_visibility_rule()
|
||||
{
|
||||
var row = new PrivateInstanceEntity { Id = 5, OwnerPlayerId = 3 };
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, true, Array.Empty<long>(), row, false));
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, new long[] { 9 }, row, false));
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(3, false, Array.Empty<long>(), row, false));
|
||||
Assert.True(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, true));
|
||||
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(9, false, Array.Empty<long>(), row, false));
|
||||
Assert.False(Controllers.Match.MatchController.CanSeePrivateInstance(0, true, Array.Empty<long>(), row, true));
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<GameClientSession> PlayerAsync()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
return await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
}
|
||||
|
||||
private async Task<long> SeedRoomAsync(long creatorId, int accessibility, string[] scenes, bool isAg = false)
|
||||
{
|
||||
var roomId = 9_200_000 + Random.Shared.Next(1, 99_999);
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Rooms.Add(new RoomEntity
|
||||
{
|
||||
Id = roomId,
|
||||
Name = $"Subs{Guid.NewGuid():N}"[..18],
|
||||
CreatorPlayerId = creatorId,
|
||||
ImageName = RoomService.DefaultRoomImageName,
|
||||
Accessibility = accessibility,
|
||||
CloningAllowed = true,
|
||||
IsAGRoom = isAg,
|
||||
LocationReplicationId = "a75f7547-79eb-47c6-8986-6767abcb4f92",
|
||||
});
|
||||
for (var i = 0; i < scenes.Length; i++)
|
||||
db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, OrderIndex = i, Name = scenes[i] });
|
||||
await db.SaveChangesAsync();
|
||||
return roomId;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostFormAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.PostAsync(path, new FormUrlEncodedContent(new Dictionary<string, string>()));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"POST {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private HttpClient Client(string subdomain, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,7 @@ public class AvatarItemsController(
|
||||
// miss and silently drop every owned colored item (the contest
|
||||
// hat "I bought it but it never shows in my wardrobe" bug).
|
||||
if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue;
|
||||
if (item.IsBaseAvatarItem) continue; // custom-clothing template, not clothing
|
||||
|
||||
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1;
|
||||
perSlot.TryGetValue(slot, out var count);
|
||||
@@ -277,6 +278,8 @@ public class AvatarItemsController(
|
||||
foreach (var item in catalog.Items.Values)
|
||||
{
|
||||
if (result.Count >= maxItems) return result;
|
||||
// Custom-clothing templates aren't ownable clothing.
|
||||
if (item.IsBaseAvatarItem) continue;
|
||||
result.Add(item);
|
||||
}
|
||||
// Colored variants aren't in the base avatar_item_lookup catalog, so
|
||||
@@ -326,14 +329,33 @@ public class AvatarItemsController(
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>The custom-clothing templates ("base avatar items") and
|
||||
/// their RecNet AvatarItemIds, as the real service served them. The
|
||||
/// client flags every row of <c>defaultbaseavataritems</c> as a base
|
||||
/// item (AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId in
|
||||
/// OutfitManager.AODPBDNAGII) and leaves base items out of the owned
|
||||
/// rebuild (MBPOFMEHDIJ), so they never read as owned clothing. This
|
||||
/// endpoint used to return the first 100 catalog items, which turned
|
||||
/// ordinary items (80sHipHopJacket, Beard Close, ...) into templates
|
||||
/// that showed a price even when owned and equipped.</summary>
|
||||
public static readonly IReadOnlyDictionary<string, int> BaseAvatarItemIds =
|
||||
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["c5d70cb4-71dd-4fe4-b719-34fe2073c611"] = 2184, // UGCTee_Shirt
|
||||
["95a519de-f2cb-429c-b014-508477f20d42"] = 2918, // UGCPulloverHoodie_Shirt
|
||||
};
|
||||
|
||||
public static bool IsBaseAvatarItemGuid(string? guid) =>
|
||||
!string.IsNullOrEmpty(guid) && BaseAvatarItemIds.ContainsKey(guid);
|
||||
|
||||
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult DefaultBaseAvatarItems()
|
||||
{
|
||||
var rows = _catalog.Value.Items.Values
|
||||
.OrderBy(i => i.AvatarItemType)
|
||||
.ThenBy(i => i.FriendlyName)
|
||||
.Take(100)
|
||||
var items = _catalog.Value.Items;
|
||||
var rows = BaseAvatarItemIds.Keys
|
||||
.Where(items.ContainsKey)
|
||||
.Select(g => items[g])
|
||||
.ToList();
|
||||
return Ok(rows);
|
||||
}
|
||||
@@ -607,13 +629,13 @@ public class AvatarItemsController(
|
||||
// GUID as a color GUID and log "Can not find hair dye color".
|
||||
AvatarItemType = AvatarItemType.Outfit,
|
||||
AvatarItemDesc = row.Guid + ",,,",
|
||||
AvatarItemId = StableAvatarItemId(row.Guid),
|
||||
AvatarItemId = BaseAvatarItemIds.TryGetValue(row.Guid, out var baseId) ? baseId : StableAvatarItemId(row.Guid),
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = pretty,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
TagList = string.Empty,
|
||||
IsBaseAvatarItem = false,
|
||||
IsBaseAvatarItem = IsBaseAvatarItemGuid(row.Guid),
|
||||
};
|
||||
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
||||
bySlot[row.OutfitType] = lst = new List<string>();
|
||||
@@ -626,7 +648,7 @@ public class AvatarItemsController(
|
||||
var defaults = dump.DefaultUnlockedVariants
|
||||
.Where(v => string.IsNullOrEmpty(v.Swatch) && string.IsNullOrEmpty(v.Mask) && string.IsNullOrEmpty(v.Decal))
|
||||
.Select(v => v.Item)
|
||||
.Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g))
|
||||
.Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g) && !IsBaseAvatarItemGuid(g))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -56,9 +56,12 @@ public class FreeGiftsController(DorkNetDbContext db, NotificationService notifi
|
||||
await db.SaveChangesAsync();
|
||||
foreach (var row in created)
|
||||
{
|
||||
// The full gift package, as GET api/avatar/v2/gifts lists it: the
|
||||
// client consumes a gift by its Id, which the old hand-built
|
||||
// payload didn't carry.
|
||||
await notifications.NotifyAsync(row.RecipientPlayerId,
|
||||
PushNotificationId.GiftPackageReceived,
|
||||
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
|
||||
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(row.Gift));
|
||||
}
|
||||
|
||||
return Ok(new { Success = true, Sent = created.Count });
|
||||
|
||||
@@ -1295,39 +1295,14 @@ public class ImagesController(
|
||||
.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));
|
||||
}
|
||||
/// <summary>Push the new profile image through the same account pushes
|
||||
/// as every other profile edit: the owner's SelfAccountUpdate and an
|
||||
/// AccountUpdate to everyone. A hand-built account here left out the
|
||||
/// emoji, banner, pronouns, identity flags and creation date, so an image
|
||||
/// change wiped them from the owner's cached account, and nobody else saw
|
||||
/// the new picture.</summary>
|
||||
private Task NotifyAccountImageChangedAsync(PlayerEntity player) =>
|
||||
notifications.AccountChanged(player);
|
||||
|
||||
private static string NormaliseTaggedIds(string? csv, long exceptId)
|
||||
{
|
||||
|
||||
@@ -1095,11 +1095,9 @@ public class InventionsController(
|
||||
});
|
||||
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 });
|
||||
// No push: InventionModerationStateChanged ("100") means "your
|
||||
// inventions' moderation state changed" and takes no payload; sent
|
||||
// on every cheer it only made the creator refetch for nothing.
|
||||
}
|
||||
else if (!req.Cheer && existing is not null)
|
||||
{
|
||||
@@ -1256,8 +1254,10 @@ public class InventionsController(
|
||||
if (!canPush) return Forbid();
|
||||
|
||||
var subRoomId = req.SubRoomId;
|
||||
// Wire SubRoomId (OrderIndex + 1) → the scene's OrderIndex.
|
||||
var sceneOrderIndex = Services.RoomSceneIds.ToOrderIndex(subRoomId);
|
||||
var scene = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
|
||||
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == sceneOrderIndex);
|
||||
|
||||
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
|
||||
if (subRoomDataFilename.Length == 0)
|
||||
@@ -1271,7 +1271,7 @@ public class InventionsController(
|
||||
if (subRoomBlob is null)
|
||||
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
|
||||
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
|
||||
subRoomBlob.SubRoomId ??= subRoomId;
|
||||
subRoomBlob.SubRoomId ??= sceneOrderIndex;
|
||||
|
||||
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
|
||||
if (roomDataFilename.Length > 0)
|
||||
|
||||
@@ -279,11 +279,11 @@ public class InventoryController(
|
||||
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// A consume always changes an existing mapping, so it is always
|
||||
// ConsumableMappingRemoved (71) carrying the count that is left;
|
||||
// Added (70) would make the client treat it as a new stack.
|
||||
await notifications.NotifyAsync(pid,
|
||||
row.Quantity > 0
|
||||
? PushNotificationId.ConsumableMappingAdded
|
||||
: PushNotificationId.ConsumableMappingRemoved,
|
||||
new { row.Id, row.ItemSlug, row.Quantity });
|
||||
PushNotificationId.ConsumableMappingRemoved, ConsumableMappingWire(row));
|
||||
return Ok(new { row.Id, Count = row.Quantity });
|
||||
}
|
||||
|
||||
@@ -332,8 +332,7 @@ public class InventoryController(
|
||||
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.NotifyAsync(recipient,
|
||||
PushNotificationId.ConsumableMappingAdded,
|
||||
new { target.Id, target.ItemSlug, target.Quantity });
|
||||
PushNotificationId.ConsumableMappingAdded, ConsumableMappingWire(target));
|
||||
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
|
||||
}
|
||||
|
||||
@@ -449,6 +448,26 @@ public class InventoryController(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>ConsumableMapping push payload ("70" Added / "71" Removed):
|
||||
/// exactly the client's mapping DTO <c>CGCALDBHLGD</c> (formatter
|
||||
/// <c>JICIOMDBPDO</c>) — Id (Int64), ConsumableItemDesc (STRING, not an
|
||||
/// object), Count, InitialCount, CreatedAt, ActiveDurationMinutes (Int32?),
|
||||
/// IsActive, IsTransferable. The old <c>{Id, ItemSlug, Quantity}</c> parsed
|
||||
/// as a mapping with no item and a count of 0.</summary>
|
||||
public static object ConsumableMappingWire(PlayerInventoryEntity inv) => new
|
||||
{
|
||||
inv.Id,
|
||||
ConsumableItemDesc = StoreService.TryGetConsumableItemDesc(inv.ItemSlug, out var desc) ? desc : inv.ItemSlug,
|
||||
Count = inv.Quantity,
|
||||
InitialCount = inv.Quantity, // the original purchase count isn't tracked
|
||||
CreatedAt = inv.AcquiredAt,
|
||||
ActiveDurationMinutes = (int?)null,
|
||||
inv.IsActive,
|
||||
// Same as GET api/consumables/v2/getUnlocked, so a push never flips
|
||||
// what the inventory list says.
|
||||
IsTransferable = false,
|
||||
};
|
||||
|
||||
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
|
||||
{
|
||||
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
|
||||
|
||||
@@ -152,8 +152,6 @@ public class InGameModerationController(
|
||||
foreach (var target in ids)
|
||||
{
|
||||
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
|
||||
// Push the ModerationKick shape the 2020.12 client expects:
|
||||
// Msg.Reason must be a ModerationBlockDetail object.
|
||||
await notifications.KickPlayerAsync(target, "Instant kick by moderator.");
|
||||
}
|
||||
return Ok(new { success = true, error = "" });
|
||||
@@ -249,21 +247,14 @@ public class InGameModerationController(
|
||||
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
|
||||
caller, target, reason, p.BannedUntil, displayReason);
|
||||
|
||||
// Push the ban as a ModerationKick (22) with IsBan set, NOT as
|
||||
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager
|
||||
// registers only 22, "ModerationUnkick" and 23
|
||||
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler
|
||||
// for 24, so that push landed nowhere and the banned player kept playing
|
||||
// until their next login. The 22 handler reads Msg.Reason as a
|
||||
// ModerationBlockDetail, which ModerationKickPayload already produces.
|
||||
await notifications.KickPlayerAsync(
|
||||
target,
|
||||
// `reason` is the numeric report category; DisplayReason is the
|
||||
// moderator's text and is what the ban dialog shows.
|
||||
string.IsNullOrWhiteSpace(displayReason)
|
||||
? "You have been banned from this room."
|
||||
: displayReason.Trim(),
|
||||
isBan: true);
|
||||
// Sign the banned player out with Logout "6" (Reason Banned). Not
|
||||
// ModerationRoomBan (24): the 2023-03-21 client registers no handler
|
||||
// for it (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335 register only
|
||||
// 22, "ModerationUnkick" and 23), so that push landed nowhere. Not a
|
||||
// ModerationKick either: a kick only sends the player to their dorm
|
||||
// and they kept playing until their next login. The ban check refuses
|
||||
// that next login.
|
||||
await notifications.LogoutAsync(target, NotificationService.LogoutReason.Banned);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents;
|
||||
public class PlayerEventsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
PlayerPresenceService presence) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
@@ -393,7 +394,11 @@ public class PlayerEventsController(
|
||||
if (evt is null) return NotFound();
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null;
|
||||
// The event stores the client's wire SubRoomId; presence and private
|
||||
// instances hold the OrderIndex (RoomSceneIds).
|
||||
var subRoomId = extras.SubRoomId is long sub && sub > 0
|
||||
? RoomSceneIds.ToOrderIndex(sub)
|
||||
: (long?)null;
|
||||
|
||||
// Same sourcing as the room browser: presence rows are the live truth
|
||||
// (one instance per distinct PhotonRoomId), and the caller's OWN private
|
||||
@@ -416,7 +421,7 @@ public class PlayerEventsController(
|
||||
{
|
||||
roomInstanceId = room.RoomInstanceId,
|
||||
roomId = room.RoomId,
|
||||
subRoomId = room.SubRoomId,
|
||||
subRoomId = RoomSceneIds.ToWire(room.SubRoomId),
|
||||
// MaxCapacity is the per-instance cap the matchmaker handed
|
||||
// out, so this is a real answer rather than a constant false.
|
||||
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
|
||||
@@ -437,7 +442,7 @@ public class PlayerEventsController(
|
||||
{
|
||||
roomInstanceId = p.Id,
|
||||
roomId = p.RoomId,
|
||||
subRoomId = p.SubRoomId,
|
||||
subRoomId = RoomSceneIds.ToWire(p.SubRoomId),
|
||||
isFull = false,
|
||||
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
|
||||
playerIds = Array.Empty<int>(),
|
||||
@@ -817,7 +822,7 @@ public class PlayerEventsController(
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
await ApplyBodyToExtrasAsync(extras);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -901,10 +906,7 @@ public class PlayerEventsController(
|
||||
|
||||
// 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 });
|
||||
await NotifyResponseChangedAsync(ev, row);
|
||||
// Left as the historical shape on purpose: this route is 2020-only
|
||||
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
|
||||
// so there is no 2023 DTO to match here.
|
||||
@@ -947,10 +949,15 @@ public class PlayerEventsController(
|
||||
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 });
|
||||
{
|
||||
existing = new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response };
|
||||
db.PlayerEventResponses.Add(existing);
|
||||
}
|
||||
else
|
||||
existing.Response = response;
|
||||
await db.SaveChangesAsync();
|
||||
var responded = await db.PlayerEvents.FirstAsync(e => e.Id == evt);
|
||||
await NotifyResponseChangedAsync(responded, existing);
|
||||
return RespondResult(legacyForm, 0 /*Success*/);
|
||||
}
|
||||
|
||||
@@ -968,9 +975,18 @@ public class PlayerEventsController(
|
||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||
|
||||
await db.PlayerEventResponses
|
||||
var deleted = await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == Me && r.EventId == evt)
|
||||
.ExecuteDeleteAsync();
|
||||
if (deleted > 0)
|
||||
{
|
||||
// PlayerEventResponseDeleted "84" carries only PlayerEventId.
|
||||
var creator = await db.PlayerEvents.Where(e => e.Id == evt)
|
||||
.Select(e => (long?)e.CreatorPlayerId).FirstOrDefaultAsync();
|
||||
if (creator is long c && c != Me)
|
||||
await notifications.NotifyAsync(c, PushNotificationId.PlayerEventResponseDeleted,
|
||||
new { PlayerEventId = evt });
|
||||
}
|
||||
return RespondResult(legacyForm, 0 /*Success*/);
|
||||
}
|
||||
|
||||
@@ -993,6 +1009,7 @@ public class PlayerEventsController(
|
||||
// has to echo the event that was deleted.
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
|
||||
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
|
||||
var notify = await AttendeeIdsAsync(evt);
|
||||
|
||||
db.PlayerEvents.Remove(evt);
|
||||
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
|
||||
@@ -1000,6 +1017,7 @@ public class PlayerEventsController(
|
||||
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
|
||||
.ExecuteDeleteAsync();
|
||||
await db.SaveChangesAsync();
|
||||
await NotifyEventDeletedAsync(id, notify);
|
||||
return Ok(Wrapper(evt, extras, attendees));
|
||||
}
|
||||
|
||||
@@ -1021,6 +1039,7 @@ public class PlayerEventsController(
|
||||
evt.Title = Clamp(value.Trim(), 128);
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1034,6 +1053,7 @@ public class PlayerEventsController(
|
||||
evt.Description = Clamp(value.Trim(), 2000);
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1056,6 +1076,7 @@ public class PlayerEventsController(
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1072,6 +1093,7 @@ public class PlayerEventsController(
|
||||
if (start is null && end is null) return BadRequest("missing_time");
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1085,6 +1107,7 @@ public class PlayerEventsController(
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.ImageName = Clamp(value.Trim(), 128);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1099,6 +1122,7 @@ public class PlayerEventsController(
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.Accessibility = accessibility;
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1120,6 +1144,7 @@ public class PlayerEventsController(
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.Tags = tags;
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1158,6 +1183,7 @@ public class PlayerEventsController(
|
||||
?? extras.CanRequestBroadcastPermissions;
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1188,6 +1214,7 @@ public class PlayerEventsController(
|
||||
}
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1197,6 +1224,7 @@ public class PlayerEventsController(
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
if (ev is null) return NotFound();
|
||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||
var notify = await AttendeeIdsAsync(ev);
|
||||
db.PlayerEvents.Remove(ev);
|
||||
// Cascade-clean the responses; SQLite has no FK cascade
|
||||
// configured here so do it manually.
|
||||
@@ -1206,6 +1234,7 @@ public class PlayerEventsController(
|
||||
await db.PlayerSettings
|
||||
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
|
||||
.ExecuteDeleteAsync();
|
||||
await NotifyEventDeletedAsync(eventId, notify);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -1244,6 +1273,7 @@ public class PlayerEventsController(
|
||||
var failed = new List<object>();
|
||||
var sender = Me;
|
||||
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
|
||||
var eventWire = await ToWireOneAsync(ev);
|
||||
foreach (var rid in req.InvitedPlayerIds.Distinct())
|
||||
{
|
||||
var exists = await db.Players.AnyAsync(p => p.Id == rid);
|
||||
@@ -1252,17 +1282,15 @@ public class PlayerEventsController(
|
||||
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
|
||||
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 });
|
||||
// The invite as a text message from the host, stored and pushed
|
||||
// live (it used to be stored only, so it appeared after a relog).
|
||||
await systemNotifications.SendAsync(rid,
|
||||
SystemNotificationService.MessageType.TextMessage, fromPlayerId: sender, data: inviteText);
|
||||
// And the event itself, so it shows in the invitee's event list:
|
||||
// PlayerEventCreated "80" carries the full event (MDCBEPJCJPO) —
|
||||
// the same object GET api/playerevents/v2/{id} returns.
|
||||
await notifications.NotifyAsync(rid, PushNotificationId.PlayerEventCreated, eventWire);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
||||
}
|
||||
|
||||
@@ -1289,21 +1317,56 @@ public class PlayerEventsController(
|
||||
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
|
||||
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge
|
||||
// them to re-fetch instead of sending a chat message.
|
||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged);
|
||||
// Attendees' event cards key off BroadcastingRoomInstanceId, so send
|
||||
// them the updated event.
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
|
||||
{
|
||||
var recipients = await db.PlayerEventResponses
|
||||
/// <summary>Everyone who answered the event with anything but NotGoing,
|
||||
/// except its creator.</summary>
|
||||
private async Task<List<long>> AttendeeIdsAsync(PlayerEventEntity evt) =>
|
||||
(await db.PlayerEventResponses
|
||||
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
|
||||
.Select(r => r.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
|
||||
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
|
||||
.ToListAsync())
|
||||
.Where(p => p != evt.CreatorPlayerId)
|
||||
.ToList();
|
||||
|
||||
/// <summary>PlayerEventUpdated "81" to the attendees, carrying the full
|
||||
/// event (MDCBEPJCJPO, formatter CENEMCMGDKG) — the same object
|
||||
/// GET api/playerevents/v2/{id} returns; the client replaces its cached
|
||||
/// event with it. The old <c>{PlayerEventId, Title}</c> overwrote the
|
||||
/// cached event with an empty name and default times, and the
|
||||
/// PlayerEventStateChanged (85) used for broadcasts has no client handler
|
||||
/// at all.</summary>
|
||||
private async Task NotifyEventUpdatedAsync(PlayerEventEntity evt)
|
||||
{
|
||||
var recipients = await AttendeeIdsAsync(evt);
|
||||
if (recipients.Count == 0) return;
|
||||
var wire = await ToWireOneAsync(evt);
|
||||
foreach (var playerId in recipients)
|
||||
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventUpdated, wire);
|
||||
}
|
||||
|
||||
/// <summary>PlayerEventDeleted "82" ({PlayerEventId}) to the players who
|
||||
/// were attending, collected before the rows were removed.</summary>
|
||||
private async Task NotifyEventDeletedAsync(long eventId, IEnumerable<long> recipients)
|
||||
{
|
||||
foreach (var playerId in recipients)
|
||||
await notifications.NotifyAsync(playerId, PushNotificationId.PlayerEventDeleted,
|
||||
new { PlayerEventId = eventId });
|
||||
}
|
||||
|
||||
/// <summary>PlayerEventResponseChanged "83" to the event's creator:
|
||||
/// <c>{PlayerEvent, PlayerEventResponse}</c> (COAGAJPELCG), each the same
|
||||
/// object the event and response REST routes return.</summary>
|
||||
private async Task NotifyResponseChangedAsync(PlayerEventEntity evt, PlayerEventResponseEntity response)
|
||||
{
|
||||
if (evt.CreatorPlayerId == response.PlayerId) return;
|
||||
await notifications.NotifyAsync(evt.CreatorPlayerId, PushNotificationId.PlayerEventResponseChanged,
|
||||
new { PlayerEvent = await ToWireOneAsync(evt), PlayerEventResponse = ResponseWire(response) });
|
||||
}
|
||||
|
||||
// ── Report ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -48,6 +48,7 @@ public class PlaylistsController(
|
||||
/// query or JSON body so JSON callers (admin UI) keep working.</summary>
|
||||
[HttpPost("/playlists")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var name = await ReadFieldAsync("name", "Name");
|
||||
@@ -87,6 +88,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/name")]
|
||||
[HttpPut("/playlists/{playlistId:long}/name")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistName(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("name", "Name", "value", "Value");
|
||||
@@ -96,6 +98,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/description")]
|
||||
[HttpPut("/playlists/{playlistId:long}/description")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistDescription(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("description", "Description", "value", "Value");
|
||||
@@ -105,6 +108,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/image")]
|
||||
[HttpPut("/playlists/{playlistId:long}/image")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistImage(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
|
||||
@@ -116,6 +120,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/tags")]
|
||||
[HttpPut("/playlists/{playlistId:long}/tags")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistTags(long playlistId)
|
||||
{
|
||||
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
|
||||
@@ -131,6 +136,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/visibility")]
|
||||
[HttpPut("/playlists/{playlistId:long}/visibility")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistAccessibility(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync(
|
||||
@@ -144,6 +150,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
|
||||
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
|
||||
{
|
||||
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
|
||||
@@ -156,6 +163,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/restrictions")]
|
||||
[HttpPut("/playlists/{playlistId:long}/restrictions")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistRestrictions(long playlistId)
|
||||
{
|
||||
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
|
||||
@@ -174,6 +182,7 @@ public class PlaylistsController(
|
||||
[HttpPost("/playlists/{playlistId:long}/warning")]
|
||||
[HttpPut("/playlists/{playlistId:long}/warning")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PlaylistWarning(long playlistId)
|
||||
{
|
||||
var mask = await ReadFieldAsync("warningMask", "WarningMask");
|
||||
@@ -295,6 +304,7 @@ public class PlaylistsController(
|
||||
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
|
||||
{
|
||||
try
|
||||
@@ -307,6 +317,7 @@ public class PlaylistsController(
|
||||
|
||||
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -48,7 +48,11 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase
|
||||
public class RelationshipsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
ServerSettingsService settings) : ControllerBase
|
||||
{
|
||||
// RelationshipType — int values match Relationship.RelationshipType
|
||||
// in the patched dump.cs.
|
||||
@@ -226,7 +230,9 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await notifications.FriendRequestReceived(id, me);
|
||||
await PushPairAsync(me, id);
|
||||
// The "X sent you a friend request" row in the target's notifications.
|
||||
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendInvite, fromPlayerId: me);
|
||||
return Ok(BuildRelationship(row, me));
|
||||
}
|
||||
|
||||
@@ -251,7 +257,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
||||
rel.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await notifications.FriendRequestAccepted(id, me);
|
||||
await PushPairAsync(me, id);
|
||||
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
|
||||
return Ok(BuildRelationship(rel, me));
|
||||
}
|
||||
|
||||
@@ -285,7 +292,7 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
||||
|
||||
db.Relationships.RemoveRange(removable);
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.FriendRemoved(id, me);
|
||||
await PushPairAsync(me, id);
|
||||
|
||||
var remaining = pair.Except(removable).ToList();
|
||||
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
|
||||
@@ -333,6 +340,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
await PushPairAsync(me, id);
|
||||
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
|
||||
return Ok(BuildRelationship(existing, me));
|
||||
}
|
||||
|
||||
@@ -411,6 +420,10 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
||||
if (ignored is bool i) row.Ignored = i;
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
// The HTTP reply is only an ack; the updated row reaches the client
|
||||
// through RelationshipChanged. A preference is the caller's own bit,
|
||||
// so only the caller is told.
|
||||
await PushPairAsync(me, otherId, notifyOther: false);
|
||||
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
@@ -511,6 +524,36 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Push RelationshipChanged for the pair (<paramref name="me"/>,
|
||||
/// <paramref name="other"/>) after a change, each side getting its OWN view
|
||||
/// of the relationship. Without this a friend request, accept or unfriend
|
||||
/// only showed up after the friends list was refetched, and the client
|
||||
/// never re-subscribed to the new friend's presence.</summary>
|
||||
private async Task PushPairAsync(long me, long other, bool notifyOther = true)
|
||||
{
|
||||
var pair = await db.Relationships
|
||||
.Where(r => (r.RequesterId == me && r.TargetId == other) ||
|
||||
(r.RequesterId == other && r.TargetId == me))
|
||||
.ToListAsync();
|
||||
var globalFriends = await settings.IsGlobalFriendsEnabledAsync();
|
||||
|
||||
await notifications.RelationshipChanged(me, ViewOf(me, other, pair, globalFriends));
|
||||
if (notifyOther)
|
||||
await notifications.RelationshipChanged(other, ViewOf(other, me, pair, globalFriends));
|
||||
}
|
||||
|
||||
/// <summary>The relationship from <paramref name="viewer"/>'s side, the way
|
||||
/// <c>v2/get</c> would list it.</summary>
|
||||
private static Dictionary<string, object> ViewOf(
|
||||
long viewer, long other, List<RelationshipEntity> pair, bool globalFriends)
|
||||
{
|
||||
if (globalFriends
|
||||
&& other != RelationshipQueries.SystemAccountId
|
||||
&& !pair.Any(r => r.Status == EntityStatus.Blocked))
|
||||
return SynthFriend(other, pair.FirstOrDefault(r => r.RequesterId == viewer), viewer);
|
||||
return pair.Count == 0 ? EmptyRelationship(other) : BuildMerged(viewer, other, pair);
|
||||
}
|
||||
|
||||
private async Task<HashSet<long>> FriendIdsAsync(long playerId)
|
||||
{
|
||||
var rows = await db.Relationships
|
||||
|
||||
@@ -35,6 +35,7 @@ public class RoomsController(
|
||||
DomainConfig domain,
|
||||
NotificationService notifications,
|
||||
ServerSettingsService serverSettings,
|
||||
SystemNotificationService systemNotifications,
|
||||
ILogger<RoomsController> logger) : ControllerBase
|
||||
{
|
||||
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
|
||||
@@ -574,6 +575,7 @@ public class RoomsController(
|
||||
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
|
||||
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var blobs = await db.RoomDataBlobs
|
||||
.Where(b => b.RoomId == roomId
|
||||
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
|
||||
@@ -586,7 +588,7 @@ public class RoomsController(
|
||||
.Take(50)
|
||||
.Select(b => new
|
||||
{
|
||||
SubRoomId = subRoomId,
|
||||
SubRoomId = RoomSceneIds.ToWire(subRoomId),
|
||||
DataBlob = b.BlobName,
|
||||
SavedByAccountId = b.UploadedByPlayerId,
|
||||
CreatedAt = b.UploadedAt,
|
||||
@@ -613,6 +615,7 @@ public class RoomsController(
|
||||
[FromQuery] int? unityAssetTarget,
|
||||
[FromQuery] int? unityAssetVersion)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
@@ -661,6 +664,7 @@ public class RoomsController(
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRoom(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
@@ -689,6 +693,7 @@ public class RoomsController(
|
||||
[FromQuery] int? unityAssetTarget,
|
||||
[FromQuery] int? unityAssetVersion)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
@@ -731,6 +736,7 @@ public class RoomsController(
|
||||
long roomId, long subRoomId,
|
||||
[FromQuery] int skip = 0, [FromQuery] int take = 50)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found", roomId });
|
||||
|
||||
@@ -763,7 +769,7 @@ public class RoomsController(
|
||||
saves.Add(new
|
||||
{
|
||||
SubRoomDataSaveId = currentSaveId,
|
||||
SubRoomId = subRoomId,
|
||||
SubRoomId = RoomSceneIds.ToWire(subRoomId),
|
||||
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||
@@ -809,7 +815,7 @@ public class RoomsController(
|
||||
var modified = scene?.DataModifiedAt ?? room.UpdatedAt;
|
||||
return new
|
||||
{
|
||||
SubRoomId = (long)orderIndex,
|
||||
SubRoomId = RoomSceneIds.ToWire(orderIndex),
|
||||
RoomId = room.Id,
|
||||
Name = scene?.Name ?? "Home",
|
||||
DataBlob = dataBlob,
|
||||
@@ -824,7 +830,7 @@ public class RoomsController(
|
||||
CurrentSave = new
|
||||
{
|
||||
SubRoomDataSaveId = scene?.StudioSubRoomDataSaveId ?? 0L,
|
||||
SubRoomId = (long)orderIndex,
|
||||
SubRoomId = RoomSceneIds.ToWire(orderIndex),
|
||||
UnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
SubRoomUnityAssetId = scene?.StudioUnityAssetId ?? string.Empty,
|
||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||
@@ -871,7 +877,7 @@ public class RoomsController(
|
||||
return new
|
||||
{
|
||||
SubRoomDataSaveId = saveId,
|
||||
SubRoomId = subRoomId,
|
||||
SubRoomId = RoomSceneIds.ToWire(subRoomId),
|
||||
UnityAssetId = unityAssetId,
|
||||
SubRoomUnityAssetId = unityAssetId,
|
||||
ReferencedUnityAssetIds = Array.Empty<string>(),
|
||||
@@ -916,13 +922,14 @@ public class RoomsController(
|
||||
public async Task<IActionResult> SubRoomRestoreData(
|
||||
long roomId, long subRoomId, [FromForm] string? filename)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
if (string.IsNullOrWhiteSpace(filename))
|
||||
return BadRequest(new { error = "missing_filename" });
|
||||
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null) return NotFound(new { error = "room_not_found" });
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
|
||||
var blob = await db.RoomDataBlobs
|
||||
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
|
||||
@@ -1399,6 +1406,7 @@ public class RoomsController(
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
|
||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
||||
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(room.Id, blob.BlobName);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push the same SubscriptionUpdateRoom the save flow sends so
|
||||
@@ -1472,10 +1480,27 @@ public class RoomsController(
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
|
||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
|
||||
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(roomId, blob.BlobName);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { restored = blob.BlobName });
|
||||
}
|
||||
|
||||
/// <summary>A room-level restore also restores the entry sub-room
|
||||
/// (OrderIndex 0): room details serve the entry scene's own blob ahead
|
||||
/// of the room's, so updating only <c>Rooms.CurrentDataBlobName</c>
|
||||
/// would leave the old version in place. The per-sub-room restore keeps
|
||||
/// the two in step the other way round. Dorms keep their blob per
|
||||
/// player (DormState) instead.</summary>
|
||||
private Task SyncEntrySceneBlobAsync(long roomId, string blobName)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return db.RoomScenes
|
||||
.Where(s => s.RoomId == roomId && s.OrderIndex == 0)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.DataBlobName, blobName)
|
||||
.SetProperty(x => x.DataModifiedAt, now));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
|
||||
/// per-caller bookmark/cheer flags for one room. Wire shape verified
|
||||
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
|
||||
@@ -2001,7 +2026,7 @@ public class RoomsController(
|
||||
UseLevelBasedMatchmaking = false,
|
||||
UseAgeBasedMatchmaking = false,
|
||||
UseRecRoyaleMatchmaking = false,
|
||||
SubRoomId = (long)s.OrderIndex,
|
||||
SubRoomId = RoomSceneIds.ToWire(s.OrderIndex),
|
||||
RoomId = room.Id,
|
||||
Name = s.Name,
|
||||
UnityAssetId = s.StudioUnityAssetId,
|
||||
@@ -2027,7 +2052,7 @@ public class RoomsController(
|
||||
UseLevelBasedMatchmaking = false,
|
||||
UseAgeBasedMatchmaking = false,
|
||||
UseRecRoyaleMatchmaking = false,
|
||||
SubRoomId = 0L,
|
||||
SubRoomId = RoomSceneIds.ToWire(0),
|
||||
RoomId = room.Id,
|
||||
Name = "Home",
|
||||
UnityAssetId = string.Empty,
|
||||
@@ -2046,11 +2071,10 @@ public class RoomsController(
|
||||
},
|
||||
};
|
||||
|
||||
var wireRoles = new List<object>
|
||||
{
|
||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
|
||||
};
|
||||
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
|
||||
// The creator is Creator (255), never CoOwner (30): the client reads
|
||||
// the local player's role from this list and only lets a player edit
|
||||
// roles strictly below their own (see RoomRoleRules).
|
||||
var wireRoles = RoomRoleRules.BuildWires(room, roleList);
|
||||
|
||||
var tags = string.IsNullOrEmpty(room.TagsCsv)
|
||||
? Array.Empty<object>()
|
||||
@@ -2075,7 +2099,9 @@ public class RoomsController(
|
||||
State = room.State,
|
||||
Accessibility = room.Accessibility,
|
||||
SupportsLevelVoting = room.SupportsLevelVoting,
|
||||
IsRRO = room.IsAGRoom,
|
||||
// Player rooms are never RRO on the wire (see RoomService.WireIsRRO):
|
||||
// the client refuses publish and room settings for an RRO.
|
||||
IsRRO = RoomService.WireIsRRO(room),
|
||||
SupportsScreens = room.SupportsScreens,
|
||||
SupportsWalkVR = room.SupportsWalkVR,
|
||||
SupportsTeleportVR = room.SupportsTeleportVR,
|
||||
@@ -2904,6 +2930,7 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/name")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/name")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> RenameRoom(
|
||||
long roomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
|
||||
@@ -2922,9 +2949,9 @@ public class RoomsController(
|
||||
{
|
||||
// RoomRoles co-owner check — same authorisation as the
|
||||
// existing modify endpoint, lets co-owners rename too.
|
||||
var coOwner = await db.RoomRoles.AnyAsync(rr =>
|
||||
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
|
||||
if (!coOwner) return Forbid();
|
||||
// Room settings are creator / co-owner only; moderators and
|
||||
// hosts moderate players, they don't edit the room.
|
||||
if (!await CanEditRoomAsync(room, me)) return Forbid();
|
||||
}
|
||||
|
||||
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
|
||||
@@ -2959,11 +2986,13 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> RenameSubroom(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var newName = body?.Name;
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
return BadRequest(new { Result = 1, error = "empty_name" });
|
||||
@@ -2976,9 +3005,9 @@ public class RoomsController(
|
||||
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
|
||||
if (room.CreatorPlayerId != me)
|
||||
{
|
||||
var coOwner = await db.RoomRoles.AnyAsync(rr =>
|
||||
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
|
||||
if (!coOwner) return Forbid();
|
||||
// Room settings are creator / co-owner only; moderators and
|
||||
// hosts moderate players, they don't edit the room.
|
||||
if (!await CanEditRoomAsync(room, me)) return Forbid();
|
||||
}
|
||||
|
||||
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
|
||||
@@ -3607,6 +3636,7 @@ public class RoomsController(
|
||||
[HttpPost("rooms/{roomId:long}/subrooms")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> AddSubRoom(
|
||||
long roomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
|
||||
@@ -3614,7 +3644,7 @@ public class RoomsController(
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
|
||||
var name = (req.Name ?? string.Empty).Trim();
|
||||
if (name.Length == 0) name = "NewScene";
|
||||
@@ -3649,12 +3679,14 @@ public class RoomsController(
|
||||
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
var scene = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
@@ -3710,20 +3742,6 @@ public class RoomsController(
|
||||
/// <summary>One row of the per-subroom role-permission matrix the 2023
|
||||
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
|
||||
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
|
||||
public sealed class SubRoomPermissionEntry
|
||||
{
|
||||
// Field order + CLR types come from the BNLPBMJJOMM constructor
|
||||
// (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role,
|
||||
// Boolean Override, FEOJFGECKGH Type, String Value). The two enum
|
||||
// members serialize as numbers (the role writer boxes them
|
||||
// System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here.
|
||||
public string? Permission { get; set; }
|
||||
public int? Role { get; set; }
|
||||
public bool? Override { get; set; }
|
||||
public int? Type { get; set; }
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PublishSubRoomSaveRequest
|
||||
{
|
||||
public long? SubRoomDataSaveId { get; set; }
|
||||
@@ -3746,6 +3764,7 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public Task<IActionResult> SubRoomMaxPlayers(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
@@ -3763,6 +3782,7 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public Task<IActionResult> SubRoomAccessibility(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
@@ -3773,45 +3793,120 @@ public class RoomsController(
|
||||
s.CanMatchmakeInto = a != AccessibilityPrivate;
|
||||
});
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the
|
||||
/// role-permission matrix. Client:
|
||||
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — "change
|
||||
/// subroom permissions", the role-permission matrix. Client:
|
||||
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList<BNLPBMJJOMM>)</c>
|
||||
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT
|
||||
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c>
|
||||
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string
|
||||
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw
|
||||
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON
|
||||
/// deserialize into the List).
|
||||
/// (NLDBPDCNNCF.txt:9198, verb 3, same typed helper as every other room
|
||||
/// mutation, so the response is the full <c>FGCPNAACHIK</c> details).
|
||||
/// Unlike its siblings the body is NOT form-encoded: the closure calls
|
||||
/// <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c> (CCNOMNNHNNG.txt ISIL 009), a raw
|
||||
/// JSON array of cells. The editor sends one changed cell per call
|
||||
/// (OMJKHJLFOCO.AFHJLBBFAGP.<LocalRequestModifyRolePermission>g__ChangeAndRefresh
|
||||
/// builds a one-element BNLPBMJJOMM[]), so each cell is upserted; cells not
|
||||
/// sent are left alone.
|
||||
///
|
||||
/// <para><b>Scope note — deliberately not persisted.</b> DorkNet has no
|
||||
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no
|
||||
/// column for it and there is no permissions table. Adding one means an
|
||||
/// entity + DbContext + migration, which is outside this file. Until then
|
||||
/// the matrix is accepted, logged, and the room details echoed back so the
|
||||
/// client's promise resolves instead of 415-ing; the values do not survive
|
||||
/// a round-trip. The old handler silently bound an unrelated DTO whose keys
|
||||
/// the client never sends, which was strictly worse.</para></summary>
|
||||
/// <para>Read back through <c>GET photon_access_token</c> (its
|
||||
/// <c>Permissions</c> list), and pushed to everyone in that sub-room as
|
||||
/// the <c>PhotonAccessToken</c> hub message so the change takes effect
|
||||
/// without a rejoin.</para>
|
||||
///
|
||||
/// <para>Authorization mirrors the client's own rule
|
||||
/// (OMJKHJLFOCO.DIIMLMKEJLC): the creator may edit every role, including
|
||||
/// Co-Owner and Creator; a co-owner only the roles below their own;
|
||||
/// nobody else may edit (<see cref="RoomRoleRules.CanEditRolePermissions"/>).</para></summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRoomPermissions(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
var roleRows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||
if (!RoomRoleRules.CanEditRoom(room, roleRows, pid)) return Forbid();
|
||||
|
||||
string body;
|
||||
using (var reader = new StreamReader(Request.Body))
|
||||
body = await reader.ReadToEndAsync();
|
||||
var cells = RoomPermissionMatrix.Parse(body);
|
||||
if (cells is null) return BadRequest(new { error = "invalid_permissions_body" });
|
||||
|
||||
var denied = cells.Where(c => !RoomRoleRules.CanEditRolePermissions(room, roleRows, pid, c.Role)).ToList();
|
||||
if (denied.Count > 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[subroom-permissions] denied room={Room} sub={Sub} by={By} roles={Roles}",
|
||||
roomId, subRoomId, pid, string.Join(",", denied.Select(d => d.Role).Distinct()));
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogWarning(
|
||||
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)",
|
||||
roomId, subRoomId, pid, req?.Count ?? 0);
|
||||
var now = DateTime.UtcNow;
|
||||
scene.DataModifiedAt = now;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
var existing = await db.RoomRolePermissions
|
||||
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId)
|
||||
.ToListAsync();
|
||||
foreach (var cell in cells)
|
||||
{
|
||||
// Exact match, like the unique index on
|
||||
// (RoomId, SubRoomId, Role, Permission).
|
||||
var row = existing.FirstOrDefault(p =>
|
||||
p.Role == cell.Role && string.Equals(p.Permission, cell.Permission, StringComparison.Ordinal));
|
||||
if (row is null)
|
||||
{
|
||||
row = new RoomRolePermissionEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
SubRoomId = subRoomId,
|
||||
Role = cell.Role,
|
||||
Permission = cell.Permission,
|
||||
};
|
||||
db.RoomRolePermissions.Add(row);
|
||||
existing.Add(row);
|
||||
}
|
||||
row.Type = cell.Type;
|
||||
row.Override = cell.Override;
|
||||
row.Value = cell.Value;
|
||||
row.UpdatedByPlayerId = pid;
|
||||
row.UpdatedAt = now;
|
||||
}
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
break;
|
||||
}
|
||||
catch (DbUpdateException) when (attempt < 3)
|
||||
{
|
||||
// Another editor inserted the same new cell between our read
|
||||
// and our insert (unique index). Forget our pending rows and
|
||||
// apply the cells again, now as updates of theirs.
|
||||
foreach (var entry in db.ChangeTracker.Entries<RoomRolePermissionEntity>().ToList())
|
||||
entry.State = EntityState.Detached;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}",
|
||||
roomId, subRoomId, pid, cells.Count);
|
||||
|
||||
// Everyone in that sub-room gets the new matrix; the client's
|
||||
// PhotonAccessToken handler re-initialises its permissions from it.
|
||||
var matrix = await RoomPermissionMatrix.LoadAsync(db, roomId, subRoomId);
|
||||
foreach (var (instance, players) in presence.EnumerateActiveInstances(roomId, includePrivate: true))
|
||||
{
|
||||
if (instance.SubRoomId != subRoomId) continue;
|
||||
var tokenBody = RoomPermissionMatrix.AccessTokenBody(instance.RoomInstanceId, matrix);
|
||||
foreach (var other in players)
|
||||
await notifications.NotifyTypedAsync(other, "PhotonAccessToken", tokenBody);
|
||||
}
|
||||
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
@@ -3840,12 +3935,14 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
|
||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
@@ -3919,12 +4016,14 @@ public class RoomsController(
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
var src = await db.RoomScenes
|
||||
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
|
||||
if (src is null) return NotFound();
|
||||
@@ -3971,6 +4070,7 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public Task<IActionResult> ModifySubRoom(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
@@ -4010,11 +4110,13 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||
[Authorize]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> MoveSubRoom(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
@@ -4105,6 +4207,7 @@ public class RoomsController(
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var body = await ReadSaveRoomSceneRequestAsync();
|
||||
body.RoomSceneId = subRoomId;
|
||||
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
|
||||
@@ -4165,10 +4268,11 @@ public class RoomsController(
|
||||
/// successful mutation surface as a client-side parse failure.</summary>
|
||||
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
|
||||
{
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
if (!await CanEditRoomAsync(room, pid)) return Forbid();
|
||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
mutator(scene);
|
||||
@@ -4219,12 +4323,7 @@ public class RoomsController(
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||
var list = new List<object>(rows.Count + 1)
|
||||
{
|
||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 30),
|
||||
};
|
||||
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
|
||||
return Ok(list);
|
||||
return Ok(RoomRoleRules.BuildWires(room, rows));
|
||||
}
|
||||
|
||||
private static string CurrentOrSyntheticDataBlobName(RoomEntity room)
|
||||
@@ -4339,30 +4438,38 @@ public class RoomsController(
|
||||
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
|
||||
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task<EFHPLDPNGIM></c>,
|
||||
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
|
||||
/// above.</summary>
|
||||
/// above. The creator answers as Creator (255).</summary>
|
||||
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId == playerId)
|
||||
return Ok(BuildRoomAccountRoleWire(playerId, 30));
|
||||
var row = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
|
||||
if (row is null) return NotFound();
|
||||
return Ok(BuildRoomRoleGrantWire(row));
|
||||
var rows = await db.RoomRoles
|
||||
.Where(r => r.RoomId == roomId && r.PlayerId == playerId)
|
||||
.ToListAsync();
|
||||
var wire = RoomRoleRules.BuildWires(room, rows)
|
||||
.FirstOrDefault(w => w.AccountId == RoomRoleRules.ToClientAccountId(playerId));
|
||||
if (wire is null) return NotFound();
|
||||
return Ok(wire);
|
||||
}
|
||||
|
||||
public sealed class GrantRoleRequest { public int? Role { get; set; } }
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants
|
||||
/// a role to a player. Auto-accepted (no invite step).
|
||||
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role",
|
||||
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a
|
||||
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full
|
||||
/// <c>FGCPNAACHIK</c> details — which this already does. Only the
|
||||
/// roomserver/ twin was missing.</summary>
|
||||
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — "change room account
|
||||
/// role" (<c>NLDBPDCNNCF.LMIEIJMCEAB</c>, NLDBPDCNNCF.txt:7516, verb 3 =
|
||||
/// PUT, single form key <c>role</c> boxed as Int32 —
|
||||
/// NLDBPDCNNCF_NestedType_BFKNABDHMGG.txt ISIL 015-024), response the full
|
||||
/// <c>FGCPNAACHIK</c> details. Three callers:
|
||||
/// <list type="bullet">
|
||||
/// <item>the owner's role button (AddRemovePlayerRoomRoleConditionalButton
|
||||
/// .EEGJBELCBDI): Host/Moderator are set directly, and "Remove" sends
|
||||
/// role 0;</item>
|
||||
/// <item>the invitee accepting a co-owner invite (message Type 62,
|
||||
/// KPFKPIKDBFJ.ELKAEBMMIGL / KLNLKDJJDLE Accept): their OWN account id
|
||||
/// with role 30;</item>
|
||||
/// <item>a co-owner rejecting a direct add (Type 60, COJCABEJDFN Reject):
|
||||
/// their own id with role 0.</item>
|
||||
/// </list>
|
||||
/// Authorization is <see cref="RoomRoleRules.CheckChange"/>.</summary>
|
||||
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
@@ -4372,45 +4479,20 @@ public class RoomsController(
|
||||
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GrantRole(long roomId, long playerId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
|
||||
var role = await ReadClientRoomRoleAsync();
|
||||
if (role is null) return BadRequest(new { error = "missing_role" });
|
||||
if (role.Value == 0)
|
||||
return await ClearRoomAccountRoleAsync(room, playerId);
|
||||
[RecNetEnvelope]
|
||||
public Task<IActionResult> GrantRole(long roomId, long playerId)
|
||||
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
|
||||
|
||||
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
|
||||
return BadRequest(new { error = "invalid_role", role });
|
||||
|
||||
var existing = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
|
||||
if (existing is null)
|
||||
{
|
||||
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = roomId, PlayerId = playerId, Role = roomRole,
|
||||
Accepted = true, GrantedByPlayerId = me,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Accepted = true;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
|
||||
/// as grant but Accepted=false; the target's accept-invite flow
|
||||
/// flips the flag (separate endpoint, not yet exposed). For now
|
||||
/// invited rows surface in RoomDetails.InvitedCoOwners etc.
|
||||
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
|
||||
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
|
||||
/// <summary>PUT <c>rooms/{id}/roles/{playerId}/invite</c> — "invite account
|
||||
/// to role" (<c>NLDBPDCNNCF.KIJDCLINOIC</c>, NLDBPDCNNCF.txt:7686, verb 3,
|
||||
/// form key <c>role</c> as Int32 — NLDBPDCNNCF_NestedType_ENHMMCAOING.txt).
|
||||
/// Only Co-Owner goes through an invite in this client: the owner's button
|
||||
/// sends role 30 here, the invitee is told with a Type 62
|
||||
/// (RoomCoOwnerInvited) message carrying RoomId, and either accepts with
|
||||
/// <c>PUT roles/{me}</c> role 30 or rejects by sending role 0 back to THIS
|
||||
/// route on their own id (KPFKPIKDBFJ.OGONAKHNMGN, KLNLKDJJDLE Reject),
|
||||
/// which clears the pending invite but leaves any role they already
|
||||
/// hold.</summary>
|
||||
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
@@ -4420,62 +4502,168 @@ public class RoomsController(
|
||||
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> InviteRole(long roomId, long playerId)
|
||||
[RecNetEnvelope]
|
||||
public Task<IActionResult> InviteRole(long roomId, long playerId)
|
||||
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
|
||||
|
||||
private async Task<IActionResult> ChangeRoomAccountRoleAsync(long roomId, long playerId, bool isInvite)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
|
||||
var role = await ReadClientRoomRoleAsync();
|
||||
if (role is null) return BadRequest(new { error = "missing_role" });
|
||||
if (role.Value == 0)
|
||||
return await ClearRoomAccountRoleAsync(room, playerId);
|
||||
var requested = await ReadClientRoomRoleAsync();
|
||||
if (requested is null) return BadRequest(new { error = "missing_role" });
|
||||
if (playerId <= 0 || !await db.Players.AnyAsync(p => p.Id == playerId))
|
||||
return NotFound(new { error = "unknown_player" });
|
||||
|
||||
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
|
||||
return BadRequest(new { error = "invalid_role", role });
|
||||
|
||||
var existing = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
|
||||
if (existing is null)
|
||||
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||
var error = RoomRoleRules.CheckChange(
|
||||
room, rows, me, playerId, requested.Value, isInvite, out var kind);
|
||||
if (error is not null)
|
||||
{
|
||||
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
logger.LogWarning(
|
||||
"[room-roles] denied room={Room} actor={Actor} target={Target} role={Role} invite={Invite} error={Error}",
|
||||
roomId, me, playerId, requested.Value, isInvite, error);
|
||||
return error == "cannot_change_creator_role" || error == "invalid_role"
|
||||
? BadRequest(new { error, role = requested.Value })
|
||||
: Forbid();
|
||||
}
|
||||
|
||||
var targetRows = rows.Where(r => r.PlayerId == playerId).ToList();
|
||||
var wasCoOwner = targetRows.Any(r => r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||
var hadCoOwnerInvite = targetRows.Any(r => !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||
RoomRoleRules.TryFromWire(requested.Value, out var storedRole);
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case RoomRoleRules.ChangeKind.Clear when isInvite:
|
||||
// Reject / withdraw an invite: only the pending rows go.
|
||||
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted));
|
||||
break;
|
||||
|
||||
case RoomRoleRules.ChangeKind.Clear:
|
||||
db.RoomRoles.RemoveRange(targetRows);
|
||||
break;
|
||||
|
||||
case RoomRoleRules.ChangeKind.Invite:
|
||||
{
|
||||
RoomId = roomId, PlayerId = playerId, Role = roomRole,
|
||||
Accepted = false, GrantedByPlayerId = me,
|
||||
});
|
||||
// Already holding the role: nothing to invite to. Otherwise
|
||||
// replace any other pending invite but keep the role the
|
||||
// player holds until they accept.
|
||||
if (targetRows.Any(r => r.Accepted && r.Role == storedRole)) break;
|
||||
db.RoomRoles.RemoveRange(targetRows.Where(r => !r.Accepted && r.Role != storedRole));
|
||||
var pending = targetRows.FirstOrDefault(r => !r.Accepted && r.Role == storedRole);
|
||||
if (pending is null)
|
||||
{
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = roomId, PlayerId = playerId, Role = storedRole,
|
||||
Accepted = false, GrantedByPlayerId = me, GrantedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
pending.GrantedByPlayerId = me;
|
||||
pending.GrantedAt = DateTime.UtcNow;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case RoomRoleRules.ChangeKind.AcceptInvite:
|
||||
case RoomRoleRules.ChangeKind.Grant:
|
||||
{
|
||||
// One role per account: the new one replaces the rest
|
||||
// (an accepted invite replaces the role held meanwhile).
|
||||
var keep = targetRows.FirstOrDefault(r => r.Role == storedRole);
|
||||
db.RoomRoles.RemoveRange(targetRows.Where(r => !ReferenceEquals(r, keep)));
|
||||
if (keep is null)
|
||||
{
|
||||
db.RoomRoles.Add(new RoomRoleEntity
|
||||
{
|
||||
RoomId = roomId, PlayerId = playerId, Role = storedRole,
|
||||
Accepted = true,
|
||||
GrantedByPlayerId = kind == RoomRoleRules.ChangeKind.AcceptInvite
|
||||
? targetRows.FirstOrDefault(r => !r.Accepted)?.GrantedByPlayerId ?? me
|
||||
: me,
|
||||
GrantedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
keep.Accepted = true;
|
||||
if (kind == RoomRoleRules.ChangeKind.Grant) keep.GrantedByPlayerId = me;
|
||||
keep.GrantedAt = DateTime.UtcNow;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var isCoOwnerNow = await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||
var hasCoOwnerInviteNow = await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == playerId && !r.Accepted && r.Role == RoomRoleRules.StoredCoOwner);
|
||||
logger.LogInformation(
|
||||
"[room-roles] room={Room} actor={Actor} target={Target} role={Role} invite={Invite} kind={Kind} coOwner={Was}->{Now}",
|
||||
roomId, me, playerId, requested.Value, isInvite, kind, wasCoOwner, isCoOwnerNow);
|
||||
|
||||
// The invitee's notifications are how the role reaches them: the
|
||||
// 2023 client renders message Types 60/61/62 (factory
|
||||
// OCGKOJMFLAI.ALEHDHAEFOK, enum KJECOLODAFA.MEPCALGGMJC) and reads
|
||||
// only RoomId from them. Self-service changes (accept / reject /
|
||||
// leave) notify nobody.
|
||||
if (playerId != me)
|
||||
{
|
||||
existing.Accepted = false;
|
||||
if (!hadCoOwnerInvite && hasCoOwnerInviteNow)
|
||||
await systemNotifications.SendAsync(playerId,
|
||||
SystemNotificationService.MessageType.RoomCoOwnerInvited, me, roomId: roomId);
|
||||
if (!wasCoOwner && isCoOwnerNow)
|
||||
await systemNotifications.SendAsync(playerId,
|
||||
SystemNotificationService.MessageType.RoomCoOwnerAdded, me, roomId: roomId);
|
||||
if (wasCoOwner && !isCoOwnerNow)
|
||||
await systemNotifications.SendAsync(playerId,
|
||||
SystemNotificationService.MessageType.RoomCoOwnerRemoved, me, roomId: roomId);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
|
||||
if (hadCoOwnerInvite && !hasCoOwnerInviteNow)
|
||||
await MarkCoOwnerInviteMessagesReadAsync(playerId, roomId);
|
||||
|
||||
var details = await BuildRoomServerDetailsWithRolesAsync(roomId);
|
||||
await PushRoomUpdateAsync(roomId, details, playerId, me);
|
||||
return Ok(details);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
|
||||
/// <summary>Once an invite is answered (or withdrawn) its Type 62 message
|
||||
/// must stop offering Accept/Reject in the inbox.</summary>
|
||||
private async Task MarkCoOwnerInviteMessagesReadAsync(long playerId, long roomId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId)
|
||||
return BadRequest(new { error = "cannot_change_creator_role" });
|
||||
|
||||
var rows = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
|
||||
var stale = await db.Messages
|
||||
.Where(m => m.RecipientPlayerId == playerId
|
||||
&& m.RoomId == roomId
|
||||
&& m.Type == SystemNotificationService.MessageType.RoomCoOwnerInvited
|
||||
&& m.ReadAt == null)
|
||||
.ToListAsync();
|
||||
if (rows.Count > 0)
|
||||
db.RoomRoles.RemoveRange(rows);
|
||||
|
||||
if (stale.Count == 0) return;
|
||||
foreach (var m in stale) m.ReadAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
|
||||
}
|
||||
|
||||
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId)
|
||||
/// <summary>Push the fresh room details as <c>RoomUpdate</c> (handler
|
||||
/// IBEOONPEELF.txt:507, Action<FGCPNAACHIK>) to everyone currently
|
||||
/// in the room plus the players whose view changed, so the role list,
|
||||
/// the role-gated buttons and the local player's own role refresh
|
||||
/// without leaving the room.</summary>
|
||||
private async Task PushRoomUpdateAsync(long roomId, object details, params long[] alsoNotify)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId) return true;
|
||||
return await db.RoomRoles.AnyAsync(r =>
|
||||
r.RoomId == room.Id
|
||||
&& r.PlayerId == playerId
|
||||
&& r.Accepted
|
||||
&& r.Role == 0);
|
||||
var recipients = presence.EnumerateActiveInstances(roomId, includePrivate: true)
|
||||
.SelectMany(i => i.PlayerIds)
|
||||
.Concat(alsoNotify)
|
||||
.Where(pid => pid > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
foreach (var pid in recipients)
|
||||
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, details);
|
||||
}
|
||||
|
||||
private async Task<int?> ReadClientRoomRoleAsync()
|
||||
@@ -4486,6 +4674,8 @@ public class RoomsController(
|
||||
var form = await Request.ReadFormAsync();
|
||||
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
|
||||
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
|
||||
if (TryParseRoleName(form["role"].FirstOrDefault() ?? form["Role"].FirstOrDefault(), out parsed))
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
|
||||
@@ -4495,10 +4685,20 @@ public class RoomsController(
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (body?.Role is int bodyRole) return bodyRole;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!prop.Name.Equals("role", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out parsed))
|
||||
return parsed;
|
||||
if (prop.Value.ValueKind == JsonValueKind.String
|
||||
&& (int.TryParse(prop.Value.GetString(), out parsed)
|
||||
|| TryParseRoleName(prop.Value.GetString(), out parsed)))
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -4509,64 +4709,34 @@ public class RoomsController(
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole)
|
||||
/// <summary>OMMBGJMJJPN member names, for callers that box the enum
|
||||
/// itself (Enum.ToString) instead of an Int32.</summary>
|
||||
internal static bool TryParseRoleName(string? raw, out int role)
|
||||
{
|
||||
var rows = await db.RoomRoles
|
||||
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
|
||||
.ToListAsync();
|
||||
if (rows.Count > 0)
|
||||
db.RoomRoles.RemoveRange(rows);
|
||||
}
|
||||
|
||||
private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
|
||||
{
|
||||
roomRole = clientRole switch
|
||||
role = (raw ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
30 => 0, // CoOwner
|
||||
20 => 1, // Moderator
|
||||
10 => 2, // Host
|
||||
"none" => RoomRoleRules.WireNone,
|
||||
"banned" => RoomRoleRules.WireBanned,
|
||||
"host" => RoomRoleRules.WireHost,
|
||||
"moderator" => RoomRoleRules.WireModerator,
|
||||
"coowner" => RoomRoleRules.WireCoOwner,
|
||||
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
|
||||
"creator" => RoomRoleRules.WireCreator,
|
||||
_ => -1,
|
||||
};
|
||||
return roomRole >= 0;
|
||||
return role >= 0;
|
||||
}
|
||||
|
||||
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
|
||||
=> BuildRoomAccountRoleWire(
|
||||
role.PlayerId,
|
||||
ToClientRoomRole(role.Role),
|
||||
role.Accepted,
|
||||
role.GrantedByPlayerId);
|
||||
|
||||
private static object BuildRoomAccountRoleWire(
|
||||
long accountId,
|
||||
int role,
|
||||
bool accepted = true,
|
||||
long? lastChangedByAccountId = null)
|
||||
=> new
|
||||
{
|
||||
AccountId = ToClientAccountId(accountId),
|
||||
Role = accepted ? role : 0,
|
||||
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
|
||||
InvitedRole = accepted ? 0 : role,
|
||||
};
|
||||
|
||||
private static int ToClientRoomRole(int roomRole) => roomRole switch
|
||||
/// <summary>Creator or accepted co-owner (see
|
||||
/// <see cref="RoomRoleRules.CanEditRoom"/>).</summary>
|
||||
private async Task<bool> CanEditRoomAsync(RoomEntity room, long playerId)
|
||||
{
|
||||
0 => 30, // CoOwner
|
||||
1 => 20, // Moderator
|
||||
2 => 10, // Host
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static int ToClientAccountId(long accountId)
|
||||
=> accountId > int.MaxValue
|
||||
? int.MaxValue
|
||||
: accountId < int.MinValue
|
||||
? int.MinValue
|
||||
: (int)accountId;
|
||||
|
||||
private static int? ToClientAccountId(long? accountId)
|
||||
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
|
||||
if (room.CreatorPlayerId == playerId) return true;
|
||||
var rows = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
|
||||
.ToListAsync();
|
||||
return RoomRoleRules.CanEditRoom(room, rows, playerId);
|
||||
}
|
||||
|
||||
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
|
||||
{
|
||||
|
||||
@@ -501,6 +501,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/description")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/description")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareDescription(long roomId)
|
||||
{
|
||||
// RawValue, not the whitespace-skipping reader: clearing the
|
||||
@@ -520,6 +521,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/image")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/image")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareImage(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -541,6 +543,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareTags(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -567,6 +570,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareAccessibility(long roomId)
|
||||
{
|
||||
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
|
||||
@@ -899,6 +903,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareCloning(long roomId)
|
||||
{
|
||||
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
|
||||
@@ -918,6 +923,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareAutomute(long roomId)
|
||||
{
|
||||
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
|
||||
@@ -939,6 +945,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareRestrictions(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -965,6 +972,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/warning")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareWarning(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -983,6 +991,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareCreator(long roomId)
|
||||
{
|
||||
var creatorId = await ReadBareLongAsync(
|
||||
@@ -1001,6 +1010,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareAllowNewUsers(long roomId)
|
||||
{
|
||||
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
|
||||
@@ -1015,6 +1025,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareMinLevel(long roomId)
|
||||
{
|
||||
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
|
||||
@@ -1029,6 +1040,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
|
||||
{
|
||||
var value = await ReadBareIntAsync(
|
||||
@@ -1054,6 +1066,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareLoadScreen(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -1085,6 +1098,7 @@ public class RoomsModerationController(
|
||||
[HttpDelete("rooms/{roomId:long}/loadscreen")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareLoadScreenRemove(long roomId)
|
||||
{
|
||||
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
|
||||
@@ -1121,6 +1135,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
|
||||
{
|
||||
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
||||
@@ -1137,6 +1152,7 @@ public class RoomsModerationController(
|
||||
[HttpDelete("rooms/{roomId:long}/promo_images")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
|
||||
{
|
||||
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
||||
@@ -1167,6 +1183,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -1197,6 +1214,7 @@ public class RoomsModerationController(
|
||||
[HttpDelete("rooms/{roomId:long}/promo_external")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -1275,6 +1293,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareAck(long roomId) =>
|
||||
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
|
||||
|
||||
@@ -1305,6 +1324,7 @@ public class RoomsModerationController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
[RecNetEnvelope]
|
||||
public async Task<IActionResult> BareModify(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
@@ -1375,13 +1395,13 @@ public class RoomsModerationController(
|
||||
{
|
||||
var newName = await ReadCloneNameAsync();
|
||||
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (source is null) return NotFound();
|
||||
if (source is null) return CloneResult(null, "That room doesn't exist.");
|
||||
// RRO/AG rooms are first-party templates meant to be cloned — the
|
||||
// room-creation "base room" picker starts a new build by cloning one
|
||||
// (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false,
|
||||
// so without the IsAGRoom exception every base-room create 403s.
|
||||
// so without the IsAGRoom exception every base-room create is refused.
|
||||
if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me)
|
||||
return Forbid();
|
||||
return CloneResult(null, "You can't clone this room!");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
newName = $"{source.Name}Copy";
|
||||
@@ -1432,6 +1452,13 @@ public class RoomsModerationController(
|
||||
// it editable. Safe for admin: the purge gate and "RR Original"
|
||||
// badge both key on CreatorPlayerId == SystemAccountId, so a
|
||||
// user-owned clone is still shown as Custom and stays purgeable.
|
||||
//
|
||||
// The WIRE IsRRO is a different thing: it is false for every
|
||||
// player-owned room (RoomService.WireIsRRO). The 2023 client's
|
||||
// room-load pipeline never reads IsRRO (the baked scene comes
|
||||
// from the sub-room's UnitySceneId, the blob step from DataBlob),
|
||||
// while its owner UI refuses publish and every room setting on
|
||||
// an RRO, which is what kept cloned rooms from being published.
|
||||
IsAGRoom = source.IsAGRoom,
|
||||
IsDormRoom = false,
|
||||
CloningAllowed = source.CloningAllowed,
|
||||
@@ -1509,6 +1536,18 @@ public class RoomsModerationController(
|
||||
CanMatchmakeInto = s.CanMatchmakeInto,
|
||||
DataModifiedAt = DateTime.UtcNow,
|
||||
}).ToList();
|
||||
// A template with no scene rows still has to give the new room a
|
||||
// real entry sub-room (named "Home", from the clone's own location,
|
||||
// blob and capacity). Without it the details served a synthesised
|
||||
// placeholder and Room Settings > Subrooms showed an empty row.
|
||||
if (clonedScenes.Count == 0)
|
||||
{
|
||||
var home = Services.RoomService.NewHomeScene(clone);
|
||||
home.Id = ++nextSceneId;
|
||||
clonedScenes.Add(home);
|
||||
}
|
||||
foreach (var scene in clonedScenes.Where(s => string.IsNullOrWhiteSpace(s.Name)))
|
||||
scene.Name = Services.RoomService.DefaultSubRoomName(scene.OrderIndex);
|
||||
if (clonedScenes.Count > 0)
|
||||
{
|
||||
db.RoomScenes.AddRange(clonedScenes);
|
||||
@@ -1517,17 +1556,22 @@ public class RoomsModerationController(
|
||||
|
||||
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
|
||||
|
||||
// The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN)
|
||||
// deserializes the response as the FULL room-details object
|
||||
// (FGCPNAACHIK — the same type get-by-id / rename return on the
|
||||
// roomserver host), NOT the slim list-room shape. This is the
|
||||
// roomserver-family path (rooms/{id}/clone), so use
|
||||
// BuildRoomServerDetails — the exact shape GET rooms/{id} returns for
|
||||
// the 2023 client. Returning ToWireRoom (or the wrong details variant)
|
||||
// made the strict reader fail → "Failed to copy room: Failed to clone
|
||||
// room".
|
||||
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
|
||||
// The value is the FULL room-details object (FGCPNAACHIK), the exact
|
||||
// shape GET rooms/{id} returns for the 2023 client — not the slim
|
||||
// list-room shape, which made the strict reader fail.
|
||||
return CloneResult(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
|
||||
}
|
||||
/// <summary>Reply for <c>rooms/{id}/clone</c>. The 2023 client sends the
|
||||
/// clone through the same RecNet rooms helper as "save room" and "restore
|
||||
/// subroom save" (NLDBPDCNNCF.BDBHCEGKGDC with an op name), which reads the
|
||||
/// legacy <c>{success, value, error}</c> envelope. A bare room-details body
|
||||
/// has no <c>success</c>, so the client rejects it with an empty error:
|
||||
/// "Failed to copy room: Exception of type 'CEMNLBKJABA' was thrown", even
|
||||
/// though the room was created. Refusals are 200 with success=false and a
|
||||
/// message the client shows.</summary>
|
||||
private OkObjectResult CloneResult(object? room, string error = "") =>
|
||||
Ok(new { success = error.Length == 0, value = room, error });
|
||||
|
||||
/// <summary>Give a freshly-cloned room its OWN copy of the source's save
|
||||
/// data instead of pointing its scenes at the source's blob.
|
||||
///
|
||||
@@ -1575,8 +1619,11 @@ public class RoomsModerationController(
|
||||
// The scene was created as a shallow copy, so its DataBlobName is
|
||||
// currently the SOURCE's. Either a copy replaces it or it gets
|
||||
// cleared — it must never survive as-is.
|
||||
// A synthesised Home scene (template had no rows) has no source
|
||||
// scene; it starts from the room-level blob it was built with.
|
||||
var sourceBlob = i < sourceScenes.Count ? sourceScenes[i].DataBlobName : target.DataBlobName;
|
||||
var copied = await CopyBlobForCloneAsync(
|
||||
sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId);
|
||||
sourceBlob, clone.Id, target.OrderIndex, callerId);
|
||||
|
||||
target.DataBlobName = copied ?? string.Empty;
|
||||
target.DataModifiedAt = DateTime.UtcNow;
|
||||
@@ -1887,7 +1934,13 @@ public class RoomsModerationController(
|
||||
/// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no
|
||||
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
|
||||
/// reader's dispose-walk NRE'd on the missing nested lists and every
|
||||
/// mutation reported failure even though it had persisted.</summary>
|
||||
/// mutation reported failure even though it had persisted.
|
||||
///
|
||||
/// <para>That helper is <c>NLDBPDCNNCF.BDBHCEGKGDC<TResult></c>, the
|
||||
/// <c>{success, value, error}</c> envelope reader, so the details are the
|
||||
/// envelope's <c>value</c>: every caller carries
|
||||
/// <see cref="Controllers.RecNetEnvelopeAttribute"/>. A bare details body
|
||||
/// fails with "Exception of type 'CEMNLBKJABA' was thrown."</para></summary>
|
||||
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
|
||||
{
|
||||
var scenes = await db.RoomScenes
|
||||
|
||||
@@ -156,9 +156,19 @@ public class StorefrontsBuyController(
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push the buyer's balance update so their wallet UI refreshes.
|
||||
// StorefrontBalancePurchase "62" is PurchaseBalanceModificationDTO
|
||||
// {BalanceAddType, Delta, Balance, Platform, CurrencyType}; Balance
|
||||
// is the absolute new balance, Delta only feeds the client's log.
|
||||
await notifications.NotifyAsync(pid,
|
||||
PushNotificationId.StorefrontBalancePurchase,
|
||||
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug });
|
||||
new
|
||||
{
|
||||
BalanceAddType = 0,
|
||||
Delta = (int)-item.Price,
|
||||
Balance = newBalance,
|
||||
Platform = 0,
|
||||
req.CurrencyType,
|
||||
});
|
||||
|
||||
if (isGift)
|
||||
{
|
||||
@@ -166,24 +176,13 @@ public class StorefrontsBuyController(
|
||||
// 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.
|
||||
// GiftPackageReceived "30" carries the client's full gift package
|
||||
// (FHMABOHAEED) — the same object GET api/avatar/v2/gifts lists.
|
||||
// A hand-picked subset left out PlatformsToSpawnOn (-1 = every
|
||||
// platform), so the box could count as spawning nowhere.
|
||||
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,
|
||||
EquipmentPrefabName = gift.EquipmentPrefabName,
|
||||
EquipmentModificationGuid = gift.EquipmentModificationGuid,
|
||||
Message = gift.Message ?? string.Empty,
|
||||
});
|
||||
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
|
||||
}
|
||||
|
||||
// For gift purchases the buyer doesn't get a box — their flow
|
||||
|
||||
@@ -154,7 +154,7 @@ public class AccountsController(
|
||||
playerId, username, summary);
|
||||
}
|
||||
|
||||
await notifications.KickPlayerAsync(playerId, "This account has been deleted.");
|
||||
await notifications.LogoutAsync(playerId, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||
presence.Clear(playerId);
|
||||
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace DorkNet.Server.Controllers.Admin;
|
||||
public class AdminController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
LevelService level,
|
||||
CommunityBoardService communityBoard,
|
||||
RoomBlobNormalizerService roomBlobNormalizer,
|
||||
@@ -160,7 +161,9 @@ public class AdminController(
|
||||
await LogAsync("ban_player", "player", id, body.Reason ?? "");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned.");
|
||||
// A ban must sign the player out (a kick only sends them to the dorm);
|
||||
// the next login is refused by the ban check.
|
||||
await notifications.LogoutAsync(id, NotificationService.LogoutReason.Banned);
|
||||
playerPresence.Clear(id);
|
||||
|
||||
return Ok(new { player.Id, player.BannedUntil });
|
||||
@@ -243,7 +246,7 @@ public class AdminController(
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
|
||||
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||
playerPresence.Clear(id);
|
||||
|
||||
return Ok(new
|
||||
@@ -1570,10 +1573,13 @@ public class AdminController(
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
/// <summary>Push fresh RoomDetails after role/owner mutations. The
|
||||
/// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts,
|
||||
/// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom
|
||||
/// stores a replacement.</summary>
|
||||
/// <summary>Push fresh room details after role/owner mutations, to everyone
|
||||
/// in the room plus the player whose role changed. The client caches the
|
||||
/// room's Roles and role-gated UI reads that cache. The 2023 "RoomUpdate"
|
||||
/// handler (IBEOONPEELF.txt:507) reads the FULL room (FGCPNAACHIK) — the
|
||||
/// shape GET roomserver/rooms/{id} returns. The 2020 {Room, Scenes}
|
||||
/// wrapper parsed as RoomId 0 with no roles, so role changes never
|
||||
/// reached anyone until they re-entered the room.</summary>
|
||||
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
@@ -1587,7 +1593,7 @@ public class AdminController(
|
||||
.ToListAsync();
|
||||
|
||||
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
|
||||
.BuildRoomDetails(room, scenes, roles: roles);
|
||||
.BuildRoomServerDetails(room, scenes, roles: roles);
|
||||
|
||||
var recipients = onlinePresence.OnlinePlayerIds()
|
||||
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
|
||||
@@ -1739,7 +1745,7 @@ public class AdminController(
|
||||
{
|
||||
var r = playerPresence.GetRoom(pid);
|
||||
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
|
||||
await notifications.KickPlayerAsync(pid, reason);
|
||||
await notifications.KickPlayerAsync(pid, reason, gameSessionId: instanceId);
|
||||
playerPresence.Clear(pid);
|
||||
kicked.Add(pid);
|
||||
}
|
||||
@@ -2396,7 +2402,9 @@ public class AdminController(
|
||||
var reason = string.IsNullOrWhiteSpace(body?.Reason)
|
||||
? "Kicked by an admin."
|
||||
: body!.Reason!.Trim();
|
||||
await notifications.KickPlayerAsync(id, reason);
|
||||
// The room instance lets the client announce the kick to that room.
|
||||
await notifications.KickPlayerAsync(id, reason,
|
||||
gameSessionId: playerPresence.GetRoom(id)?.RoomInstanceId ?? 0);
|
||||
playerPresence.Clear(id);
|
||||
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
|
||||
await db.SaveChangesAsync();
|
||||
@@ -3328,10 +3336,11 @@ public class AdminController(
|
||||
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" });
|
||||
// Log out any session still on the old password; the client has to
|
||||
// sign in again with the new one. The reason must be the numeric
|
||||
// enum: the string "AdminPasswordReset" failed the client's parse and
|
||||
// the push was dropped.
|
||||
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||
return Ok(new { id });
|
||||
}
|
||||
|
||||
@@ -3500,22 +3509,26 @@ public class AdminController(
|
||||
|
||||
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.
|
||||
// The move itself comes from the presence set above: the client's
|
||||
// next heartbeat sees it is out of sync and joins that instance.
|
||||
// RoomInstanceUpdate only refreshes the client's cached copy of the
|
||||
// instance, and RoomUpdate its cached room details, which must be
|
||||
// the full room (FGCPNAACHIK) the roomserver GET returns.
|
||||
await notifications.NotifyAsync(id,
|
||||
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
|
||||
var forceJoinScenes = await db.RoomScenes
|
||||
.Where(sc => sc.RoomId == room.Id)
|
||||
.OrderBy(sc => sc.OrderIndex)
|
||||
.ToListAsync();
|
||||
// With every role grant: the client replaces its cached room with
|
||||
// this, so a payload without them would drop everyone's roles.
|
||||
var forceJoinRoles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id)
|
||||
.ToListAsync();
|
||||
await notifications.NotifyAsync(id,
|
||||
PushNotificationId.SubscriptionUpdateRoom,
|
||||
new
|
||||
{
|
||||
Room = new { Id = room.Id, Name = room.Name },
|
||||
RoomId = room.Id,
|
||||
SubRoomId = subRoomId,
|
||||
Reason = "AdminForceJoin",
|
||||
});
|
||||
DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(
|
||||
room, forceJoinScenes, roles: forceJoinRoles));
|
||||
|
||||
await LogAsync("force_join", "player", id,
|
||||
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
|
||||
@@ -3532,20 +3545,46 @@ public class AdminController(
|
||||
|
||||
// ── System ───────────────────────────────────────────────────────────
|
||||
|
||||
public sealed record SetMotdRequest(string Message);
|
||||
public sealed record SetMotdRequest(string? Message, int? StartsInMinutes);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
/// <summary>Broadcast to every connected player.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Message</c>: a coach message (MessageReceived, Type 100, from
|
||||
/// the system account) in each online player's notifications —
|
||||
/// the client's channel for free text from the server.</item>
|
||||
/// <item><c>StartsInMinutes</c>: the client's own maintenance countdown
|
||||
/// (ServerMaintenance "25", which reads only
|
||||
/// <c>StartsInMinutes</c>; 0 cancels a running countdown).</item>
|
||||
/// </list>
|
||||
/// ServerMaintenance used to carry the text as <c>{Message}</c>, which the
|
||||
/// client never reads: the text was never shown, and StartsInMinutes 0
|
||||
/// cancelled any countdown instead.</summary>
|
||||
[HttpPost("broadcast")]
|
||||
public async Task<ActionResult> 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);
|
||||
var message = body.Message?.Trim() ?? string.Empty;
|
||||
if (message.Length == 0 && body.StartsInMinutes is null)
|
||||
return BadRequest("empty message");
|
||||
if (body.StartsInMinutes is < 0)
|
||||
return BadRequest("StartsInMinutes must be 0 or more");
|
||||
|
||||
var recipients = onlinePresence.OnlinePlayerIds().ToList();
|
||||
if (message.Length > 0)
|
||||
{
|
||||
foreach (var pid in recipients)
|
||||
await systemNotifications.SendAsync(pid,
|
||||
SystemNotificationService.MessageType.CoachMessage,
|
||||
fromPlayerId: PlayerService.SystemAccountId,
|
||||
data: message);
|
||||
}
|
||||
if (body.StartsInMinutes is int minutes)
|
||||
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
|
||||
new { StartsInMinutes = minutes });
|
||||
|
||||
await LogAsync("broadcast", "system", 0,
|
||||
body.StartsInMinutes is int m ? $"[maintenance in {m} min] {message}" : message);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
return Ok(new { recipients = recipients.Count });
|
||||
}
|
||||
|
||||
// ── Store catalog management ─────────────────────────────────────────
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
@@ -41,6 +42,27 @@ public class AdminLoginController(
|
||||
if (!player.IsAdmin)
|
||||
return StatusCode(403, new { error = "not_admin" });
|
||||
|
||||
return TokenResponse(player);
|
||||
}
|
||||
|
||||
/// <summary>GET api/admin/v1/sso — exchange a single sign-on session
|
||||
/// (<see cref="ExternalSignIn.WebScheme"/>, signed in on this admin host via
|
||||
/// the external provider) for the same token pair <c>login</c> returns.
|
||||
/// Admin rights still come from the account's <c>IsAdmin</c>.</summary>
|
||||
[HttpGet("sso")]
|
||||
public async Task<ActionResult> Sso()
|
||||
{
|
||||
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
|
||||
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
|
||||
return Unauthorized(new { error = "not_signed_in" });
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null) return Unauthorized(new { error = "not_signed_in" });
|
||||
if (!player.IsAdmin) return StatusCode(403, new { error = "not_admin" });
|
||||
return TokenResponse(player);
|
||||
}
|
||||
|
||||
private OkObjectResult TokenResponse(Data.Entities.PlayerEntity player)
|
||||
{
|
||||
var (access, refresh) = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new
|
||||
{
|
||||
|
||||
@@ -460,6 +460,20 @@ public class ChatController(
|
||||
var removed = await db.ChatThreadMembers
|
||||
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
|
||||
.ExecuteDeleteAsync();
|
||||
if (removed > 0)
|
||||
{
|
||||
// "PlayerLeftChat" is an Action<ChatMessage> (DLDKCILCKNA.txt:721);
|
||||
// the handler only reads ChatThreadId and SenderPlayerId (the
|
||||
// player who left) and drops them from that thread's members.
|
||||
// Sent to the remaining members, whose member lists otherwise
|
||||
// stayed stale, and to the leaver's own other sessions.
|
||||
var thread = await EnsureThreadRowAsync(chatThreadId);
|
||||
var left = new ChatMessageEntity { ThreadKey = chatThreadId, SenderPlayerId = Me, Body = string.Empty };
|
||||
var recipients = await ThreadRecipientsAsync(chatThreadId);
|
||||
recipients.Add(Me);
|
||||
foreach (var rid in recipients.Distinct())
|
||||
await notifications.NotifyTypedAsync(rid, "PlayerLeftChat", ToWireMessage(left, thread.Id));
|
||||
}
|
||||
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ public class GoToController(
|
||||
RoomService rooms,
|
||||
DorkNet.Server.Data.DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
ServerSettingsService serverSettings,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
@@ -316,6 +317,10 @@ public class GoToController(
|
||||
[FromForm(Name = "JoinMode")] int joinMode = 0,
|
||||
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
|
||||
{
|
||||
// The client sends the wire SubRoomId it read from the room details
|
||||
// (OrderIndex + 1, see RoomSceneIds); everything below works in
|
||||
// OrderIndex.
|
||||
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId);
|
||||
var room = await rooms.GetByIdAsync(roomId);
|
||||
if (room is null)
|
||||
{
|
||||
@@ -877,11 +882,15 @@ public class GoToController(
|
||||
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);
|
||||
// Tell the OWNER someone tried to follow them in, as the
|
||||
// client's own "wants to join" message (RequestGameInvite,
|
||||
// from the caller), so they can send an invite. At most once
|
||||
// per requester, owner and instance per cooldown, so retrying
|
||||
// doesn't fill the owner's inbox.
|
||||
if (callerId is long requester
|
||||
&& JoinRequestCooldown.TryStart(requester, playerId, theirRoom.RoomInstanceId))
|
||||
await systemNotifications.SendAsync(playerId,
|
||||
SystemNotificationService.MessageType.RequestGameInvite, fromPlayerId: requester);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 26, // RoomInstanceIsPrivate
|
||||
@@ -1547,9 +1556,25 @@ public class RoomInstanceDto
|
||||
[JsonPropertyName("roomId")]
|
||||
public long RoomId { get; set; }
|
||||
|
||||
[JsonPropertyName("subRoomId")]
|
||||
/// <summary>The sub-room's <see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>
|
||||
/// (0 = the room's first sub-room). Server code reads and writes this;
|
||||
/// the wire carries <see cref="WireSubRoomId"/>.</summary>
|
||||
[JsonIgnore]
|
||||
public long SubRoomId { get; set; }
|
||||
|
||||
/// <summary>The 2023 client's SubRoomId: OrderIndex + 1 (see
|
||||
/// <see cref="Services.RoomSceneIds"/>). It must equal the
|
||||
/// <c>SubRooms[].SubRoomId</c> the room details advertise — the client
|
||||
/// compares the two (<c>SubroomModel.get_SubroomIsLocal</c>) and looks the
|
||||
/// current sub-room up by it. Presence round-trips through this property
|
||||
/// too, so stored JSON stays consistent.</summary>
|
||||
[JsonPropertyName("subRoomId")]
|
||||
public long WireSubRoomId
|
||||
{
|
||||
get => Services.RoomSceneIds.ToWire(SubRoomId);
|
||||
set => SubRoomId = Services.RoomSceneIds.ToOrderIndex(value);
|
||||
}
|
||||
|
||||
// STRING RoomSceneLocationId GUID — foreign-keys to
|
||||
// AGRoomRuntimeConfig.Locations[].ReplicationId. Resolved via
|
||||
// AGRoomSettings.TryGetRoomSceneLocationById (RVA 0x10F57D0). Default
|
||||
@@ -1668,3 +1693,35 @@ public class RoomInstanceDto
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rate limit for "wants to join" messages: one per (requester,
|
||||
/// owner, room instance) per <see cref="Window"/>. Later retries and other
|
||||
/// instances still go through. Per replica, which is enough to stop a
|
||||
/// retry loop from flooding an inbox.</summary>
|
||||
internal static class JoinRequestCooldown
|
||||
{
|
||||
public static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<(long, long, long), DateTime> Last = new();
|
||||
|
||||
/// <summary>True when no request for this key was sent within the window
|
||||
/// (and records this one); false while it is cooling down.</summary>
|
||||
public static bool TryStart(long requester, long owner, long roomInstanceId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (Last.Count > 10_000)
|
||||
foreach (var (key, at) in Last)
|
||||
if (now - at > Window) Last.TryRemove(key, out _);
|
||||
|
||||
var k = (requester, owner, roomInstanceId);
|
||||
while (true)
|
||||
{
|
||||
if (!Last.TryGetValue(k, out var prev))
|
||||
{
|
||||
if (Last.TryAdd(k, now)) return true;
|
||||
continue;
|
||||
}
|
||||
if (now - prev < Window) return false;
|
||||
if (Last.TryUpdate(k, now, prev)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,66 +140,48 @@ public class MatchController(
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>photon_access_token</c> — read by the room-permissions
|
||||
/// runtime as <c>ALIIIAGAPND {RoomInstanceId, PhotonAccessToken,
|
||||
/// Permissions}</c> (NLDBPDCNNCF.CEOPCANHPPH, verb 0, issued from the
|
||||
/// rooms client, hence the <c>roomserver/</c> twin).
|
||||
/// <c>Permissions</c> is the role-permission matrix of the sub-room the
|
||||
/// player is in: the defaults plus whatever the room's editors saved with
|
||||
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>
|
||||
/// (<see cref="RoomPermissionMatrix"/>). It must not be empty: with no
|
||||
/// Permissions the client falls into its persisted-role migration path,
|
||||
/// which NREs during first spawn.</summary>
|
||||
[HttpGet("/photon_access_token")]
|
||||
public IActionResult PhotonAccessToken()
|
||||
[HttpGet("/roomserver/photon_access_token")]
|
||||
public async Task<IActionResult> PhotonAccessToken()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var appId = config["Photon:AppId"] ?? string.Empty;
|
||||
var voiceAppId = config["Photon:VoiceAppId"] ?? string.Empty;
|
||||
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
|
||||
var currentRoom = presence.GetRoom(pid);
|
||||
var permissions = BuildPhotonPermissionSeed();
|
||||
var permissions = currentRoom is null
|
||||
? RoomPermissionMatrix.Merge(Array.Empty<RoomRolePermissionEntity>())
|
||||
: await RoomPermissionMatrix.LoadAsync(db, currentRoom.RoomId, currentRoom.SubRoomId);
|
||||
logger.LogInformation(
|
||||
"[photon-token] player={PlayerId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
|
||||
pid, currentRoom?.RoomInstanceId ?? 0L, permissions.Length);
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
// 2023 deserializes this endpoint as ALIIIAGAPND before room
|
||||
// permissions initialization. If Permissions is missing/empty,
|
||||
// OMJKHJLFOCO falls into the persisted-role migration path that
|
||||
// currently NREs during first spawn.
|
||||
["RoomInstanceId"] = currentRoom?.RoomInstanceId ?? 0L,
|
||||
["PhotonAccessToken"] = string.Empty,
|
||||
["Permissions"] = permissions,
|
||||
"[photon-token] player={PlayerId} room={RoomId} sub={SubRoomId} roomInstance={RoomInstanceId} permissions={PermissionCount}",
|
||||
pid, currentRoom?.RoomId ?? 0L, currentRoom?.SubRoomId ?? 0L, currentRoom?.RoomInstanceId ?? 0L, permissions.Count);
|
||||
|
||||
// Existing custom-auth/app-id shape kept for Photon auth callers.
|
||||
["ResultCode"] = 1,
|
||||
["Message"] = string.Empty,
|
||||
["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["PhotonAppId"] = appId,
|
||||
["PhotonVoiceAppId"] = voiceAppId,
|
||||
["AppId"] = appId,
|
||||
["VoiceAppId"] = voiceAppId,
|
||||
["CloudRegion"] = region,
|
||||
["Region"] = region,
|
||||
["Token"] = string.Empty,
|
||||
});
|
||||
var body = RoomPermissionMatrix.AccessTokenBody(currentRoom?.RoomInstanceId ?? 0L, permissions);
|
||||
// Existing custom-auth/app-id shape kept for Photon auth callers; the
|
||||
// ALIIIAGAPND reader ignores keys it does not register.
|
||||
body["ResultCode"] = 1;
|
||||
body["Message"] = string.Empty;
|
||||
body["UserId"] = pid.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
body["PhotonAppId"] = appId;
|
||||
body["PhotonVoiceAppId"] = voiceAppId;
|
||||
body["AppId"] = appId;
|
||||
body["VoiceAppId"] = voiceAppId;
|
||||
body["CloudRegion"] = region;
|
||||
body["Region"] = region;
|
||||
body["Token"] = string.Empty;
|
||||
return Ok(body);
|
||||
}
|
||||
|
||||
private static object[] BuildPhotonPermissionSeed()
|
||||
=>
|
||||
[
|
||||
BuildPhotonPermission(13, 30), // Can Use Maker Pen
|
||||
BuildPhotonPermission(20, 30), // Can Spawn Inventions
|
||||
BuildPhotonPermission(21, 30), // Can Spawn Consumables
|
||||
BuildPhotonPermission(22, 30), // Can Use Room Reset Button
|
||||
BuildPhotonPermission(23, 30), // Can Use Play Gizmos Toggle
|
||||
BuildPhotonPermission(13, 255), // Creator alias
|
||||
BuildPhotonPermission(20, 255),
|
||||
BuildPhotonPermission(21, 255),
|
||||
BuildPhotonPermission(22, 255),
|
||||
BuildPhotonPermission(23, 255),
|
||||
];
|
||||
|
||||
private static object BuildPhotonPermission(int permission, int role) => new
|
||||
{
|
||||
Permission = permission.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
Role = role,
|
||||
Override = true,
|
||||
Type = 0,
|
||||
Value = "true",
|
||||
};
|
||||
|
||||
[HttpPost("/matchmake/dorm")]
|
||||
public async Task<IActionResult> MatchmakeDorm()
|
||||
{
|
||||
@@ -490,97 +472,125 @@ public class MatchController(
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>match.*/room/{id}/instances</c> — 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
|
||||
/// <c>List<SimpleRoomInstance></c> per
|
||||
/// <c>SimpleRoomInstance.cs</c>: RoomInstanceId/RoomId/SubRoomId,
|
||||
/// IsFull, CreatedAt, PlayerIds, SubroomName, PlayerCount,
|
||||
/// HasModPresent, HashedInstanceId.
|
||||
/// <summary>GET <c>match.*/room/{id}/instances</c> — the room instance
|
||||
/// browser.
|
||||
///
|
||||
/// 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.</summary>
|
||||
/// <para><b>Client contract.</b> <c>RecNet.Matchmaking.KAIPGDGACGG(long)</c>
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:13966, route at :14237, verb 0 =
|
||||
/// GET on the matchmaking host) returns <c>List<PNDCMIMEJLD></c>, a bare
|
||||
/// array. The reader (JPNMBICAMEE.txt) takes exactly RoomInstanceId,
|
||||
/// RoomId, SubRoomId, IsFull, CreatedAt and PlayerIds; the post-parse hook
|
||||
/// (PNDCMIMEJLD.FKDDCNLJOLF) derives PlayerCount from PlayerIds.Count,
|
||||
/// SubroomName from the cached room details' SubRooms by SubRoomId, and
|
||||
/// HasModPresent from the room's Roles. The screen
|
||||
/// (RoomInstanceBrowserScreen / RoomInstancePagedGrid) shows every row it
|
||||
/// is given, current instance first then oldest CreatedAt first, and
|
||||
/// renders the age as UtcNow - CreatedAt, so CreatedAt must be UTC.</para>
|
||||
///
|
||||
/// <para><b>What is listed.</b> Every live instance of the room: each
|
||||
/// distinct Photon room in which at least one player is presenced with a
|
||||
/// live activity marker (<see cref="PlayerPresenceService.EnumerateActiveInstances"/>).
|
||||
/// Private instances are included only for callers allowed into them —
|
||||
/// the room creator, accepted role holders, the instance owner, its
|
||||
/// invitees, and players already inside — so another room's invite-only
|
||||
/// match never leaks. Empty instances are not listed: there is nothing to
|
||||
/// join and they are what made the old list stale.</para>
|
||||
///
|
||||
/// <para><b>Fixed defects.</b> The Redis presence path returned nothing at
|
||||
/// all, so the browser was empty on every multi-replica deployment;
|
||||
/// private instances (every user room is born private and has one
|
||||
/// instance per sub-room) were dropped from presence and re-added only
|
||||
/// for their owner with an empty PlayerIds, so they showed 0 players or
|
||||
/// nothing; expired or crashed presences still counted; IsFull was always
|
||||
/// false; and SubRoomId used the OrderIndex, which does not match the
|
||||
/// room details' wire ids.</para></summary>
|
||||
[HttpGet("/room/{roomId:long}/instances")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> 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<Data.Entities.PrivateInstanceEntity>()
|
||||
: await db.PrivateInstances
|
||||
.Where(p => p.RoomId == roomId && p.OwnerPlayerId == pid)
|
||||
.ToListAsync();
|
||||
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return Ok(Array.Empty<object>());
|
||||
|
||||
// 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<string, SimpleInstance>(StringComparer.Ordinal);
|
||||
foreach (var (room, playerIds) in presenced)
|
||||
var live = presence.EnumerateActiveInstances(roomId, includePrivate: true).ToList();
|
||||
|
||||
var privateIds = live.Where(l => l.Room.IsPrivate).Select(l => l.Room.RoomInstanceId).Distinct().ToList();
|
||||
var privateRows = privateIds.Count == 0
|
||||
? new Dictionary<long, PrivateInstanceEntity>()
|
||||
: await db.PrivateInstances.AsNoTracking()
|
||||
.Where(p => privateIds.Contains(p.Id))
|
||||
.ToDictionaryAsync(p => p.Id);
|
||||
|
||||
var callerHasRole = pid != 0 && (room.CreatorPlayerId == pid
|
||||
|| await db.RoomRoles.AsNoTracking().AnyAsync(r =>
|
||||
r.RoomId == roomId && r.PlayerId == pid && r.Accepted));
|
||||
var invitedTo = pid == 0 || privateIds.Count == 0
|
||||
? new HashSet<long>()
|
||||
: (await db.PrivateInstanceInvitees.AsNoTracking()
|
||||
.Where(i => i.PlayerId == pid && privateIds.Contains(i.PrivateInstanceId))
|
||||
.Select(i => i.PrivateInstanceId)
|
||||
.ToListAsync()).ToHashSet();
|
||||
|
||||
var rows = new List<RoomInstanceRow>();
|
||||
foreach (var (inst, playerIds) in live)
|
||||
{
|
||||
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<int>(),
|
||||
SubroomName: p.Name,
|
||||
PlayerCount: 0,
|
||||
HasModPresent: false,
|
||||
HashedInstanceId: p.PhotonRoomId);
|
||||
privateRows.TryGetValue(inst.RoomInstanceId, out var privateRow);
|
||||
if (inst.IsPrivate && !CanSeePrivateInstance(
|
||||
pid, callerHasRole, playerIds, privateRow, invitedTo.Contains(inst.RoomInstanceId)))
|
||||
continue;
|
||||
|
||||
var capacity = inst.MaxCapacity > 0 ? inst.MaxCapacity : privateRow?.MaxCapacity ?? 0;
|
||||
rows.Add(new RoomInstanceRow(
|
||||
RoomInstanceId: inst.RoomInstanceId,
|
||||
RoomId: inst.RoomId,
|
||||
SubRoomId: inst.SubRoomId,
|
||||
IsFull: capacity > 0 && playerIds.Count >= capacity,
|
||||
CreatedAt: privateRow is not null
|
||||
? DateTime.SpecifyKind(privateRow.CreatedAt, DateTimeKind.Utc)
|
||||
: DateTime.UtcNow,
|
||||
PlayerIds: playerIds.Distinct().OrderBy(p => p).ToList()));
|
||||
}
|
||||
|
||||
// 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(),
|
||||
}));
|
||||
logger.LogInformation(
|
||||
"[room-instances] room={Room} caller={Caller} live={Live} listed={Listed}",
|
||||
roomId, pid, live.Count, rows.Count);
|
||||
return Ok(rows.OrderBy(r => r.CreatedAt).Select(ToWire));
|
||||
}
|
||||
|
||||
private sealed record SimpleInstance(
|
||||
/// <summary>Who may see (and so be offered a join into) a private
|
||||
/// instance in the browser.</summary>
|
||||
public static bool CanSeePrivateInstance(
|
||||
long callerId,
|
||||
bool callerHasRoomRole,
|
||||
IReadOnlyCollection<long> playersInside,
|
||||
PrivateInstanceEntity? privateRow,
|
||||
bool callerInvited)
|
||||
{
|
||||
if (callerId == 0) return false;
|
||||
if (callerHasRoomRole) return true;
|
||||
if (playersInside.Contains(callerId)) return true;
|
||||
if (privateRow is not null && privateRow.OwnerPlayerId == callerId) return true;
|
||||
return callerInvited;
|
||||
}
|
||||
|
||||
internal sealed record RoomInstanceRow(
|
||||
long RoomInstanceId, long RoomId, long SubRoomId, bool IsFull,
|
||||
DateTime CreatedAt, List<int> PlayerIds, string SubroomName,
|
||||
int PlayerCount, bool HasModPresent, string HashedInstanceId);
|
||||
DateTime CreatedAt, List<long> PlayerIds);
|
||||
|
||||
/// <summary>PNDCMIMEJLD wire row. Account ids are Int32 on this DTO;
|
||||
/// SubRoomId is the wire id the room details advertise
|
||||
/// (<see cref="RoomSceneIds"/>), which the client uses to name the row.
|
||||
/// The reader matches PascalCase, camelCase and lowercase keys; camelCase
|
||||
/// is what this route has always sent.</summary>
|
||||
internal static object ToWire(RoomInstanceRow r) => new
|
||||
{
|
||||
roomInstanceId = r.RoomInstanceId,
|
||||
roomId = r.RoomId,
|
||||
subRoomId = RoomSceneIds.ToWire(r.SubRoomId),
|
||||
isFull = r.IsFull,
|
||||
createdAt = r.CreatedAt,
|
||||
playerIds = r.PlayerIds.Select(p => (int)p).ToArray(),
|
||||
};
|
||||
|
||||
/// <summary>DELETE <c>match.*/invite/{id}</c> — Matchmaking.cs
|
||||
/// DeclineRoomInvite. Verified URL at Matchmaking.txt:6505 (method
|
||||
|
||||
@@ -69,17 +69,15 @@ public class MatchPlayerController(
|
||||
if (replaced)
|
||||
{
|
||||
log.LogWarning(
|
||||
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
|
||||
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; logging old session(s) out",
|
||||
logScope, playerId, takeOverExclusiveSession);
|
||||
// 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.");
|
||||
// (negotiate fires AFTER /player/login on the boot
|
||||
// sequence — verified in the live trace), so the Logout
|
||||
// push (Reason SessionTakeover) reaches only the OLDER
|
||||
// session, which shows the client's own "signed in
|
||||
// elsewhere" message and returns to the boot scene.
|
||||
await notifications.LogoutStaleSession(playerId);
|
||||
}
|
||||
}
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace DorkNet.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an action's reply in the legacy <c>{success, value, error}</c>
|
||||
/// envelope the 2023 rooms client reads for every room and playlist mutation.
|
||||
///
|
||||
/// <para><b>Client contract.</b> All of these calls go through one generic
|
||||
/// helper, <c>NLDBPDCNNCF.BDBHCEGKGDC<TResult>(string opName, HTTPMethods,
|
||||
/// string route, …)</c> (RVA 0x35E50A0, recnet-runtime-decomp/NLDBPDCNNCF.cs:2310;
|
||||
/// called as <c>Call 0x1835E50A0</c> 47 times in
|
||||
/// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt). It is the same helper, with the
|
||||
/// same <c>BDBHCEGKGDC<FGCPNAACHIK></c> instantiation token
|
||||
/// <c>[0x1884C1948]</c>, that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
|
||||
/// clone is proven in game to need <c>{success, value, error}</c>
|
||||
/// (docs/recroom-2023-room-save.md). A bare room-details body has no
|
||||
/// <c>success</c>, so the helper rejects it with no message and the player sees
|
||||
/// <c>Exception of type 'CEMNLBKJABA' was thrown.</c> even though the server
|
||||
/// applied the change. The routes are: rooms/{0}/{accessibility,
|
||||
/// allow_new_users, automute, cloning, comments, creator, description, image,
|
||||
/// loadscreen, max_player_calculation_mode, min_level, modify, name,
|
||||
/// promo_external, promo_images, restrictions, tags, voice_chat_encryption,
|
||||
/// warning}, rooms/{0}/roles/{1}[/invite], rooms/{0}/subrooms[/{1}[/accessibility,
|
||||
/// clone, maxplayers, modify, move, name, permissions, publish_save]],
|
||||
/// playlists and playlists/{0}/{accessibility, description, image, levelvoting,
|
||||
/// name, restrictions, rooms/{1}, tags, warning}.</para>
|
||||
///
|
||||
/// <para>A 2xx object result becomes <c>{success:true, value, error:""}</c>.
|
||||
/// Anything else becomes HTTP 200 <c>{success:false, value:null, error}</c>
|
||||
/// with a message the client can show, because a non-2xx reply also surfaces
|
||||
/// only as the message-less CEMNLBKJABA.</para>
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
public sealed class RecNetEnvelopeAttribute : ResultFilterAttribute
|
||||
{
|
||||
public override void OnResultExecuting(ResultExecutingContext context)
|
||||
{
|
||||
context.Result = Wrap(context.Result);
|
||||
base.OnResultExecuting(context);
|
||||
}
|
||||
|
||||
public static IActionResult Wrap(IActionResult result)
|
||||
{
|
||||
switch (result)
|
||||
{
|
||||
case ObjectResult obj when obj.StatusCode is null or (>= 200 and < 300):
|
||||
return IsEnvelope(obj.Value)
|
||||
? result
|
||||
: Success(obj.Value);
|
||||
case ObjectResult obj:
|
||||
return Failure(ErrorText(obj.Value) ?? DefaultError(obj.StatusCode ?? 500));
|
||||
case ForbidResult or ChallengeResult or UnauthorizedResult:
|
||||
return Failure(DefaultError(403));
|
||||
case StatusCodeResult code when code.StatusCode is >= 200 and < 300:
|
||||
return Success(null);
|
||||
case StatusCodeResult code:
|
||||
return Failure(DefaultError(code.StatusCode));
|
||||
case EmptyResult:
|
||||
return Success(null);
|
||||
default:
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public static OkObjectResult Success(object? value) =>
|
||||
new(new { success = true, value, error = string.Empty });
|
||||
|
||||
public static OkObjectResult Failure(string error) =>
|
||||
new(new { success = false, value = (object?)null, error });
|
||||
|
||||
private static string DefaultError(int status) => status switch
|
||||
{
|
||||
400 => "That request was not valid.",
|
||||
401 or 403 => "You don't have permission to do that.",
|
||||
404 => "That room doesn't exist.",
|
||||
409 => "That name is already taken.",
|
||||
_ => "Something went wrong.",
|
||||
};
|
||||
|
||||
/// <summary>An <c>error</c>/<c>Error</c> string on the failure body, if any.</summary>
|
||||
private static string? ErrorText(object? value)
|
||||
{
|
||||
if (value is null) return null;
|
||||
if (value is string s) return string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(value));
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!prop.Name.Equals("error", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (prop.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = prop.Value.GetString();
|
||||
return string.IsNullOrWhiteSpace(text) ? null : text;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsEnvelope(object? value)
|
||||
{
|
||||
if (value is null) return false;
|
||||
var props = value.GetType().GetProperties();
|
||||
bool Has(string name) => props.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
return Has("success") && Has("value") && Has("error");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Site;
|
||||
|
||||
/// <summary>
|
||||
/// The client builds CI publishes, for the site's Downloads page and the
|
||||
/// Quest page's "latest build" install. Files are streamed from the downloads
|
||||
/// bucket (<see cref="DownloadsService"/>) with Range support, so the browser
|
||||
/// installer can read an .xapk's zip directory and entries without fetching
|
||||
/// the whole file. Only files the manifest lists are served.
|
||||
///
|
||||
/// The builds contain Rec Room's own game files, so the routes answer only on
|
||||
/// the site's own host (the apex) and 404 on every other host the server
|
||||
/// accepts: the game's API/CDN subdomains are typically left open for the
|
||||
/// client, while the site sits behind an access proxy (e.g. Cloudflare Access).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("api/site/v1/downloads")]
|
||||
public class DownloadsController(DownloadsService downloads, DomainConfig domain) : ControllerBase
|
||||
{
|
||||
private bool Available => downloads.IsConfigured
|
||||
&& string.Equals(Request.Host.Host, domain.Apex, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>With <see cref="DownloadsService.RequireSignIn"/>, only a
|
||||
/// signed-in, linked player may read the builds.</summary>
|
||||
private async Task<bool> SignedInOrOpenAsync() =>
|
||||
!downloads.RequireSignIn
|
||||
|| (await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme)).Succeeded;
|
||||
|
||||
/// <summary>GET /api/site/v1/downloads — the manifest as published
|
||||
/// (snake_case keys), or 404 when downloads aren't set up or the request
|
||||
/// isn't for the site's host.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetManifest(CancellationToken ct)
|
||||
{
|
||||
if (!Available) return NotFound(new { error = "Downloads aren't set up on this server." });
|
||||
if (!await SignedInOrOpenAsync()) return Unauthorized(new { error = "sign_in_required" });
|
||||
var manifest = await downloads.GetManifestAsync(ct);
|
||||
Response.Headers.CacheControl = "no-cache";
|
||||
return Content((manifest ?? new System.Text.Json.Nodes.JsonObject { ["releases"] = new System.Text.Json.Nodes.JsonArray() })
|
||||
.ToJsonString(), "application/json");
|
||||
}
|
||||
|
||||
/// <summary>GET /api/site/v1/downloads/files/{path} — one listed file,
|
||||
/// streamed; honours Range (206).</summary>
|
||||
[HttpGet("files/{**path}")]
|
||||
public async Task GetFile(string path, CancellationToken ct)
|
||||
{
|
||||
if (!Available) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
||||
if (!await SignedInOrOpenAsync()) { Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
|
||||
var manifest = await downloads.GetManifestAsync(ct);
|
||||
var entry = manifest is null ? null : DownloadsService.FindFile(manifest, path);
|
||||
if (entry is null) { Response.StatusCode = StatusCodes.Status404NotFound; return; }
|
||||
|
||||
using var upstream = await downloads.GetFileAsync(path, Request.Headers.Range.ToString(), ct);
|
||||
Response.StatusCode = (int)upstream.StatusCode;
|
||||
if (!upstream.IsSuccessStatusCode) return;
|
||||
|
||||
var content = upstream.Content.Headers;
|
||||
if (content.ContentLength is long length) Response.ContentLength = length;
|
||||
if (content.ContentRange is not null) Response.Headers.ContentRange = content.ContentRange.ToString();
|
||||
Response.Headers.AcceptRanges = "bytes";
|
||||
if (upstream.Headers.ETag is not null) Response.Headers.ETag = upstream.Headers.ETag.ToString();
|
||||
Response.Headers.CacheControl = "private, no-cache";
|
||||
Response.ContentType = "application/octet-stream";
|
||||
var name = (string?)entry["name"] ?? Path.GetFileName(path);
|
||||
Response.Headers.ContentDisposition = new System.Net.Mime.ContentDisposition { FileName = name }.ToString();
|
||||
|
||||
await using var body = await upstream.Content.ReadAsStreamAsync(ct);
|
||||
await body.CopyToAsync(Response.Body, 1 << 16, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Sign-in with the external OpenID Connect provider (<see cref="ExternalSignInOptions"/>)
|
||||
/// for the website and the admin panel. The game client's login is separate.
|
||||
///
|
||||
/// <para>Flow: <c>login</c> challenges the provider; its callback
|
||||
/// (<see cref="ExternalSignIn.CallbackPath"/>, handled by the OpenID Connect
|
||||
/// middleware) stores the provider identity in the short-lived
|
||||
/// <c>DorkNet.External</c> cookie and lands on <c>complete</c>. A linked identity
|
||||
/// becomes a <c>DorkNet.Web</c> session at once; an unlinked one goes to the
|
||||
/// website's <c>/link</c> page, where the player proves the account with its
|
||||
/// password once (<c>account/link</c>).</para>
|
||||
///
|
||||
/// <para>Each host signs in on its own (website and admin panel), because the
|
||||
/// hyphen host style puts them under different cookie domains.</para>
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
public class ExternalSignInController(
|
||||
IOptions<ExternalSignInOptions> options,
|
||||
ExternalSignInService signIn,
|
||||
LinkAttemptLimiter limiter,
|
||||
DomainConfig domain,
|
||||
ILogger<ExternalSignInController> log) : ControllerBase
|
||||
{
|
||||
private ExternalSignInOptions Options => options.Value;
|
||||
|
||||
/// <summary>GET /api/auth/external/login?returnUrl=/path — start sign-in.</summary>
|
||||
[HttpGet(ExternalSignIn.LoginPath)]
|
||||
public IActionResult Login([FromQuery] string? returnUrl)
|
||||
{
|
||||
if (!Options.IsConfigured) return NotFound(new { error = "sign_in_not_configured" });
|
||||
var complete = $"{ExternalSignIn.CompletePath}?returnUrl={Uri.EscapeDataString(LocalUrl(returnUrl))}";
|
||||
return Challenge(new AuthenticationProperties { RedirectUri = complete }, ExternalSignIn.OidcScheme);
|
||||
}
|
||||
|
||||
/// <summary>GET /api/auth/external/complete — after the provider's callback.</summary>
|
||||
[HttpGet(ExternalSignIn.CompletePath)]
|
||||
public async Task<IActionResult> Complete([FromQuery] string? returnUrl)
|
||||
{
|
||||
returnUrl = LocalUrl(returnUrl);
|
||||
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
|
||||
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
|
||||
return Redirect(WithQuery(returnUrl, "signin_error", "no_identity"));
|
||||
var (issuer, subject, email) = identity;
|
||||
|
||||
var link = await signIn.FindAsync(issuer, subject);
|
||||
var player = link is null ? null : await signIn.ActivePlayerAsync(link);
|
||||
if (link is not null && player is not null)
|
||||
{
|
||||
await signIn.TouchAsync(link, email);
|
||||
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player, link));
|
||||
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
|
||||
log.LogInformation("[signin] player={Player} via {Issuer}", player.Id, issuer);
|
||||
return Redirect(returnUrl);
|
||||
}
|
||||
if (link is not null)
|
||||
return Redirect(WithQuery(returnUrl, "signin_error", "account_unavailable"));
|
||||
|
||||
// Not linked yet. Linking happens on the website; the admin panel
|
||||
// tells its user to link there first.
|
||||
if (IsAdminHost())
|
||||
return Redirect(WithQuery(returnUrl, "signin_error", "not_linked"));
|
||||
return Redirect($"/link?returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||
}
|
||||
|
||||
/// <summary>GET /api/site/v1/account/me — who is signed in, or which
|
||||
/// provider identity is waiting to be linked.</summary>
|
||||
[HttpGet("api/site/v1/account/me")]
|
||||
public async Task<IActionResult> Me()
|
||||
{
|
||||
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
|
||||
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
|
||||
var pending = external.Succeeded ? ExternalIdentity(external.Principal) : null;
|
||||
return Ok(new
|
||||
{
|
||||
signInAvailable = Options.IsConfigured,
|
||||
provider = Options.DisplayName,
|
||||
signedIn = web.Succeeded,
|
||||
player = web.Succeeded
|
||||
? new
|
||||
{
|
||||
id = ExternalSignIn.PlayerId(web.Principal)?.ToString(),
|
||||
username = web.Principal!.Identity?.Name,
|
||||
email = web.Principal.FindFirst(ExternalSignIn.EmailClaim)?.Value,
|
||||
}
|
||||
: null,
|
||||
pendingLink = pending is { } p ? new { email = p.Email } : null,
|
||||
});
|
||||
}
|
||||
|
||||
public sealed record LinkRequest(string? Username, string? Password);
|
||||
|
||||
/// <summary>POST /api/site/v1/account/link {username, password} — link the
|
||||
/// pending provider identity to the account these credentials prove.</summary>
|
||||
[HttpPost("api/site/v1/account/link")]
|
||||
public async Task<IActionResult> Link([FromBody] LinkRequest body)
|
||||
{
|
||||
var external = await HttpContext.AuthenticateAsync(ExternalSignIn.ExternalScheme);
|
||||
if (!external.Succeeded || ExternalIdentity(external.Principal) is not { } identity)
|
||||
return Unauthorized(new { error = "no_pending_identity" });
|
||||
var (issuer, subject, email) = identity;
|
||||
var username = body.Username?.Trim() ?? string.Empty;
|
||||
if (username.Length == 0 || string.IsNullOrEmpty(body.Password))
|
||||
return BadRequest(new { error = "missing_credentials" });
|
||||
|
||||
var keys = new[] { $"sub:{issuer}|{subject}", $"user:{username.ToLowerInvariant()}" };
|
||||
if (limiter.IsBlocked(keys))
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" });
|
||||
|
||||
var (outcome, player, link) = await signIn.LinkAsync(issuer, subject, email, username, body.Password);
|
||||
switch (outcome)
|
||||
{
|
||||
case LinkOutcome.Linked:
|
||||
limiter.Reset(keys);
|
||||
await HttpContext.SignInAsync(ExternalSignIn.WebScheme, ExternalSignIn.PlayerPrincipal(player!, link!));
|
||||
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
|
||||
log.LogInformation("[signin] linked player={Player} to {Issuer}", player!.Id, issuer);
|
||||
return Ok(new { linked = true, username = player.Username });
|
||||
case LinkOutcome.InvalidCredentials:
|
||||
limiter.RecordFailure(keys);
|
||||
return Unauthorized(new { error = "invalid_credentials" });
|
||||
case LinkOutcome.Banned:
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "banned" });
|
||||
default:
|
||||
return Conflict(new { error = "identity_linked_to_another_account" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>POST /api/site/v1/account/unlink — remove this provider identity
|
||||
/// from the account and sign out.</summary>
|
||||
[HttpPost("api/site/v1/account/unlink")]
|
||||
public async Task<IActionResult> Unlink()
|
||||
{
|
||||
var web = await HttpContext.AuthenticateAsync(ExternalSignIn.WebScheme);
|
||||
if (!web.Succeeded || ExternalSignIn.PlayerId(web.Principal) is not { } playerId)
|
||||
return Unauthorized(new { error = "not_signed_in" });
|
||||
var issuer = web.Principal!.FindFirst(ExternalSignIn.IssuerClaim)?.Value ?? string.Empty;
|
||||
await signIn.UnlinkAsync(playerId, issuer);
|
||||
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
|
||||
return Ok(new { unlinked = true });
|
||||
}
|
||||
|
||||
/// <summary>POST /api/site/v1/account/logout — end the session here.</summary>
|
||||
[HttpPost("api/site/v1/account/logout")]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
await HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
|
||||
await HttpContext.SignOutAsync(ExternalSignIn.ExternalScheme);
|
||||
return Ok(new { signedOut = true });
|
||||
}
|
||||
|
||||
/// <summary>(issuer, subject, email) from the provider's claims. The
|
||||
/// subject is the account key; the email is display only.</summary>
|
||||
private (string Issuer, string Subject, string Email)? ExternalIdentity(System.Security.Claims.ClaimsPrincipal? p)
|
||||
{
|
||||
var subject = p?.FindFirst("sub")?.Value;
|
||||
if (string.IsNullOrEmpty(subject)) return null;
|
||||
var issuer = p!.FindFirst("iss")?.Value ?? Options.Authority;
|
||||
return (issuer.TrimEnd('/'), subject, p.FindFirst("email")?.Value ?? string.Empty);
|
||||
}
|
||||
|
||||
private string LocalUrl(string? url) =>
|
||||
!string.IsNullOrEmpty(url) && Url.IsLocalUrl(url) ? url : "/";
|
||||
|
||||
private static string WithQuery(string url, string key, string value) =>
|
||||
url + (url.Contains('?') ? "&" : "?") + key + "=" + Uri.EscapeDataString(value);
|
||||
|
||||
private bool IsAdminHost() =>
|
||||
string.Equals(Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Data;
|
||||
|
||||
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options)
|
||||
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options), IDataProtectionKeyContext
|
||||
{
|
||||
public DbSet<PlayerEntity> Players => Set<PlayerEntity>();
|
||||
public DbSet<AvatarEntity> Avatars => Set<AvatarEntity>();
|
||||
@@ -11,6 +12,7 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
public DbSet<RelationshipEntity> Relationships => Set<RelationshipEntity>();
|
||||
public DbSet<RoomEntity> Rooms => Set<RoomEntity>();
|
||||
public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>();
|
||||
public DbSet<RoomRolePermissionEntity> RoomRolePermissions => Set<RoomRolePermissionEntity>();
|
||||
public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>();
|
||||
public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>();
|
||||
public DbSet<RoomDataBlobEntity> RoomDataBlobs => Set<RoomDataBlobEntity>();
|
||||
@@ -87,9 +89,19 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
public DbSet<UgcPurchasableEntity> UgcPurchasables => Set<UgcPurchasableEntity>();
|
||||
public DbSet<RoomConsumableEntity> RoomConsumables => Set<RoomConsumableEntity>();
|
||||
public DbSet<RoomConsumableOwnershipEntity> RoomConsumableOwnership => Set<RoomConsumableOwnershipEntity>();
|
||||
public DbSet<ExternalLoginEntity> ExternalLogins => Set<ExternalLoginEntity>();
|
||||
// ASP.NET Core data-protection keys (sign-in cookies), kept in the
|
||||
// database so a redeploy or a second replica doesn't sign everyone out.
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ExternalLoginEntity>(e =>
|
||||
{
|
||||
e.HasIndex(x => new { x.Issuer, x.Subject }).IsUnique();
|
||||
e.HasIndex(x => x.PlayerId);
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -134,6 +146,14 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
||||
e.HasIndex(p => new { p.ClubId, p.MembershipType }).IsUnique();
|
||||
});
|
||||
|
||||
// One cell per (room, sub-room, role, permission): the sub-room
|
||||
// permissions PUT upserts the cells it was sent.
|
||||
modelBuilder.Entity<RoomRolePermissionEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
e.HasIndex(p => new { p.RoomId, p.SubRoomId, p.Role, p.Permission }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClubAdditionalImageEntity>(e =>
|
||||
{
|
||||
e.HasKey(i => i.Id);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// An identity from the external sign-in provider (OpenID Connect) linked to a
|
||||
/// DorkNet account. One row per (Issuer, Subject): the provider's stable user
|
||||
/// id, never the email, which can change or be reused. A player proves they own
|
||||
/// the account once, with its password, when the link is made
|
||||
/// (<c>ExternalSignInController.Link</c>); after that the provider alone signs
|
||||
/// them in to the site and, for admins, the admin panel.
|
||||
/// </summary>
|
||||
public class ExternalLoginEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>The provider's issuer URL (the <c>iss</c> claim).</summary>
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The provider's user id (the <c>sub</c> claim).</summary>
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The email the provider reported when the link was made or last
|
||||
/// used. Shown to the player; never used to find the account.</summary>
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime LastUsedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One cell of a sub-room's role-permission matrix, as written by
|
||||
/// <c>PUT rooms/{roomId}/subrooms/{subRoomId}/permissions</c> and served back
|
||||
/// in the <c>Permissions</c> list of <c>GET photon_access_token</c>.
|
||||
///
|
||||
/// The 2023-03-21 client models a cell as <c>BNLPBMJJOMM</c>
|
||||
/// (formatter <c>RecNet.Runtime/HDOJMEBCAGP.txt</c>, keys
|
||||
/// <c>Permission</c> / <c>Role</c> / <c>Override</c> / <c>Type</c> /
|
||||
/// <c>Value</c>): Permission and Value are strings, Role is the room-role
|
||||
/// enum <c>OMMBGJMJJPN</c> (None 0, Host 10, Moderator 20, CoOwner 30,
|
||||
/// TemporaryCoOwner 31, Creator 255), Type is <c>FEOJFGECKGH</c>
|
||||
/// (Bool 0, Int 1, StringList 2) and Override is a bool.
|
||||
///
|
||||
/// The editor sends only the cells it changed (the room-permissions runtime
|
||||
/// builds a one-element <c>BNLPBMJJOMM[]</c> per edit,
|
||||
/// <c>OMJKHJLFOCO.AFHJLBBFAGP.<LocalRequestModifyRolePermission>g__ChangeAndRefresh</c>),
|
||||
/// so the write is an upsert per (room, sub-room, role, permission) and never
|
||||
/// a replace of the whole matrix.
|
||||
/// </summary>
|
||||
public class RoomRolePermissionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long RoomId { get; set; }
|
||||
|
||||
/// <summary>The wire sub-room id from the route (the same value the room
|
||||
/// details advertise as <c>SubRooms[].SubRoomId</c>).</summary>
|
||||
public long SubRoomId { get; set; }
|
||||
|
||||
/// <summary>Wire room-role value (OMMBGJMJJPN), e.g. 30 = CoOwner, 255 = Creator.</summary>
|
||||
public int Role { get; set; }
|
||||
|
||||
/// <summary>The permission id exactly as the client sent it.</summary>
|
||||
[MaxLength(128)]
|
||||
public string Permission { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>FEOJFGECKGH: 0 = Bool, 1 = Int, 2 = StringList.</summary>
|
||||
public int Type { get; set; }
|
||||
|
||||
public bool Override { get; set; }
|
||||
|
||||
/// <summary>The value exactly as the client sent it (a string on the wire).</summary>
|
||||
[MaxLength(4096)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
public long UpdatedByPlayerId { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<!-- RenderedCompactJsonFormatter for stdout JSON logs in production
|
||||
(Coolify-friendly). The host pipeline reads stdout line-by-line,
|
||||
and structured JSON lets a future Loki/Datadog ingest parse fields
|
||||
@@ -26,7 +28,7 @@
|
||||
account-creation flow), and PlayerPresenceService heartbeats all
|
||||
need shared state across replicas. -->
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.13" />
|
||||
<!-- S3-compatible object storage. Production target: Garage in
|
||||
Coolify (one-click). Dev fallback: local MinIO for parity
|
||||
testing. The SDK speaks plain S3, so swapping endpoints
|
||||
@@ -37,8 +39,8 @@
|
||||
<!-- Password hashing for the real account auth flow. BCrypt embeds
|
||||
cost+salt in the output string so we only need one column. -->
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.3.1" />
|
||||
<!-- Server-side crop/resize for img.* CDN transforms (?cropSquare=1&width=N
|
||||
on profile thumbnails). The 2020 client treats this as part of the
|
||||
image CDN contract — it uploads a full-frame capture and expects the
|
||||
|
||||
@@ -27,15 +27,16 @@ namespace DorkNet.Server.Hubs;
|
||||
/// Pre-PR-2 used a local <c>static ConcurrentDictionary</c> 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.
|
||||
/// Hub methods the 2023-03-21 client sends (fire-and-forget, RecNet.Runtime
|
||||
/// OMHPBIEHOHN):
|
||||
/// "SubscribeToPlayers" - {PlayerIds}: players to get presence for
|
||||
/// "SubscribeTo" - {Channels}: always empty in this build
|
||||
/// "UnsubscribeFrom" - {Channels}: always empty in this build
|
||||
/// There is no UnsubscribeFromPlayers; SubscribeToPlayers always carries the
|
||||
/// complete set. All accept JsonElement and tolerate casing differences.
|
||||
///
|
||||
/// The client reconnects at most 3 times per login (its attempt counter is
|
||||
/// never reset), so the server must not drop healthy connections.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class NotifyHub(
|
||||
@@ -65,28 +66,25 @@ public class NotifyHub(
|
||||
// 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.
|
||||
// joined the group yet) and send those ids a Logout with
|
||||
// Reason SessionTakeover. The client shows its own "signed in
|
||||
// elsewhere" message and returns to the boot scene. (A
|
||||
// ModerationKick only sent the old session to its dorm, still
|
||||
// signed in.)
|
||||
//
|
||||
// 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
|
||||
// line — never receives it, 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)",
|
||||
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); logging out {OldCount} existing connection(s)",
|
||||
id, Context.ConnectionId, existing.Count);
|
||||
try
|
||||
{
|
||||
await notifications.KickConnectionsAsync(
|
||||
existing,
|
||||
"Your account was signed in on another device.");
|
||||
await notifications.LogoutConnectionsAsync(existing);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -156,36 +154,111 @@ public class NotifyHub(
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
public Task SubscribeToPlayers(JsonElement payload)
|
||||
/// <summary>SignalR group a connection joins to receive one player's
|
||||
/// presence. Only presence is sent here — a subscriber must never receive
|
||||
/// the subject's own pushes (DMs, gifts, …).</summary>
|
||||
public static string PresenceGroupForPlayer(long playerId) => $"presence:{playerId}";
|
||||
|
||||
private const string SubscriptionsKey = "presence-subscriptions";
|
||||
|
||||
/// <summary>Most players one connection may subscribe to. The client sends
|
||||
/// its relationships plus the players around it (dozens); anything far
|
||||
/// past that is not a real client, and each id costs a group membership
|
||||
/// on the backplane.</summary>
|
||||
public const int MaxPresenceSubscriptions = 5000;
|
||||
|
||||
/// <summary><c>SubscribeToPlayers(PlayerSubscriptionListDTO {PlayerIds:
|
||||
/// List<int>})</c> (RecNet.Runtime OMHPBIEHOHN.txt:3430): the full set
|
||||
/// of players this client wants presence for — its relationships, the
|
||||
/// players around it, and itself. Sent on every (re)connect and whenever
|
||||
/// the set changes, always as the complete set, so it REPLACES the
|
||||
/// connection's subscriptions. Presence for those players is then pushed
|
||||
/// to <see cref="PresenceGroupForPlayer"/> by
|
||||
/// <see cref="PresenceBroadcastService"/>, so non-friends the client is
|
||||
/// looking at (people in the room, an open profile) stay current too.
|
||||
///
|
||||
/// The caller's own id is skipped: a presence frame about the local player
|
||||
/// is applied as a correction and moves them. An unreadable payload
|
||||
/// leaves the current subscriptions alone rather than clearing them.
|
||||
/// Groups live per connection on its own replica, are routed cluster-wide
|
||||
/// by the Redis backplane, and are dropped by SignalR on disconnect.</summary>
|
||||
public async Task SubscribeToPlayers(JsonElement payload)
|
||||
{
|
||||
var me = TryGetPlayerId();
|
||||
if (!TryReadPlayerIds(payload, out var requested))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[hub] SubscribeToPlayers unreadable payload player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||
me, Context.ConnectionId, RawPayload(payload));
|
||||
return;
|
||||
}
|
||||
if (me is long self) requested.Remove(self);
|
||||
if (requested.Count > MaxPresenceSubscriptions)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[hub] SubscribeToPlayers over the {Max} limit player={PlayerId} conn={ConnectionId} count={Count}; kept the current set",
|
||||
MaxPresenceSubscriptions, me, Context.ConnectionId, requested.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
var current = Context.Items.TryGetValue(SubscriptionsKey, out var existing) && existing is HashSet<long> set
|
||||
? set
|
||||
: new HashSet<long>();
|
||||
var added = requested.Except(current).ToList();
|
||||
var removed = current.Except(requested).ToList();
|
||||
foreach (var id in added)
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
|
||||
foreach (var id in removed)
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, PresenceGroupForPlayer(id));
|
||||
Context.Items[SubscriptionsKey] = requested;
|
||||
|
||||
logger.LogInformation(
|
||||
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} subscribed={Count} added={Added} removed={Removed}",
|
||||
me, Context.ConnectionId, requested.Count, added.Count, removed.Count);
|
||||
}
|
||||
|
||||
/// <summary><c>SubscribeTo({Channels: List<string>})</c>. The
|
||||
/// 2023-03-21 client composes it on every connect but its channel set is
|
||||
/// always empty, so there is nothing to subscribe to; logged only.</summary>
|
||||
public Task SubscribeTo(JsonElement payload)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||
"[hub] SubscribeTo player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UnsubscribeFromPlayers(JsonElement payload)
|
||||
/// <summary>Counterpart of <see cref="SubscribeTo"/>; logged only.</summary>
|
||||
public Task UnsubscribeFrom(JsonElement payload)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[hub] UnsubscribeFromPlayers player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||
"[hub] UnsubscribeFrom player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Subscribe(JsonElement payload)
|
||||
/// <summary>Reads <c>{PlayerIds:[…]}</c> (any key casing), or a bare
|
||||
/// array, as player ids; numbers or numeric strings.</summary>
|
||||
public static bool TryReadPlayerIds(JsonElement payload, out HashSet<long> ids)
|
||||
{
|
||||
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;
|
||||
ids = new HashSet<long>();
|
||||
var list = payload;
|
||||
if (payload.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var match = payload.EnumerateObject()
|
||||
.Where(prop => string.Equals(prop.Name, "PlayerIds", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(prop => (JsonElement?)prop.Value)
|
||||
.FirstOrDefault();
|
||||
if (match is not JsonElement value) return false;
|
||||
list = value;
|
||||
}
|
||||
if (list.ValueKind != JsonValueKind.Array) return false;
|
||||
foreach (var item in list.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out var n) && n > 0) ids.Add(n);
|
||||
else if (item.ValueKind == JsonValueKind.String && long.TryParse(item.GetString(), out var p) && p > 0) ids.Add(p);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private long? TryGetPlayerId()
|
||||
|
||||
@@ -0,0 +1,3718 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
[DbContext(typeof(DorkNetDbContext))]
|
||||
[Migration("20261024201104_RoomRolePermissions")]
|
||||
partial class RoomRolePermissions
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AdminPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AdminPlayerId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("TargetType", "TargetId");
|
||||
|
||||
b.ToTable("AdminActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomAvatarItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquippedItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FaceFeatures")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("HairColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InventoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelections")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelectionsV2")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SavedOutfitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SkinColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Avatars");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReadAt");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.ToTable("BugReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBuiltIn")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CharadesWordLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ModerationState")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey", "SentAt");
|
||||
|
||||
b.ToTable("ChatMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LastReadMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SnoozeUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ThreadKey", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreadMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CheeredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPhotoId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TargetPhotoId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("TargetRoomId");
|
||||
|
||||
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Cheers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "Slot")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAdditionalImages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AuthorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "CreatedAt");
|
||||
|
||||
b.ToTable("ClubAnnouncements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AnnouncementId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("AnnouncementId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAnnouncementReads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CategoryTagId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryTagId");
|
||||
|
||||
b.HasIndex("ClubId", "CategoryTagId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubCategoryAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("ClubCategoryTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ClubChatEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ClubType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ClubhouseRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRRO")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Joinability")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Clubs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ClubId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ApproveMember")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BanUnban")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CreateEvent")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditDetails")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditPermissionSettings")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MembershipType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PostAnnouncement")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "MembershipType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SubscribedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId");
|
||||
|
||||
b.HasIndex("PlayerId", "ClubId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AssignedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CohortKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Variant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CohortKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CohortAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Json")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CommunityBoardRows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxRedemptions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RedemptionCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RewardAmount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardCurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RewardItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Coupons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CouponId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CouponId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CouponRedemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CurrencyType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssetName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("BaseAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PurchaseCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsFeatured");
|
||||
|
||||
b.HasIndex("IsPublic");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CustomAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomAvatarItemId");
|
||||
|
||||
b.HasIndex("PlayerId", "CustomAvatarItemId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItemOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
|
||||
{
|
||||
b.Property<long>("PlayerId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("PlayerId");
|
||||
|
||||
b.ToTable("DormStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("GrantedItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Offer1ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer2ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer3ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SelectedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SelectedGiftDropId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "SelectedAt");
|
||||
|
||||
b.ToTable("GameRewardSelections");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActivityLevelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlayerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Region")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Region");
|
||||
|
||||
b.ToTable("GameSessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AvatarItemDescOrHairDyeDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("AvatarItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConsumableItemDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Consumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ConsumedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Currency")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EquipmentModificationGuid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquipmentPrefabName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftRarity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsGifted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsValid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageMaterial")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageVariant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SourceStoreItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsCurrentPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Xp")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "Consumed");
|
||||
|
||||
b.ToTable("GiftPackages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("CreationRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CreatorPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentVersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("FirstPublishedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GeneralPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAgInvention")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("NumPlayersHaveUsedInRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Permission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SpawnCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CheerCount");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.ToTable("Inventions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("InstantiationCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("InventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LightsCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InventionId", "VersionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InventionVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("BannedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("BannedByAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Cidr")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Cidr");
|
||||
|
||||
b.ToTable("IpBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ItemKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemKey", "ItemType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ItemWishlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EarnedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "EventKey");
|
||||
|
||||
b.ToTable("Keepsakes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
|
||||
{
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Channel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LowerIsBetter")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ValueFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("RoomId", "Channel");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("LeaderboardChannelMeta");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StatChannel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Value")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "StatChannel")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId", "StatChannel", "Value");
|
||||
|
||||
b.ToTable("LeaderboardStats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Context")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlatformMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("LoadingScreenTips");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "SentAt");
|
||||
|
||||
b.HasIndex("SenderPlayerId", "SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAll")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAnnouncements")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowEventInvite")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowFriendRequest")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowMessage")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NotificationPrefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ClearedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ObjectiveProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PendingDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TaggedPlayerIdsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploaderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ViewCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("UploaderPlayerId");
|
||||
|
||||
b.ToTable("Photos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlatformIgnores");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeviceId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PlayerDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Elo")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GameMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Losses")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MatchesPlayed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Wins")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GameMode", "Elo");
|
||||
|
||||
b.HasIndex("PlayerId", "GameMode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerElo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BannerImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CanReceiveInvites")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedFromDeviceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayEmoji")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdentityFlags")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsCommunityTeam")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeveloper")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsJunior")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LastPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastPlatformId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PersonalPronouns")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProfileImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Reputation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("XP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Capacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("StartsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("StartsAt");
|
||||
|
||||
b.ToTable("PlayerEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("EventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Response")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("EventId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerEventResponses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemSlug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerInventory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCurated")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsCurated");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Playlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Cheered")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlaylistId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlaylistInteractions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlaylistId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("PlaylistId", "OrderIndex");
|
||||
|
||||
b.ToTable("PlaylistRooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DataBlob")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("OwnerPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRegion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhotonRoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId");
|
||||
|
||||
b.ToTable("PrivateInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
|
||||
{
|
||||
b.Property<long>("PrivateInstanceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("InvitedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LatestInviteMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PrivateInstanceId", "PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PrivateInstanceInvitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PushTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Ignored")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Muted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RequesterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RequesterId", "TargetId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ResolutionNote")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ResolverAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetEventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.HasIndex("ResolvedAt");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.ToTable("Reports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BannedPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "BannedPlayerId");
|
||||
|
||||
b.ToTable("RoomBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomBookmarks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("CurrencyId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomConsumables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ConcurrencyCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomConsumableId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomConsumableId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomConsumableId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomConsumableOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomCurrencyId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomCurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DailyLimit")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomCurrencies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Amount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.ToTable("RoomCurrencyPurchaseOffers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Bytes")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ReferencedFilenamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploadedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomDataBlobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowNewUsers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CloningAllowed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomRoomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DisableMicAutoMute")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("HiddenFromBrowse")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("HotScore")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAGRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDormRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsStudioRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LoadScreensJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LocationReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayerCalculationMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<string>("PromoExternalContentJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PromoImagesJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RoomWarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioSessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsMobile")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsVRLow")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("HotScore");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Rooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(174)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.ToTable("RoomKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PaidPrice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("PurchasedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomKeyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomKeyId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomKeyPurchases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Accepted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("GrantedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("GrantedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "Role")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Override")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Permission")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId", "Role", "Permission")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CanMatchmakeInto")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DataModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSandbox")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomSceneLocationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StudioAssetBundleNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("StudioSubRoomDataSaveId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioUnityAssetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.HasIndex("RoomId", "OrderIndex")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomScenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("FirstVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "LastVisitAt");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomVisits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChestsOpened")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CompletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CustomGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("HealthPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsInAir")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ShieldPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WalkGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompletedAt");
|
||||
|
||||
b.ToTable("RoyaleMatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("MatchId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("MatchId", "PlayerId");
|
||||
|
||||
b.ToTable("RoyaleMatchPlayers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CurrentLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NextLevelAcornReward")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("NextLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RankIdx")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RankName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalXP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoyalePlayerProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllAvatarItemsOwned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BaseRoomNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CharadesSlotBindingsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CircuitsV2LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiscoveredGameConfigsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("GlobalFriendsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlayMenuTagsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("ProfanityFilterDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecCenterDoorsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("RoomBlobVersionClampDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SignupsDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WeeklyChallengeRewardJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("WeeklyChallengesCompletedRequired")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("WeeklyChallengesJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Descriptor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("RedeemedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SignupCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("AvailableUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsLimitedTime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Storefront")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Storefront");
|
||||
|
||||
b.HasIndex("IsActive", "Category");
|
||||
|
||||
b.ToTable("StoreItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SubscriberPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Pk")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssignedPlayerIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AssignedPlayerNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraBugUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinNumAssignedPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<uint?>("TestPassId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Pk");
|
||||
|
||||
b.HasIndex("Id")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("TestPassId");
|
||||
|
||||
b.ToTable("TestCases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
|
||||
{
|
||||
b.Property<uint>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("EndDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("WasManuallyClosed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TestPasses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("UgcPurchasables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithOne("Avatar")
|
||||
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithMany("Settings")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("RequesterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Requester");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Navigation("Avatar");
|
||||
|
||||
b.Navigation("Relationships");
|
||||
|
||||
b.Navigation("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RoomRolePermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RoomRolePermissions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
RoomId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
SubRoomId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
Role = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Permission = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Override = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Value = table.Column<string>(type: "TEXT", maxLength: 4096, nullable: false),
|
||||
UpdatedByPlayerId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RoomRolePermissions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission",
|
||||
table: "RoomRolePermissions",
|
||||
columns: new[] { "RoomId", "SubRoomId", "Role", "Permission" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "RoomRolePermissions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3772 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
[DbContext(typeof(DorkNetDbContext))]
|
||||
[Migration("20261025134141_ExternalSignIn")]
|
||||
partial class ExternalSignIn
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.13");
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AdminPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AdminPlayerId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("TargetType", "TargetId");
|
||||
|
||||
b.ToTable("AdminActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomAvatarItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquippedItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FaceFeatures")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("HairColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InventoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelections")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelectionsV2")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SavedOutfitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SkinColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Avatars");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReadAt");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.ToTable("BugReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBuiltIn")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CharadesWordLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ModerationState")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey", "SentAt");
|
||||
|
||||
b.ToTable("ChatMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LastReadMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SnoozeUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ThreadKey", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreadMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CheeredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPhotoId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TargetPhotoId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("TargetRoomId");
|
||||
|
||||
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Cheers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "Slot")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAdditionalImages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AuthorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "CreatedAt");
|
||||
|
||||
b.ToTable("ClubAnnouncements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AnnouncementId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("AnnouncementId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAnnouncementReads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CategoryTagId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryTagId");
|
||||
|
||||
b.HasIndex("ClubId", "CategoryTagId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubCategoryAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("ClubCategoryTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ClubChatEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ClubType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ClubhouseRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRRO")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Joinability")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Clubs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ClubId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ApproveMember")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BanUnban")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CreateEvent")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditDetails")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditPermissionSettings")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MembershipType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PostAnnouncement")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "MembershipType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SubscribedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId");
|
||||
|
||||
b.HasIndex("PlayerId", "ClubId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AssignedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CohortKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Variant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CohortKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CohortAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Json")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CommunityBoardRows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxRedemptions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RedemptionCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RewardAmount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardCurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RewardItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Coupons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CouponId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CouponId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CouponRedemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CurrencyType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssetName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("BaseAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PurchaseCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsFeatured");
|
||||
|
||||
b.HasIndex("IsPublic");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CustomAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomAvatarItemId");
|
||||
|
||||
b.HasIndex("PlayerId", "CustomAvatarItemId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItemOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
|
||||
{
|
||||
b.Property<long>("PlayerId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("PlayerId");
|
||||
|
||||
b.ToTable("DormStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ExternalLoginEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastUsedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("Issuer", "Subject")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ExternalLogins");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("GrantedItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Offer1ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer2ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer3ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SelectedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SelectedGiftDropId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "SelectedAt");
|
||||
|
||||
b.ToTable("GameRewardSelections");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActivityLevelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlayerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Region")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Region");
|
||||
|
||||
b.ToTable("GameSessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AvatarItemDescOrHairDyeDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("AvatarItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConsumableItemDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Consumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ConsumedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Currency")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EquipmentModificationGuid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquipmentPrefabName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftRarity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsGifted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsValid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageMaterial")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageVariant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SourceStoreItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsCurrentPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Xp")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "Consumed");
|
||||
|
||||
b.ToTable("GiftPackages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("CreationRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CreatorPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentVersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("FirstPublishedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GeneralPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAgInvention")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("NumPlayersHaveUsedInRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Permission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SpawnCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CheerCount");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.ToTable("Inventions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("InstantiationCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("InventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LightsCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InventionId", "VersionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InventionVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("BannedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("BannedByAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Cidr")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Cidr");
|
||||
|
||||
b.ToTable("IpBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ItemKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemKey", "ItemType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ItemWishlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EarnedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "EventKey");
|
||||
|
||||
b.ToTable("Keepsakes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
|
||||
{
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Channel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LowerIsBetter")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ValueFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("RoomId", "Channel");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("LeaderboardChannelMeta");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StatChannel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Value")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "StatChannel")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId", "StatChannel", "Value");
|
||||
|
||||
b.ToTable("LeaderboardStats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Context")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlatformMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("LoadingScreenTips");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "SentAt");
|
||||
|
||||
b.HasIndex("SenderPlayerId", "SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAll")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAnnouncements")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowEventInvite")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowFriendRequest")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowMessage")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NotificationPrefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ClearedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ObjectiveProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PendingDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TaggedPlayerIdsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploaderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ViewCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("UploaderPlayerId");
|
||||
|
||||
b.ToTable("Photos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlatformIgnores");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeviceId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PlayerDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Elo")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GameMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Losses")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MatchesPlayed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Wins")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GameMode", "Elo");
|
||||
|
||||
b.HasIndex("PlayerId", "GameMode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerElo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BannerImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CanReceiveInvites")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedFromDeviceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayEmoji")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdentityFlags")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsCommunityTeam")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeveloper")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsJunior")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LastPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastPlatformId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PersonalPronouns")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProfileImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Reputation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("XP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Capacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("StartsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("StartsAt");
|
||||
|
||||
b.ToTable("PlayerEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("EventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Response")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("EventId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerEventResponses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemSlug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerInventory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCurated")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsCurated");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Playlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Cheered")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlaylistId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlaylistInteractions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlaylistId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("PlaylistId", "OrderIndex");
|
||||
|
||||
b.ToTable("PlaylistRooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DataBlob")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("OwnerPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRegion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhotonRoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId");
|
||||
|
||||
b.ToTable("PrivateInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
|
||||
{
|
||||
b.Property<long>("PrivateInstanceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("InvitedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LatestInviteMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PrivateInstanceId", "PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PrivateInstanceInvitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PushTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Ignored")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Muted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RequesterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RequesterId", "TargetId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ResolutionNote")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ResolverAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetEventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.HasIndex("ResolvedAt");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.ToTable("Reports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BannedPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "BannedPlayerId");
|
||||
|
||||
b.ToTable("RoomBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomBookmarks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("CurrencyId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomConsumables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ConcurrencyCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomConsumableId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomConsumableId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomConsumableId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomConsumableOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomCurrencyId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomCurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DailyLimit")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomCurrencies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Amount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.ToTable("RoomCurrencyPurchaseOffers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Bytes")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ReferencedFilenamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploadedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomDataBlobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowNewUsers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CloningAllowed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomRoomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DisableMicAutoMute")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("HiddenFromBrowse")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("HotScore")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAGRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDormRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsStudioRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LoadScreensJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LocationReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayerCalculationMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<string>("PromoExternalContentJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PromoImagesJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RoomWarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioSessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsMobile")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsVRLow")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("HotScore");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Rooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(174)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.ToTable("RoomKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PaidPrice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("PurchasedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomKeyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomKeyId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomKeyPurchases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Accepted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("GrantedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("GrantedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "Role")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Override")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Permission")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId", "Role", "Permission")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CanMatchmakeInto")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DataModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSandbox")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomSceneLocationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StudioAssetBundleNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("StudioSubRoomDataSaveId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioUnityAssetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.HasIndex("RoomId", "OrderIndex")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomScenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("FirstVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "LastVisitAt");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomVisits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChestsOpened")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CompletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CustomGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("HealthPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsInAir")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ShieldPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WalkGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompletedAt");
|
||||
|
||||
b.ToTable("RoyaleMatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("MatchId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("MatchId", "PlayerId");
|
||||
|
||||
b.ToTable("RoyaleMatchPlayers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CurrentLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NextLevelAcornReward")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("NextLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RankIdx")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RankName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalXP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoyalePlayerProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllAvatarItemsOwned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BaseRoomNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CharadesSlotBindingsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CircuitsV2LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiscoveredGameConfigsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("GlobalFriendsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlayMenuTagsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("ProfanityFilterDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecCenterDoorsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("RoomBlobVersionClampDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SignupsDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WeeklyChallengeRewardJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("WeeklyChallengesCompletedRequired")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("WeeklyChallengesJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Descriptor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("RedeemedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SignupCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("AvailableUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsLimitedTime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Storefront")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Storefront");
|
||||
|
||||
b.HasIndex("IsActive", "Category");
|
||||
|
||||
b.ToTable("StoreItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SubscriberPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Pk")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssignedPlayerIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AssignedPlayerNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraBugUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinNumAssignedPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<uint?>("TestPassId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Pk");
|
||||
|
||||
b.HasIndex("Id")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("TestPassId");
|
||||
|
||||
b.ToTable("TestCases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
|
||||
{
|
||||
b.Property<uint>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("EndDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("WasManuallyClosed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TestPasses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("UgcPurchasables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Xml")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DataProtectionKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithOne("Avatar")
|
||||
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithMany("Settings")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("RequesterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Requester");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Navigation("Avatar");
|
||||
|
||||
b.Navigation("Relationships");
|
||||
|
||||
b.Navigation("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExternalSignIn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DataProtectionKeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
FriendlyName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Xml = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalLogins",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
PlayerId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
Issuer = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Subject = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Email = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastUsedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalLogins", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalLogins_Issuer_Subject",
|
||||
table: "ExternalLogins",
|
||||
columns: new[] { "Issuer", "Subject" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalLogins_PlayerId",
|
||||
table: "ExternalLogins",
|
||||
column: "PlayerId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DataProtectionKeys");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalLogins");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace DorkNet.Server.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.13");
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
|
||||
{
|
||||
@@ -972,6 +972,43 @@ namespace DorkNet.Server.Migrations
|
||||
b.ToTable("DormStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ExternalLoginEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastUsedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("Issuer", "Subject")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ExternalLogins");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -3021,6 +3058,51 @@ namespace DorkNet.Server.Migrations
|
||||
b.ToTable("RoomRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Override")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Permission")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId", "Role", "Permission")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -3623,6 +3705,23 @@ namespace DorkNet.Server.Migrations
|
||||
b.ToTable("UgcPurchasables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Xml")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DataProtectionKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
|
||||
@@ -19,13 +19,18 @@ public class AuthService(IConfiguration config, DorkNetDbContext db)
|
||||
// configs) and leave Jwt:Secret as the appsettings.json placeholder;
|
||||
// the mismatched-secret bug shows up only there.
|
||||
private string SecretKey =>
|
||||
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? config["Jwt:Secret"]
|
||||
ConfiguredSecret(config)
|
||||
?? throw new InvalidOperationException(
|
||||
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or " +
|
||||
"Jwt:Secret in appsettings.Local.json.");
|
||||
|
||||
/// <summary>The server secret, in the one resolution order every user of
|
||||
/// it shares (token signing, token validation, key encryption).</summary>
|
||||
public static string? ConfiguredSecret(IConfiguration config) =>
|
||||
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? config["Jwt:Secret"];
|
||||
|
||||
public string GenerateToken(long playerId) => CreateJwt(playerId, TimeSpan.FromHours(12), "access");
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.AspNetCore.DataProtection.XmlEncryption;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts the data-protection key ring (the keys behind the sign-in
|
||||
/// cookies) before it is stored in the <c>DataProtectionKeys</c> table:
|
||||
/// AES-256-GCM under a key derived (HKDF-SHA256) from the server secret.
|
||||
/// The secret comes from the environment, not the database, so a database
|
||||
/// backup alone holds no usable keys. Changing the secret makes the stored
|
||||
/// keys unreadable: the server then creates a new key and everyone signs in
|
||||
/// again.
|
||||
/// </summary>
|
||||
public sealed class DataProtectionKeyEncryption : IXmlEncryptor
|
||||
{
|
||||
private const int NonceSize = 12;
|
||||
private const int TagSize = 16;
|
||||
private static readonly byte[] Info = Encoding.UTF8.GetBytes("DorkNet.DataProtectionKeys.v1");
|
||||
private static readonly XName Root = "dorknetEncryptedKey";
|
||||
private readonly byte[] _key;
|
||||
|
||||
public DataProtectionKeyEncryption(string secret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
throw new InvalidOperationException("The server secret is required to encrypt the data-protection keys.");
|
||||
_key = HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret), 32, salt: null, info: Info);
|
||||
}
|
||||
|
||||
public EncryptedXmlInfo Encrypt(XElement plaintextElement)
|
||||
{
|
||||
var plaintext = Encoding.UTF8.GetBytes(plaintextElement.ToString(SaveOptions.DisableFormatting));
|
||||
var payload = new byte[NonceSize + TagSize + plaintext.Length];
|
||||
var nonce = payload.AsSpan(0, NonceSize);
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
using (var aes = new AesGcm(_key, TagSize))
|
||||
aes.Encrypt(nonce, plaintext, payload.AsSpan(NonceSize + TagSize), payload.AsSpan(NonceSize, TagSize));
|
||||
return new EncryptedXmlInfo(
|
||||
new XElement(Root, new XAttribute("version", "1"), Convert.ToBase64String(payload)),
|
||||
typeof(Decryptor));
|
||||
}
|
||||
|
||||
public XElement DecryptElement(XElement encrypted)
|
||||
{
|
||||
if (encrypted.Name != Root || (string?)encrypted.Attribute("version") != "1")
|
||||
throw new CryptographicException("Unrecognised encrypted data-protection key.");
|
||||
var payload = Convert.FromBase64String(encrypted.Value);
|
||||
if (payload.Length < NonceSize + TagSize)
|
||||
throw new CryptographicException("Encrypted data-protection key is truncated.");
|
||||
var plaintext = new byte[payload.Length - NonceSize - TagSize];
|
||||
using (var aes = new AesGcm(_key, TagSize))
|
||||
aes.Decrypt(payload.AsSpan(0, NonceSize), payload.AsSpan(NonceSize + TagSize),
|
||||
payload.AsSpan(NonceSize, TagSize), plaintext);
|
||||
return XElement.Parse(Encoding.UTF8.GetString(plaintext));
|
||||
}
|
||||
|
||||
/// <summary>Created by the data-protection system from the stored type
|
||||
/// name, with the app's service provider.</summary>
|
||||
public sealed class Decryptor(IServiceProvider services) : IXmlDecryptor
|
||||
{
|
||||
public XElement Decrypt(XElement encryptedElement) =>
|
||||
services.GetRequiredService<DataProtectionKeyEncryption>().DecryptElement(encryptedElement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the client builds CI publishes (<c>ci/downloads/publish.py</c>): a
|
||||
/// <c>manifest.json</c> plus the files it lists, in a bucket served over plain
|
||||
/// HTTP (e.g. Garage's web endpoint on the internal network). The site's
|
||||
/// Downloads and Quest pages fetch them through the server, so the bucket
|
||||
/// itself never has to be reachable from outside.
|
||||
///
|
||||
/// Configured with <c>DorkNet:Downloads:BaseUrl</c> (the folder holding
|
||||
/// <c>manifest.json</c>) and optionally <c>DorkNet:Downloads:Host</c> (the
|
||||
/// Host header to send, for web endpoints that pick the bucket by host name).
|
||||
/// Without a base URL <see cref="IsConfigured"/> is false and the site hides
|
||||
/// both features.
|
||||
/// </summary>
|
||||
public sealed class DownloadsService
|
||||
{
|
||||
public const string HttpClientName = "downloads";
|
||||
|
||||
// How long the bucket may take to start answering. The body then streams
|
||||
// with no limit (the client has none; the caller's abort token ends it).
|
||||
private static readonly TimeSpan HeaderTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly Uri? _baseUri;
|
||||
private readonly string? _host;
|
||||
|
||||
public DownloadsService(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<DownloadsService> log)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
var baseUrl = config["DorkNet:Downloads:BaseUrl"]?.Trim();
|
||||
if (!string.IsNullOrEmpty(baseUrl) && Uri.TryCreate(baseUrl.TrimEnd('/') + "/", UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
{
|
||||
_baseUri = uri;
|
||||
}
|
||||
var host = config["DorkNet:Downloads:Host"]?.Trim();
|
||||
_host = string.IsNullOrEmpty(host) ? null : host;
|
||||
RequireSignIn = config.GetValue<bool>("DorkNet:Downloads:RequireSignIn");
|
||||
|
||||
if (_baseUri is null)
|
||||
log.LogInformation("[downloads] no DorkNet:Downloads:BaseUrl; the Downloads page and Quest latest-build install are off");
|
||||
else
|
||||
log.LogInformation("[downloads] reading builds from {BaseUrl}{Host}", _baseUri, _host is null ? "" : $" (Host: {_host})");
|
||||
}
|
||||
|
||||
public bool IsConfigured => _baseUri is not null;
|
||||
|
||||
/// <summary><c>DorkNet:Downloads:RequireSignIn</c>: serve the manifest and
|
||||
/// files only to a signed-in, linked player (website sign-in).</summary>
|
||||
public bool RequireSignIn { get; }
|
||||
|
||||
/// <summary>The published manifest, or null when none has been published yet.</summary>
|
||||
public async Task<JsonObject?> GetManifestAsync(CancellationToken ct)
|
||||
{
|
||||
using var res = await SendAsync("manifest.json", range: null, ct);
|
||||
if (res.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
|
||||
res.EnsureSuccessStatusCode();
|
||||
return JsonNode.Parse(await res.Content.ReadAsStringAsync(ct)) as JsonObject;
|
||||
}
|
||||
|
||||
/// <summary>A manifest entry by its bucket path, or null when the manifest
|
||||
/// doesn't list it. Only listed files are ever served.</summary>
|
||||
public static JsonObject? FindFile(JsonObject manifest, string path)
|
||||
{
|
||||
if (manifest["releases"] is not JsonArray releases) return null;
|
||||
return releases.OfType<JsonObject>()
|
||||
.SelectMany(r => r["files"] is JsonArray files ? files.OfType<JsonObject>() : [])
|
||||
.FirstOrDefault(f => string.Equals((string?)f["path"], path, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>GET a file, forwarding the caller's Range header. The caller
|
||||
/// owns the response and streams its content.</summary>
|
||||
public Task<HttpResponseMessage> GetFileAsync(string path, string? range, CancellationToken ct)
|
||||
=> SendAsync(path, range, ct);
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(string path, string? range, CancellationToken ct)
|
||||
{
|
||||
if (_baseUri is null) throw new InvalidOperationException("Downloads are not configured.");
|
||||
var url = new Uri(_baseUri, string.Join('/', path.Split('/').Select(Uri.EscapeDataString)));
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
if (_host is not null) req.Headers.Host = _host;
|
||||
if (!string.IsNullOrEmpty(range) && RangeHeaderValue.TryParse(range, out var r)) req.Headers.Range = r;
|
||||
var client = _httpClientFactory.CreateClient(HttpClientName);
|
||||
// The timeout covers only getting the headers: with ResponseHeadersRead
|
||||
// the token is released once they arrive, and disposing the source
|
||||
// here stops its timer before the body is streamed.
|
||||
using var headers = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
headers.CancelAfter(HeaderTimeout);
|
||||
try
|
||||
{
|
||||
return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, headers.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"The downloads bucket didn't answer within {HeaderTimeout.TotalSeconds:0} s.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// External sign-in settings (<c>DorkNet:SignIn:*</c>): an OpenID Connect
|
||||
/// provider players sign in with on the website and, for admins, the admin
|
||||
/// panel. Any standard provider works (e.g. a Cloudflare Access SaaS app,
|
||||
/// Google, Authentik). Unset, the feature is off and only the existing
|
||||
/// username/password logins exist.
|
||||
/// </summary>
|
||||
public sealed class ExternalSignInOptions
|
||||
{
|
||||
/// <summary>The provider's issuer URL; discovery is read from
|
||||
/// <c>{Authority}/.well-known/openid-configuration</c>.</summary>
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The button label: "Sign in with {DisplayName}".</summary>
|
||||
public string DisplayName { get; set; } = "single sign-on";
|
||||
|
||||
public string Scopes { get; set; } = "openid email profile";
|
||||
|
||||
/// <summary>Also read the provider's userinfo endpoint after sign-in. Off by
|
||||
/// default: the ID token already carries <c>sub</c> and <c>email</c>, and
|
||||
/// Cloudflare Access answers userinfo without a Content-Type, which the
|
||||
/// OpenID Connect handler rejects.</summary>
|
||||
public bool UseUserInfoEndpoint { get; set; }
|
||||
|
||||
public bool IsConfigured =>
|
||||
Authority.Length > 0 && ClientId.Length > 0 && ClientSecret.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>Scheme, path and claim names shared by the sign-in pieces.</summary>
|
||||
public static class ExternalSignIn
|
||||
{
|
||||
/// <summary>The signed-in session (a linked DorkNet player), per host.</summary>
|
||||
public const string WebScheme = "DorkNet.Web";
|
||||
|
||||
/// <summary>A provider identity that isn't linked to an account yet. Lives
|
||||
/// only until the player links it (or 15 minutes).</summary>
|
||||
public const string ExternalScheme = "DorkNet.External";
|
||||
|
||||
public const string OidcScheme = "DorkNet.Oidc";
|
||||
|
||||
// Everything lives under /api/: the website and admin hosts answer every
|
||||
// other GET with their SPA's index.html before authentication runs.
|
||||
public const string LoginPath = "/api/auth/external/login";
|
||||
public const string CallbackPath = "/api/auth/external/callback";
|
||||
public const string CompletePath = "/api/auth/external/complete";
|
||||
|
||||
public const string PlayerIdClaim = "dorknet:player_id";
|
||||
public const string IssuerClaim = "dorknet:issuer";
|
||||
public const string SubjectClaim = "dorknet:subject";
|
||||
public const string EmailClaim = "dorknet:email";
|
||||
|
||||
public static ClaimsPrincipal PlayerPrincipal(PlayerEntity player, ExternalLoginEntity link) =>
|
||||
new(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(PlayerIdClaim, player.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, player.Username),
|
||||
new Claim(IssuerClaim, link.Issuer),
|
||||
new Claim(SubjectClaim, link.Subject),
|
||||
new Claim(EmailClaim, link.Email),
|
||||
}, WebScheme, ClaimTypes.Name, ClaimTypes.Role));
|
||||
|
||||
public static long? PlayerId(ClaimsPrincipal? principal) =>
|
||||
long.TryParse(principal?.FindFirst(PlayerIdClaim)?.Value, out var id) ? id : null;
|
||||
}
|
||||
|
||||
public enum LinkOutcome { Linked, InvalidCredentials, Banned, AlreadyLinkedElsewhere }
|
||||
|
||||
/// <summary>Links provider identities to DorkNet accounts and resolves them
|
||||
/// back. An identity is (issuer, subject); the email is only shown.</summary>
|
||||
public sealed class ExternalSignInService(DorkNetDbContext db)
|
||||
{
|
||||
public Task<ExternalLoginEntity?> FindAsync(string issuer, string subject) =>
|
||||
db.ExternalLogins.FirstOrDefaultAsync(l => l.Issuer == issuer && l.Subject == subject);
|
||||
|
||||
/// <summary>The linked player, unless the link or the player is gone or the
|
||||
/// player is banned.</summary>
|
||||
public async Task<PlayerEntity?> ActivePlayerAsync(ExternalLoginEntity link)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == link.PlayerId);
|
||||
return player is null || IsBanned(player) ? null : player;
|
||||
}
|
||||
|
||||
public async Task TouchAsync(ExternalLoginEntity link, string email)
|
||||
{
|
||||
link.LastUsedAt = DateTime.UtcNow;
|
||||
if (!string.IsNullOrEmpty(email)) link.Email = email;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Links (issuer, subject) to the account the credentials prove.
|
||||
/// A player has one identity per provider: linking a new one replaces the
|
||||
/// old. An identity already linked to a different account is refused.</summary>
|
||||
public async Task<(LinkOutcome Outcome, PlayerEntity? Player, ExternalLoginEntity? Link)> LinkAsync(
|
||||
string issuer, string subject, string email, string username, string password)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Username == username);
|
||||
if (player?.PasswordHash is null || !BCrypt.Net.BCrypt.Verify(password, player.PasswordHash))
|
||||
return (LinkOutcome.InvalidCredentials, null, null);
|
||||
if (IsBanned(player)) return (LinkOutcome.Banned, null, null);
|
||||
|
||||
var existing = await FindAsync(issuer, subject);
|
||||
if (existing is not null && existing.PlayerId != player.Id)
|
||||
return (LinkOutcome.AlreadyLinkedElsewhere, null, null);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
db.ExternalLogins.RemoveRange(
|
||||
db.ExternalLogins.Where(l => l.PlayerId == player.Id && l.Issuer == issuer));
|
||||
existing = new ExternalLoginEntity { PlayerId = player.Id, Issuer = issuer, Subject = subject };
|
||||
db.ExternalLogins.Add(existing);
|
||||
}
|
||||
existing.Email = email;
|
||||
existing.LastUsedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return (LinkOutcome.Linked, player, existing);
|
||||
}
|
||||
|
||||
public async Task<bool> UnlinkAsync(long playerId, string issuer) =>
|
||||
await db.ExternalLogins.Where(l => l.PlayerId == playerId && l.Issuer == issuer).ExecuteDeleteAsync() > 0;
|
||||
|
||||
private static bool IsBanned(PlayerEntity p) => p.BannedUntil is { } until && until > DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Caps failed link attempts (a password guess each) per provider
|
||||
/// identity and per username: 5 per 15 minutes each. In memory, per process,
|
||||
/// which is enough to stop guessing through the link form.</summary>
|
||||
public sealed class LinkAttemptLimiter
|
||||
{
|
||||
private const int MaxFailures = 5;
|
||||
private static readonly TimeSpan Window = TimeSpan.FromMinutes(15);
|
||||
private readonly ConcurrentDictionary<string, (int Count, DateTime Since)> _failures = new();
|
||||
|
||||
public bool IsBlocked(params string[] keys) => keys.Any(k =>
|
||||
_failures.TryGetValue(k, out var f) && DateTime.UtcNow - f.Since < Window && f.Count >= MaxFailures);
|
||||
|
||||
public void RecordFailure(params string[] keys)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var k in keys)
|
||||
_failures.AddOrUpdate(k, (1, now), (_, f) => now - f.Since >= Window ? (1, now) : (f.Count + 1, f.Since));
|
||||
}
|
||||
|
||||
public void Reset(params string[] keys)
|
||||
{
|
||||
foreach (var k in keys) _failures.TryRemove(k, out _);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
|
||||
player.Level = LevelForXp(player.XP);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Live level bar: "PlayerProgressionLevelUpdate" {PlayerId, Level, XP}
|
||||
// (handler PBOGNAPEBAI.txt:263, DTO DGFGIFIHCAJ) — the same values
|
||||
// api/players/v2/progression returns. Without it the watch kept the
|
||||
// level and XP it loaded at login until the next relog.
|
||||
await notifications.NotifyTypedAsync(playerId, "PlayerProgressionLevelUpdate",
|
||||
new { PlayerId = (int)playerId, player.Level, player.XP });
|
||||
|
||||
if (player.Level > oldLevel)
|
||||
{
|
||||
// Level-up grants a small currency reward. The wallet UI
|
||||
@@ -82,9 +89,13 @@ public class LevelService(DorkNetDbContext db, NotificationService notifications
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// StorefrontBalanceUpdate "61" is BalanceResponseDTO {Balance,
|
||||
// CurrencyType, Platform}; the client sets that (CurrencyType,
|
||||
// Platform) bucket to Balance. Platform 0 matches every balance REST
|
||||
// response, so the buckets line up.
|
||||
await notifications.NotifyAsync(playerId,
|
||||
PushNotificationId.StorefrontBalanceUpdate,
|
||||
new { CurrencyType = currencyType, row.Balance, Reason = reason });
|
||||
new { row.Balance, CurrencyType = currencyType, Platform = 0 });
|
||||
return row.Balance;
|
||||
}
|
||||
|
||||
|
||||
@@ -212,25 +212,17 @@ public class NotificationService(
|
||||
|
||||
// ── Higher-level event helpers ─────────────────────────────────────
|
||||
|
||||
public Task FriendRequestReceived(long targetPlayerId, long fromPlayerId) =>
|
||||
NotifyAsync(targetPlayerId, PushNotificationId.RelationshipChanged,
|
||||
new { Reason = "FriendRequestReceived", From = fromPlayerId });
|
||||
|
||||
public Task FriendRequestAccepted(long requesterPlayerId, long byPlayerId) =>
|
||||
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged,
|
||||
new { Reason = "FriendRequestAccepted", By = byPlayerId });
|
||||
|
||||
public Task FriendRequestDeclined(long requesterPlayerId, long byPlayerId) =>
|
||||
NotifyAsync(requesterPlayerId, PushNotificationId.RelationshipChanged,
|
||||
new { Reason = "FriendRequestDeclined", By = byPlayerId });
|
||||
|
||||
public Task FriendRemoved(long otherPlayerId, long byPlayerId) =>
|
||||
NotifyAsync(otherPlayerId, PushNotificationId.RelationshipChanged,
|
||||
new { Reason = "FriendRemoved", By = byPlayerId });
|
||||
|
||||
public Task FriendBlocked(long blockedPlayerId, long byPlayerId) =>
|
||||
NotifyAsync(blockedPlayerId, PushNotificationId.RelationshipChanged,
|
||||
new { Reason = "FriendBlocked", By = byPlayerId });
|
||||
/// <summary>Push one relationship row to <paramref name="recipientPlayerId"/>.
|
||||
/// <paramref name="relationship"/> must be the recipient's OWN view of the
|
||||
/// pair — the same <c>{PlayerID, RelationshipType, Muted, Ignored,
|
||||
/// Favorited}</c> object <c>api/relationships/v2/get</c> returns. The 2023
|
||||
/// handler (<c>EEGNOHOELBG.FOCBJAIDCBB</c>, Relationship
|
||||
/// <c>ECGNEHMCGCN</c>) stores it under its <c>PlayerID</c> and raises the
|
||||
/// changed event, which refreshes the friends list and the client's
|
||||
/// SubscribeToPlayers set. Any other shape parses as PlayerID 0 and the
|
||||
/// real row never updates.</summary>
|
||||
public Task RelationshipChanged(long recipientPlayerId, object relationship) =>
|
||||
NotifyAsync(recipientPlayerId, PushNotificationId.RelationshipChanged, relationship);
|
||||
|
||||
/// <summary>Push a presence delta so subscribed friends re-render
|
||||
/// their friend-list row without waiting for the watch's 15-45 s
|
||||
@@ -270,6 +262,23 @@ public class NotificationService(
|
||||
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
|
||||
}
|
||||
|
||||
/// <summary>Push one player's presence to every connection that subscribed
|
||||
/// to them with <c>SubscribeToPlayers</c>, except
|
||||
/// <paramref name="excludeConnectionIds"/> — the subject's own connections
|
||||
/// (a frame about the local player moves them) and anyone already sent the
|
||||
/// frame through their friend group.</summary>
|
||||
public async Task PresenceToSubscribersAsync(
|
||||
long playerId,
|
||||
RoomInstanceDto? room,
|
||||
bool isOnline,
|
||||
int statusVisibility,
|
||||
IReadOnlyList<string> excludeConnectionIds)
|
||||
{
|
||||
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
|
||||
await hub.Clients.GroupExcept(NotifyHub.PresenceGroupForPlayer(playerId), excludeConnectionIds)
|
||||
.SendAsync(HubMethod, payload);
|
||||
}
|
||||
|
||||
/// <summary>The PresenceUpdate envelope for one player, as a JSON string.
|
||||
///
|
||||
/// <paramref name="isOnline"/> is independent of the room: the 2023
|
||||
@@ -302,7 +311,7 @@ public class NotificationService(
|
||||
{
|
||||
["roomInstanceId"] = redacted.RoomInstanceId,
|
||||
["roomId"] = redacted.RoomId,
|
||||
["subRoomId"] = redacted.SubRoomId,
|
||||
["subRoomId"] = redacted.WireSubRoomId,
|
||||
["roomInstanceType"] = redacted.RoomInstanceType,
|
||||
["location"] = redacted.Location,
|
||||
["dataBlob"] = redacted.DataBlob,
|
||||
@@ -349,86 +358,85 @@ public class NotificationService(
|
||||
public Task PlayerEnteredRoom(IEnumerable<long> friendIds, long playerId, RoomInstanceDto room) =>
|
||||
PlayerPresenceChanged(friendIds, playerId, room);
|
||||
|
||||
/// <summary>Force every currently-connected SignalR session for
|
||||
/// <paramref name="playerId"/> to disconnect via the watch's
|
||||
/// ModerationKick flow. Wire path verified against decompilation:
|
||||
/// <c>PushNotificationId.ModerationKick = 22</c> is registered
|
||||
/// by <c>LCCEEFHOBEN.OFFLOPLJBBG</c> via the enum overload.
|
||||
/// <c>LCCEEFHOBEN.KEIAFMLIMFA(Dictionary)</c> reads
|
||||
/// <c>Msg.Reason</c> as a ModerationBlockDetail object, then
|
||||
/// calls <c>PODFHPNOHGB</c> / <c>BootLocalPlayerToDormRoom</c>.
|
||||
///
|
||||
/// Used by <see cref="Controllers.Match.MatchPlayerController.PlayerLogin"/>
|
||||
/// to enforce single-session-per-account: when a second login on
|
||||
/// the same account replaces the LoginLock, the older SignalR
|
||||
/// connections are kicked. The new session hasn't called
|
||||
/// <c>negotiate</c> yet (it happens AFTER /player/login on the
|
||||
/// boot sequence), so the kick only hits old sessions.
|
||||
/// </summary>
|
||||
public Task KickStaleSession(long playerId, string reason) =>
|
||||
NotifyAsync(playerId, PushNotificationId.ModerationKick,
|
||||
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
|
||||
/// <summary>Client logout reasons (<c>Logout</c> "6" payload <c>Reason</c>,
|
||||
/// enum <c>MPJOEALLGHE</c>, read by formatter <c>PBEDEFHAHID</c>). The
|
||||
/// value must go out as a NUMBER: release builds obfuscate enum names, so a
|
||||
/// string like "AdminPasswordReset" fails the parse and the whole push is
|
||||
/// dropped. <c>SessionManager.JNNAOHMNGEM</c> shows its own "signed in
|
||||
/// elsewhere" text for <see cref="SessionTakeover"/>, then returns the
|
||||
/// client to the boot scene for every reason.</summary>
|
||||
public enum LogoutReason
|
||||
{
|
||||
Unknown = 0,
|
||||
UserInitiated = 1,
|
||||
SessionTakeover = 2,
|
||||
ForciblyLoggedOut = 3,
|
||||
Banned = 4,
|
||||
}
|
||||
|
||||
public Task KickPlayerAsync(long playerId, string reason, bool isBan = false) =>
|
||||
NotifyAsync(playerId, PushNotificationId.ModerationKick,
|
||||
ModerationKickPayload(reason, isBan, source: "admin_kick"));
|
||||
/// <summary>Log every session of <paramref name="playerId"/> out to the
|
||||
/// boot scene. Use this, not a kick, when the player must not stay signed
|
||||
/// in: a ModerationKick only sends them to their dorm.</summary>
|
||||
public Task LogoutAsync(long playerId, LogoutReason reason) =>
|
||||
NotifyAsync(playerId, PushNotificationId.Logout, new { Reason = (int)reason });
|
||||
|
||||
/// <summary>Push a ModerationKick to a specific set of SignalR
|
||||
/// connection ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
|
||||
/// to evict the previous session when a new device connects on the
|
||||
/// same account. Targets specific ids (not the player group) so
|
||||
/// the brand-new connection joining the group right after this
|
||||
/// returns can't accidentally receive its own eviction notice.
|
||||
/// Works across replicas because SignalR's Redis backplane routes
|
||||
/// <summary>Single-session-per-account: when a second login replaces the
|
||||
/// LoginLock, log the older session out. The new session hasn't called
|
||||
/// <c>negotiate</c> yet (it happens AFTER /player/login on the boot
|
||||
/// sequence), so only the old session's connections receive it.</summary>
|
||||
public Task LogoutStaleSession(long playerId) =>
|
||||
LogoutAsync(playerId, LogoutReason.SessionTakeover);
|
||||
|
||||
/// <summary>The same takeover logout, sent to specific SignalR connection
|
||||
/// ids — used by <see cref="DorkNet.Server.Hubs.NotifyHub.OnConnectedAsync"/>
|
||||
/// to evict the previous session when a new device connects on the same
|
||||
/// account. Targets ids (not the player group) so the brand-new
|
||||
/// connection, which joins the group right after, never receives it. Works
|
||||
/// across replicas because the Redis backplane routes
|
||||
/// <c>Clients.Clients(ids)</c> to whichever node owns each id.</summary>
|
||||
public Task KickConnectionsAsync(IReadOnlyCollection<string> connectionIds, string reason)
|
||||
public Task LogoutConnectionsAsync(IReadOnlyCollection<string> connectionIds)
|
||||
{
|
||||
if (connectionIds.Count == 0) return Task.CompletedTask;
|
||||
var payload = Serialize(PushNotificationId.ModerationKick,
|
||||
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
|
||||
var payload = Serialize(PushNotificationId.Logout, new { Reason = (int)LogoutReason.SessionTakeover });
|
||||
return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload);
|
||||
}
|
||||
|
||||
private static object ModerationKickPayload(string reason, bool isBan, string source)
|
||||
/// <summary>Kick <paramref name="playerId"/> back to their dorm with
|
||||
/// <paramref name="reason"/> shown in the kick dialog. The player stays
|
||||
/// signed in; use <see cref="LogoutAsync"/> for bans and deletions.
|
||||
/// Pass the room instance the player is in as
|
||||
/// <paramref name="gameSessionId"/> to have the client announce the kick
|
||||
/// to that room.</summary>
|
||||
public Task KickPlayerAsync(long playerId, string reason, long gameSessionId = 0) =>
|
||||
NotifyAsync(playerId, PushNotificationId.ModerationKick, ModerationKickPayload(reason, gameSessionId));
|
||||
|
||||
/// <summary>ModerationKick "22" payload: the FLAT moderation block
|
||||
/// <c>PIBLFGPOCIB</c> (formatter <c>LAIJIAIHGCO</c>). The kick handler
|
||||
/// (<c>SessionManager</c> HandleModerationKicked) starts from its own
|
||||
/// "Kicked to Dormroom" text and only adds a reason as
|
||||
/// a "Reason: " line from <c>VoteKickReason</c> when <c>ReportCategory</c> is 10
|
||||
/// (VoteKick) and <c>IsHostKick</c> is false; <c>Message</c> is never
|
||||
/// shown. When <c>GameSessionId</c> is the kicked player's current room
|
||||
/// instance it also broadcasts the kick to that room, and
|
||||
/// <c>Duration</c> > 0 starts a timeout, so it stays 0. The old nested
|
||||
/// <c>{Reason:{…}}</c> shape (from the 2020 client) parsed to all defaults,
|
||||
/// which booted the player but dropped every reason.</summary>
|
||||
private static object ModerationKickPayload(string reason, long gameSessionId)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(reason)
|
||||
? "Kicked to Dormroom"
|
||||
: reason.Trim();
|
||||
|
||||
var blockDetail = new
|
||||
{
|
||||
ReportCategory = 10, // PlayerReporting.ReportCategory.VoteKick
|
||||
Duration = 0,
|
||||
GameSessionId = 0L,
|
||||
Message = message,
|
||||
IsHostKick = true,
|
||||
PlayerIdReporter = 1,
|
||||
IsBan = isBan,
|
||||
VoteKickReason = message,
|
||||
};
|
||||
|
||||
var message = string.IsNullOrWhiteSpace(reason) ? "Kicked to Dormroom" : reason.Trim();
|
||||
return new
|
||||
{
|
||||
Reason = blockDetail,
|
||||
DisplayReason = message,
|
||||
BannedByPlayerId = 1,
|
||||
Source = source,
|
||||
ReportCategory = 10, // VoteKick: the only category that shows a reason
|
||||
Duration = 0,
|
||||
GameSessionId = gameSessionId,
|
||||
IsHostKick = false,
|
||||
Message = message,
|
||||
PlayerIdReporter = (int?)null,
|
||||
IsBan = false,
|
||||
IsVoiceModAutoban = false,
|
||||
IsWarning = false,
|
||||
VoteKickReason = message,
|
||||
TimeoutStartedAt = (DateTime?)null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Notify a private-instance owner that someone tried to
|
||||
/// follow them in via "Go to Player" but was rejected for not
|
||||
/// being on the invite list. Reuses the MessageReceived channel
|
||||
/// since the watch already renders that as an inbox toast — no
|
||||
/// new client-side handler needed. The body wording surfaces it
|
||||
/// as "Player X tried to join your private match" so the owner
|
||||
/// knows to send an explicit invite if they want to let them in.</summary>
|
||||
public Task PrivateInstanceJoinRejected(long ownerPlayerId, long attemptedByPlayerId) =>
|
||||
NotifyAsync(ownerPlayerId, PushNotificationId.MessageReceived,
|
||||
new
|
||||
{
|
||||
Reason = "PrivateInstanceJoinRejected",
|
||||
AttemptedBy = attemptedByPlayerId,
|
||||
Preview = $"Player {attemptedByPlayerId} tried to join your private match",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,6 +148,34 @@ public class OnlinePresenceService
|
||||
lock (set) return set.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Connection ids of every player in <paramref name="playerIds"/>,
|
||||
/// in one go: the Redis lookups are issued together, so the multiplexer
|
||||
/// pipelines them into one round trip instead of one per player.</summary>
|
||||
public async Task<IReadOnlyCollection<string>> GetConnectionsAsync(IEnumerable<long> playerIds)
|
||||
{
|
||||
var ids = playerIds.Distinct().ToArray();
|
||||
if (ids.Length == 0) return Array.Empty<string>();
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
var db = mux.GetDatabase();
|
||||
var sets = await Task.WhenAll(ids.Select(id => db.SetMembersAsync(ConnsKey(id))));
|
||||
return sets.SelectMany(s => s).Select(v => v.ToString()).ToArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Redis sick → fall through to the local fallback, as
|
||||
// GetConnections does.
|
||||
}
|
||||
}
|
||||
var result = new List<string>();
|
||||
foreach (var id in ids)
|
||||
if (_localConns.TryGetValue(id, out var set))
|
||||
lock (set) result.AddRange(set);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of currently-online player ids across the
|
||||
/// cluster. Returns an empty array when nobody's online; never null.
|
||||
/// Callers typically <c>.ToHashSet()</c> the result for membership
|
||||
|
||||
@@ -105,6 +105,15 @@ public class PlayerPresenceService
|
||||
var db = mux.GetDatabase();
|
||||
db.StringSet(Key(playerId), json, PresenceTtl);
|
||||
db.KeyDelete(NoRoomKey(playerId));
|
||||
// Per-room membership index for the instance browser
|
||||
// (EnumerateActiveInstances). Members are validated against
|
||||
// the player's live presence on read, so a player who moved
|
||||
// on is pruned lazily instead of needing the old room here.
|
||||
if (room is not null && room.RoomId > 0)
|
||||
{
|
||||
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
|
||||
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl);
|
||||
}
|
||||
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
|
||||
playerId, room?.Name ?? "<null>", json.Length);
|
||||
}
|
||||
@@ -132,7 +141,19 @@ public class PlayerPresenceService
|
||||
{
|
||||
try
|
||||
{
|
||||
mux.GetDatabase().KeyExpire(Key(playerId), PresenceTtl);
|
||||
var db = mux.GetDatabase();
|
||||
db.KeyExpire(Key(playerId), PresenceTtl);
|
||||
// Keep the room's membership index alive as long as its
|
||||
// members are: a player refreshed only through here would
|
||||
// otherwise outlive the set (SetRoom's 2 × TTL) and drop out
|
||||
// of the instance browser and room pushes.
|
||||
var json = db.StringGet(Key(playerId));
|
||||
if (json.HasValue &&
|
||||
JsonSerializer.Deserialize<RoomInstanceDto>(json.ToString(), JsonOpts) is { RoomId: > 0 } room)
|
||||
{
|
||||
db.SetAdd(RoomIndexKey(room.RoomId), playerId);
|
||||
db.KeyExpire(RoomIndexKey(room.RoomId), PresenceTtl + PresenceTtl);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -459,45 +480,97 @@ public class PlayerPresenceService
|
||||
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
}
|
||||
|
||||
/// <summary>List the currently-active instances of a given room,
|
||||
/// grouped by PhotonRoomId, with the player ids currently presenced
|
||||
/// in each. Drives the watch's RoomInstanceBrowser
|
||||
/// (<c>GET match.*/room/{id}/instances</c>) so players can see
|
||||
/// which instance their friends are in.
|
||||
/// <summary>List the live instances of a given room, grouped by
|
||||
/// PhotonRoomId (or RoomInstanceId when a row has none), with the player
|
||||
/// ids currently presenced in each. Drives the room instance browser
|
||||
/// (<c>GET match.*/room/{id}/instances</c>) and the event instance list.
|
||||
///
|
||||
/// Redis caveat: without a secondary index per room we'd need a
|
||||
/// SCAN over every presence key — too expensive to do on each
|
||||
/// request. For now the Redis path returns empty and the local
|
||||
/// in-process dictionary is the source of truth. Single-instance
|
||||
/// deployments work; multi-replica Redis deployments need a
|
||||
/// per-room set index to be added before the browser is reliable
|
||||
/// there.</summary>
|
||||
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(long roomId)
|
||||
/// <para>Only players whose presence is still live count: an expired
|
||||
/// room presence (<see cref="PresenceTtl"/>) is dropped, and so is a
|
||||
/// player whose activity marker (<see cref="ActivityTtl"/>, refreshed by
|
||||
/// every heartbeat and join) has lapsed. A client that crashed keeps its
|
||||
/// room presence for up to ten minutes, but it is not in the instance and
|
||||
/// must not inflate the player count or keep an empty instance listed.</para>
|
||||
///
|
||||
/// <para><b>Redis.</b> <see cref="SetRoom"/> records each player in a
|
||||
/// per-room set (<c>presence-room:{roomId}</c>); this reads that set and
|
||||
/// validates every member against their current presence, pruning the
|
||||
/// ones that have moved on or expired. Without the index the Redis path
|
||||
/// had nothing to enumerate and returned an empty list on every
|
||||
/// multi-replica deployment.</para>
|
||||
///
|
||||
/// <para>Private instances are skipped unless
|
||||
/// <paramref name="includePrivate"/> is set; callers that include them
|
||||
/// must apply their own visibility rule.</para></summary>
|
||||
public IEnumerable<(RoomInstanceDto Room, List<long> PlayerIds)> EnumerateActiveInstances(
|
||||
long roomId, bool includePrivate = false)
|
||||
{
|
||||
if (_redis is not null) return Array.Empty<(RoomInstanceDto, List<long>)>();
|
||||
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
|
||||
foreach (var kv in _local)
|
||||
var members = new List<(long PlayerId, RoomInstanceDto Room)>();
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
var room = kv.Value;
|
||||
if (room is null || room.RoomId != roomId) continue;
|
||||
// Skip private instances — those are surfaced separately
|
||||
// in the controller via PrivateInstanceService so we never
|
||||
// leak a photonRoomId for an invite-only match into the
|
||||
// public browser.
|
||||
if (room.IsPrivate) continue;
|
||||
var key = string.IsNullOrEmpty(room.PhotonRoomId)
|
||||
? $"<{room.RoomInstanceId}>"
|
||||
: room.PhotonRoomId;
|
||||
try
|
||||
{
|
||||
var db = mux.GetDatabase();
|
||||
var stale = new List<RedisValue>();
|
||||
foreach (var member in db.SetMembers(RoomIndexKey(roomId)))
|
||||
{
|
||||
if (!long.TryParse(member.ToString(), out var playerId))
|
||||
{
|
||||
stale.Add(member);
|
||||
continue;
|
||||
}
|
||||
var room = GetRoom(playerId);
|
||||
if (room is null || room.RoomId != roomId)
|
||||
{
|
||||
stale.Add(member);
|
||||
continue;
|
||||
}
|
||||
members.Add((playerId, room));
|
||||
}
|
||||
if (stale.Count > 0)
|
||||
db.SetRemove(RoomIndexKey(roomId), stale.ToArray());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis room index scan failed room={RoomId}", roomId);
|
||||
return Array.Empty<(RoomInstanceDto, List<long>)>();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var playerId in _local.Keys.ToList())
|
||||
{
|
||||
var room = GetRoom(playerId); // honours the room TTL
|
||||
if (room is not null && room.RoomId == roomId)
|
||||
members.Add((playerId, room));
|
||||
}
|
||||
}
|
||||
|
||||
var groups = new Dictionary<string, (RoomInstanceDto Room, List<long> PlayerIds)>(StringComparer.Ordinal);
|
||||
foreach (var (playerId, room) in members)
|
||||
{
|
||||
if (room.IsPrivate && !includePrivate) continue;
|
||||
if (!IsRecentlyActive(playerId)) continue;
|
||||
var key = InstanceKey(room);
|
||||
if (!groups.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry = (room, new List<long>());
|
||||
groups[key] = entry;
|
||||
}
|
||||
entry.PlayerIds.Add(kv.Key);
|
||||
entry.PlayerIds.Add(playerId);
|
||||
}
|
||||
return groups.Values;
|
||||
}
|
||||
|
||||
/// <summary>The identity two presence rows share when they are in the
|
||||
/// same live instance: the Photon room, else the instance id.</summary>
|
||||
public static string InstanceKey(RoomInstanceDto room)
|
||||
=> string.IsNullOrEmpty(room.PhotonRoomId)
|
||||
? "<" + room.RoomInstanceId + ">"
|
||||
: room.PhotonRoomId;
|
||||
|
||||
private static string RoomIndexKey(long roomId) => "presence-room:" + roomId;
|
||||
|
||||
private static string Key(long playerId) => $"presence:{playerId}";
|
||||
private static string ActiveKey(long playerId) => $"presence-active:{playerId}";
|
||||
private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}";
|
||||
|
||||
@@ -36,7 +36,10 @@ public static class PresenceVisibility
|
||||
/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into
|
||||
/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync
|
||||
/// correction (:18467). A frame about the LOCAL player therefore moves that player, so
|
||||
/// these frames go to friends only, never to the subject.
|
||||
/// these frames never go to the subject. They go to the subject's friends and to every
|
||||
/// other connection that subscribed to the subject with SubscribeToPlayers (see
|
||||
/// NotifyHub.SubscribeToPlayers); non-friends only see the real presence when it is
|
||||
/// Public.
|
||||
/// </summary>
|
||||
public sealed class PresenceBroadcastService(
|
||||
IServiceScopeFactory scopes,
|
||||
@@ -58,7 +61,7 @@ public sealed class PresenceBroadcastService(
|
||||
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
|
||||
|
||||
/// <summary>Push the player's current presence (online state + committed
|
||||
/// room) to every friend.</summary>
|
||||
/// room) to every friend and subscriber.</summary>
|
||||
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
|
||||
|
||||
/// <summary>Push an offline frame regardless of any lingering markers —
|
||||
@@ -105,7 +108,6 @@ public sealed class PresenceBroadcastService(
|
||||
.Where(id => id != playerId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (friendIds.Count == 0) return;
|
||||
|
||||
var online = !forceOffline && IsOnline(playerId);
|
||||
var room = online ? presence.GetRoom(playerId) : null;
|
||||
@@ -134,6 +136,23 @@ public sealed class PresenceBroadcastService(
|
||||
await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility);
|
||||
if (hiddenFrom.Count > 0)
|
||||
await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility);
|
||||
|
||||
// Everyone else who subscribed to this player (SubscribeToPlayers:
|
||||
// people in the same room, an open profile). They are not friends,
|
||||
// so they see the real presence only when it is Public, and the
|
||||
// offline frame otherwise. Friends were served above and the
|
||||
// subject must never get a frame about themselves, so their
|
||||
// connections are excluded.
|
||||
// Only friends who are online have connections; one call for the
|
||||
// online set, then one pipelined lookup for all of them.
|
||||
var onlineIds = connections.OnlinePlayerIds().ToHashSet();
|
||||
var exclude = (await connections.GetConnectionsAsync(
|
||||
friendIds.Where(onlineIds.Contains).Append(playerId)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var publicToAll = visibility == PresenceVisibility.Public && online;
|
||||
await notifications.PresenceToSubscribersAsync(
|
||||
playerId, publicToAll ? room : null, publicToAll, visibility, exclude);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A sub-room's role-permission matrix: the cells the 2023 client reads from
|
||||
/// <c>GET photon_access_token</c> (<c>ALIIIAGAPND.Permissions</c>, a list of
|
||||
/// <c>BNLPBMJJOMM</c>) and writes with
|
||||
/// <c>PUT rooms/{id}/subrooms/{sub}/permissions</c>.
|
||||
///
|
||||
/// <para>The served list is the built-in defaults with every stored cell for
|
||||
/// the room's sub-room applied on top: a stored cell replaces the default
|
||||
/// with the same (Role, Permission), and cells with no default are appended.
|
||||
/// The list must never be empty — with no Permissions the room-permissions
|
||||
/// runtime (OMJKHJLFOCO) falls into its persisted-role migration path, which
|
||||
/// NREs during first spawn.</para>
|
||||
///
|
||||
/// <para>Cell keys (formatter RecNet.Runtime/HDOJMEBCAGP.txt):
|
||||
/// <c>Permission</c> (string), <c>Role</c> (OMMBGJMJJPN), <c>Override</c>
|
||||
/// (bool), <c>Type</c> (FEOJFGECKGH: Bool 0 / Int 1 / StringList 2),
|
||||
/// <c>Value</c> (string). Role and Type go out as numbers; requests are
|
||||
/// accepted with numbers, numeric strings or enum names.</para>
|
||||
/// </summary>
|
||||
public static class RoomPermissionMatrix
|
||||
{
|
||||
/// <summary>One matrix cell.</summary>
|
||||
public sealed record Cell(string Permission, int Role, bool Override, int Type, string Value);
|
||||
|
||||
/// <summary>The defaults every sub-room starts from: Co-Owner and Creator
|
||||
/// may use the maker pen, spawn inventions and consumables, reset the
|
||||
/// room and toggle play gizmos.</summary>
|
||||
public static IReadOnlyList<Cell> Defaults { get; } = BuildDefaults();
|
||||
|
||||
private static IReadOnlyList<Cell> BuildDefaults()
|
||||
{
|
||||
var permissions = new[]
|
||||
{
|
||||
"13", // Can Use Maker Pen
|
||||
"20", // Can Spawn Inventions
|
||||
"21", // Can Spawn Consumables
|
||||
"22", // Can Use Room Reset Button
|
||||
"23", // Can Use Play Gizmos Toggle
|
||||
};
|
||||
var list = new List<Cell>();
|
||||
foreach (var role in new[] { RoomRoleRules.WireCoOwner, RoomRoleRules.WireCreator })
|
||||
foreach (var permission in permissions)
|
||||
list.Add(new Cell(permission, role, true, 0, "true"));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Defaults merged with the stored cells.</summary>
|
||||
public static List<Cell> Merge(IEnumerable<RoomRolePermissionEntity> stored)
|
||||
{
|
||||
var cells = Defaults.ToList();
|
||||
foreach (var row in stored.OrderBy(r => r.Id))
|
||||
{
|
||||
var cell = new Cell(row.Permission, row.Role, row.Override, row.Type, row.Value);
|
||||
var at = cells.FindIndex(c =>
|
||||
c.Role == row.Role
|
||||
&& string.Equals(c.Permission, row.Permission, StringComparison.Ordinal));
|
||||
if (at >= 0) cells[at] = cell;
|
||||
else cells.Add(cell);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
/// <summary>The served matrix for one sub-room (by OrderIndex).</summary>
|
||||
public static async Task<List<Cell>> LoadAsync(DorkNetDbContext db, long roomId, long subRoomOrderIndex)
|
||||
{
|
||||
var stored = await db.RoomRolePermissions.AsNoTracking()
|
||||
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomOrderIndex)
|
||||
.ToListAsync();
|
||||
return Merge(stored);
|
||||
}
|
||||
|
||||
/// <summary>The <c>ALIIIAGAPND</c> body: <c>{RoomInstanceId,
|
||||
/// PhotonAccessToken, Permissions}</c>. Used both by
|
||||
/// <c>GET photon_access_token</c> and the <c>PhotonAccessToken</c> hub
|
||||
/// push (IBEOONPEELF.txt:519 registers it as Action<ALIIIAGAPND>).</summary>
|
||||
public static Dictionary<string, object> AccessTokenBody(long roomInstanceId, IEnumerable<Cell> cells)
|
||||
=> new()
|
||||
{
|
||||
["RoomInstanceId"] = roomInstanceId,
|
||||
["PhotonAccessToken"] = string.Empty,
|
||||
["Permissions"] = cells.Select(ToWire).ToList(),
|
||||
};
|
||||
|
||||
public static object ToWire(Cell c) => new
|
||||
{
|
||||
c.Permission,
|
||||
c.Role,
|
||||
c.Override,
|
||||
c.Type,
|
||||
c.Value,
|
||||
};
|
||||
|
||||
/// <summary>Parse the PUT body: a JSON array of cells (an object wrapping
|
||||
/// a <c>Permissions</c> array is accepted too). Returns null when the body
|
||||
/// is not JSON or holds no array.</summary>
|
||||
public static List<Cell>? Parse(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body)) return null;
|
||||
JsonDocument doc;
|
||||
try { doc = JsonDocument.Parse(body); }
|
||||
catch (JsonException) { return null; }
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
root = root.EnumerateObject()
|
||||
.Where(p => p.Name.Equals("Permissions", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => p.Value)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
if (root.ValueKind != JsonValueKind.Array) return null;
|
||||
|
||||
var cells = new List<Cell>();
|
||||
foreach (var item in root.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object) continue;
|
||||
string? permission = null;
|
||||
int? role = null;
|
||||
bool @override = false;
|
||||
int type = 0;
|
||||
string value = string.Empty;
|
||||
foreach (var prop in item.EnumerateObject())
|
||||
{
|
||||
switch (prop.Name.ToLowerInvariant())
|
||||
{
|
||||
case "permission":
|
||||
permission = Scalar(prop.Value)?.Trim();
|
||||
break;
|
||||
case "role":
|
||||
role = EnumValue(prop.Value, RoleName);
|
||||
break;
|
||||
case "type":
|
||||
type = EnumValue(prop.Value, TypeName) ?? 0;
|
||||
break;
|
||||
case "override":
|
||||
@override = prop.Value.ValueKind == JsonValueKind.True
|
||||
|| (prop.Value.ValueKind == JsonValueKind.String
|
||||
&& bool.TryParse(prop.Value.GetString(), out var b) && b);
|
||||
break;
|
||||
case "value":
|
||||
value = Scalar(prop.Value) ?? string.Empty;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (string.IsNullOrEmpty(permission) || role is null) continue;
|
||||
if (permission.Length > 128 || value.Length > 4096) continue;
|
||||
cells.Add(new Cell(permission, role.Value, @override, type, value));
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Scalar(JsonElement e) => e.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => e.GetString(),
|
||||
JsonValueKind.Number => e.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||
_ => e.GetRawText(),
|
||||
};
|
||||
|
||||
private static int? EnumValue(JsonElement e, Func<string, int?> byName)
|
||||
{
|
||||
if (e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var n)) return n;
|
||||
if (e.ValueKind != JsonValueKind.String) return null;
|
||||
var s = e.GetString() ?? string.Empty;
|
||||
return int.TryParse(s, out n) ? n : byName(s);
|
||||
}
|
||||
|
||||
private static int? RoleName(string s) => s.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"none" => RoomRoleRules.WireNone,
|
||||
"banned" => RoomRoleRules.WireBanned,
|
||||
"host" => RoomRoleRules.WireHost,
|
||||
"moderator" => RoomRoleRules.WireModerator,
|
||||
"coowner" => RoomRoleRules.WireCoOwner,
|
||||
"temporarycoowner" => RoomRoleRules.WireTemporaryCoOwner,
|
||||
"creator" => RoomRoleRules.WireCreator,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static int? TypeName(string s) => s.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"bool" => 0,
|
||||
"int" => 1,
|
||||
"stringlist" => 2,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 room-role ladder and who may change whose role.
|
||||
///
|
||||
/// <para><b>Wire enum.</b> The client's room role is <c>OMMBGJMJJPN : byte</c>
|
||||
/// (recnet-runtime-decomp/OMMBGJMJJPN.cs): None 0, Banned 1, Host 10,
|
||||
/// Moderator 20, CoOwner 30, TemporaryCoOwner 31, Creator 255. It is
|
||||
/// gap-skipping, like the club MembershipType enum, so never treat it as an
|
||||
/// index. <see cref="RoomRoleEntity.Role"/> stores a compact kind instead
|
||||
/// (0 = CoOwner, 1 = Moderator, 2 = Host); <see cref="ToWire"/> and
|
||||
/// <see cref="TryFromWire"/> convert.</para>
|
||||
///
|
||||
/// <para><b>The creator is 255, not 30.</b> The client reads the local
|
||||
/// player's role straight out of the room details <c>Roles</c> list
|
||||
/// (<c>FGCPNAACHIK.ONECLLALJEO</c>: first row whose AccountId matches), and
|
||||
/// the room-permissions runtime only lets a player edit a role strictly
|
||||
/// below their own, with Creator (255) the one unconditional pass
|
||||
/// (<c>OMJKHJLFOCO.DIIMLMKEJLC</c>,
|
||||
/// IsilDump/RecRoom.RoomPermissions.Runtime/OMJKHJLFOCO.txt:2367:
|
||||
/// <c>if role == 255 return true; if target >= role return false</c>).
|
||||
/// Advertising the creator as CoOwner (30) therefore locked the room's own
|
||||
/// creator out of the Co-Owner and Creator permission rows.</para>
|
||||
///
|
||||
/// <para><b>Server rule</b> (<see cref="CheckChange"/>): the creator may set
|
||||
/// any role on anyone but themselves. Otherwise the actor must hold an
|
||||
/// accepted CoOwner role, and both the target's current role (accepted or
|
||||
/// still invited) and the requested role must be strictly below the
|
||||
/// actor's. A player may always drop their own role or decline their own
|
||||
/// invite (role 0), and may accept exactly the role they were invited to.</para>
|
||||
/// </summary>
|
||||
public static class RoomRoleRules
|
||||
{
|
||||
public const int WireNone = 0;
|
||||
public const int WireBanned = 1;
|
||||
public const int WireHost = 10;
|
||||
public const int WireModerator = 20;
|
||||
public const int WireCoOwner = 30;
|
||||
public const int WireTemporaryCoOwner = 31;
|
||||
public const int WireCreator = 255;
|
||||
|
||||
public const int StoredCoOwner = 0;
|
||||
public const int StoredModerator = 1;
|
||||
public const int StoredHost = 2;
|
||||
|
||||
/// <summary>Stored kind → wire role.</summary>
|
||||
public static int ToWire(int storedRole) => storedRole switch
|
||||
{
|
||||
StoredCoOwner => WireCoOwner,
|
||||
StoredModerator => WireModerator,
|
||||
StoredHost => WireHost,
|
||||
_ => WireNone,
|
||||
};
|
||||
|
||||
/// <summary>Wire role → stored kind. Only the three persistable roles
|
||||
/// map; None, Banned, TemporaryCoOwner (an in-instance grant) and Creator
|
||||
/// (ownership, changed through <c>rooms/{id}/creator</c>) do not.</summary>
|
||||
public static bool TryFromWire(int wireRole, out int storedRole)
|
||||
{
|
||||
storedRole = wireRole switch
|
||||
{
|
||||
WireCoOwner => StoredCoOwner,
|
||||
WireModerator => StoredModerator,
|
||||
WireHost => StoredHost,
|
||||
_ => -1,
|
||||
};
|
||||
return storedRole >= 0;
|
||||
}
|
||||
|
||||
/// <summary>The player's accepted wire role in the room: 255 for the
|
||||
/// creator, the highest accepted grant otherwise, 0 for none.</summary>
|
||||
public static int AcceptedRank(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
|
||||
{
|
||||
if (room.CreatorPlayerId == playerId) return WireCreator;
|
||||
return rows
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Accepted)
|
||||
.Select(r => ToWire(r.Role))
|
||||
.DefaultIfEmpty(WireNone)
|
||||
.Max();
|
||||
}
|
||||
|
||||
/// <summary>The highest role the player has been invited to and not yet
|
||||
/// accepted, 0 for none.</summary>
|
||||
public static int InvitedRank(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
|
||||
=> rows
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId && !r.Accepted)
|
||||
.Select(r => ToWire(r.Role))
|
||||
.DefaultIfEmpty(WireNone)
|
||||
.Max();
|
||||
|
||||
/// <summary>True when the player may change room-level settings and the
|
||||
/// sub-room settings that hang off them: the creator or an accepted
|
||||
/// co-owner. This is the same gate as the bare room mutations
|
||||
/// (RoomsModerationController.RequireOwnedRoomAsync) and room saves.</summary>
|
||||
public static bool CanEditRoom(RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId)
|
||||
=> AcceptedRank(room, rows, playerId) >= WireCoOwner;
|
||||
|
||||
/// <summary>What a role write will do once <see cref="CheckChange"/> allows it.</summary>
|
||||
public enum ChangeKind
|
||||
{
|
||||
/// <summary>Grant or replace the target's role (accepted at once).</summary>
|
||||
Grant,
|
||||
/// <summary>Record a pending invite the target must accept.</summary>
|
||||
Invite,
|
||||
/// <summary>The target accepts their own pending invite.</summary>
|
||||
AcceptInvite,
|
||||
/// <summary>Remove the target's role and any pending invite.</summary>
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// <summary>Decide whether <paramref name="actorId"/> may set
|
||||
/// <paramref name="targetId"/>'s role to <paramref name="requestedWireRole"/>.
|
||||
/// Returns null when allowed, otherwise an error code for the response.
|
||||
/// <paramref name="kind"/> is what the caller should then do.</summary>
|
||||
public static string? CheckChange(
|
||||
RoomEntity room,
|
||||
IReadOnlyCollection<RoomRoleEntity> rows,
|
||||
long actorId,
|
||||
long targetId,
|
||||
int requestedWireRole,
|
||||
bool isInvite,
|
||||
out ChangeKind kind)
|
||||
{
|
||||
kind = requestedWireRole == WireNone
|
||||
? ChangeKind.Clear
|
||||
: isInvite ? ChangeKind.Invite : ChangeKind.Grant;
|
||||
|
||||
if (targetId == room.CreatorPlayerId)
|
||||
return "cannot_change_creator_role";
|
||||
|
||||
if (requestedWireRole != WireNone && !TryFromWire(requestedWireRole, out _))
|
||||
return "invalid_role";
|
||||
|
||||
var actorRank = AcceptedRank(room, rows, actorId);
|
||||
|
||||
// Self-service: anyone may give up their own role or decline their
|
||||
// own invite, and accept the exact role they were invited to.
|
||||
if (actorId == targetId && actorRank != WireCreator)
|
||||
{
|
||||
if (requestedWireRole == WireNone) return null;
|
||||
if (!isInvite && requestedWireRole == InvitedRank(room, rows, targetId))
|
||||
{
|
||||
kind = ChangeKind.AcceptInvite;
|
||||
return null;
|
||||
}
|
||||
return "forbidden";
|
||||
}
|
||||
|
||||
if (actorRank == WireCreator) return null;
|
||||
if (actorRank < WireCoOwner) return "forbidden";
|
||||
|
||||
var targetRank = Math.Max(
|
||||
AcceptedRank(room, rows, targetId),
|
||||
InvitedRank(room, rows, targetId));
|
||||
if (targetRank >= actorRank) return "forbidden";
|
||||
if (requestedWireRole >= actorRank) return "forbidden";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="playerId"/> may write the
|
||||
/// permission row for <paramref name="wireRole"/>: the creator may edit
|
||||
/// every role (including CoOwner and Creator); a co-owner only the roles
|
||||
/// below their own. Mirrors the client's OMJKHJLFOCO.DIIMLMKEJLC.</summary>
|
||||
public static bool CanEditRolePermissions(
|
||||
RoomEntity room, IEnumerable<RoomRoleEntity> rows, long playerId, int wireRole)
|
||||
{
|
||||
var rank = AcceptedRank(room, rows, playerId);
|
||||
if (rank == WireCreator) return true;
|
||||
if (rank < WireCoOwner) return false;
|
||||
// TemporaryCoOwner collapses to CoOwner on the client (ONECLLALJEO).
|
||||
var target = wireRole == WireTemporaryCoOwner ? WireCoOwner : wireRole;
|
||||
return target < rank;
|
||||
}
|
||||
|
||||
/// <summary>The room details / roles-list rows: one per account, with
|
||||
/// the creator first as 255 and every other account's accepted role in
|
||||
/// <c>Role</c> and pending invite in <c>InvitedRole</c>. Rows the
|
||||
/// creator happens to own in RoomRoles are ignored so the creator is
|
||||
/// never listed twice (the client takes the first match).</summary>
|
||||
public static List<RoomRoleWire> BuildWires(RoomEntity room, IEnumerable<RoomRoleEntity> rows)
|
||||
{
|
||||
var list = new List<RoomRoleWire>
|
||||
{
|
||||
new(ToClientAccountId(room.CreatorPlayerId), WireCreator, WireNone, null),
|
||||
};
|
||||
foreach (var group in rows
|
||||
.Where(r => r.RoomId == room.Id && r.PlayerId != room.CreatorPlayerId)
|
||||
.GroupBy(r => r.PlayerId)
|
||||
.OrderBy(g => g.Key))
|
||||
{
|
||||
var accepted = group.Where(r => r.Accepted).ToList();
|
||||
var invited = group.Where(r => !r.Accepted).ToList();
|
||||
var role = accepted.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max();
|
||||
var invitedRole = invited.Select(r => ToWire(r.Role)).DefaultIfEmpty(WireNone).Max();
|
||||
var lastChangedBy = group
|
||||
.OrderByDescending(r => r.GrantedAt)
|
||||
.Select(r => r.GrantedByPlayerId)
|
||||
.FirstOrDefault();
|
||||
list.Add(new RoomRoleWire(
|
||||
ToClientAccountId(group.Key),
|
||||
role,
|
||||
invitedRole,
|
||||
lastChangedBy is long by ? ToClientAccountId(by) : null));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>One <c>EFHPLDPNGIM</c> row: <c>{AccountId: Int32, Role,
|
||||
/// InvitedRole, LastChangedByAccountId: Int32?}</c>.</summary>
|
||||
public sealed record RoomRoleWire(int AccountId, int Role, int InvitedRole, int? LastChangedByAccountId);
|
||||
|
||||
/// <summary>Account ids are Int32 in the 2023 role DTO. Clamp rather than
|
||||
/// wrap so an out-of-range id is obviously wrong.</summary>
|
||||
public static int ToClientAccountId(long accountId)
|
||||
=> accountId > int.MaxValue
|
||||
? int.MaxValue
|
||||
: accountId < int.MinValue
|
||||
? int.MinValue
|
||||
: (int)accountId;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts between a sub-room's position in the room
|
||||
/// (<see cref="Data.Entities.RoomSceneEntity.OrderIndex"/>, 0-based, what the
|
||||
/// server stores and indexes on) and the <c>SubRoomId</c> the 2023 client
|
||||
/// sees on the wire.
|
||||
///
|
||||
/// <para><b>Why they differ.</b> The client refuses to load a sub-room whose
|
||||
/// id is not positive: <c>SubroomModel.DOKCPLJOKIE</c>
|
||||
/// (IsilDump/Assembly-CSharp/RRUI/Data/SubroomModel.txt:3) is
|
||||
/// <c>RoomId > 0 && SubroomId > 0</c>, and
|
||||
/// <c>Model`1.LNPEMMMGCPC</c> skips the load when it is false. With the
|
||||
/// first sub-room advertised as id 0, its Room Settings > Subrooms row
|
||||
/// never fetched its details (<c>SubroomModel.KBOJBJNCOGK</c> →
|
||||
/// <c>FGCPNAACHIK.OBFPEGLLELP(long?)</c>), so the row showed no name, and
|
||||
/// every per-row action (rename, max players, permissions, duplicate) ran
|
||||
/// against a null sub-room. Rec Room's own ids were large unique numbers;
|
||||
/// the client needs only "positive and stable per room".</para>
|
||||
///
|
||||
/// <para><b>Mapping.</b> Wire id = OrderIndex + 1, so a room's first
|
||||
/// sub-room is 1. Incoming ids go back through <see cref="ToOrderIndex"/>;
|
||||
/// 0 (and anything negative) is accepted as an alias of the first sub-room,
|
||||
/// which keeps cached client state, old presence rows and bare
|
||||
/// <c>matchmake/room/{id}</c> joins working.</para>
|
||||
///
|
||||
/// <para><b>Where the conversion happens.</b> Only at the wire: the room
|
||||
/// details <c>SubRooms[]</c>, the sub-room routes
|
||||
/// (<c>rooms/{id}/subrooms/{sub}/*</c>), <c>matchmake/room/{id}/{sub}</c>,
|
||||
/// the room-instance JSON (<c>RoomInstanceDto.subRoomId</c>), the instance
|
||||
/// browser, sub-room save DTOs and permission rows. Everything stored
|
||||
/// (RoomScenes, PrivateInstances, RoomDataBlobs, presence) keeps the
|
||||
/// OrderIndex.</para>
|
||||
/// </summary>
|
||||
public static class RoomSceneIds
|
||||
{
|
||||
/// <summary>OrderIndex → wire SubRoomId (always ≥ 1).</summary>
|
||||
public static long ToWire(long orderIndex) => Math.Max(0, orderIndex) + 1;
|
||||
|
||||
/// <summary>Wire SubRoomId → OrderIndex. 0 and negatives map to the first
|
||||
/// sub-room.</summary>
|
||||
public static int ToOrderIndex(long wireSubRoomId)
|
||||
=> wireSubRoomId <= 1 ? 0 : (int)Math.Min(int.MaxValue, wireSubRoomId - 1);
|
||||
}
|
||||
@@ -546,6 +546,94 @@ public class RoomService(DorkNetDbContext db)
|
||||
return existing;
|
||||
}
|
||||
|
||||
/// <summary>The name a sub-room gets when it has none: "Home" for the
|
||||
/// entry sub-room, "Subroom N" (1-based, matching its wire id) for the
|
||||
/// rest.</summary>
|
||||
public static string DefaultSubRoomName(int orderIndex)
|
||||
=> orderIndex <= 0 ? "Home" : $"Subroom {orderIndex + 1}";
|
||||
|
||||
/// <summary>A real entry sub-room row for a room that has none, built
|
||||
/// from the room's own location, data blob and capacity. Callers add it
|
||||
/// to the context.</summary>
|
||||
public static RoomSceneEntity NewHomeScene(RoomEntity room) => new()
|
||||
{
|
||||
RoomId = room.Id,
|
||||
OrderIndex = 0,
|
||||
Name = DefaultSubRoomName(0),
|
||||
RoomSceneLocationId = string.IsNullOrWhiteSpace(room.LocationReplicationId)
|
||||
? new RoomSceneEntity().RoomSceneLocationId
|
||||
: room.LocationReplicationId,
|
||||
DataBlobName = room.CurrentDataBlobName ?? string.Empty,
|
||||
MaxPlayers = room.MaxCapacity > 0 ? room.MaxCapacity : 8,
|
||||
IsSandbox = false,
|
||||
CanMatchmakeInto = true,
|
||||
DataModifiedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
/// <summary>Postgres only: moves the RoomScenes id sequence past the
|
||||
/// highest existing id. Rows written with explicit ids (seeds, older
|
||||
/// builds of the clone and repair code) leave it behind, and the next
|
||||
/// insert that relies on it (add / clone sub-room) then collides on the
|
||||
/// primary key. A no-op on SQLite, which assigns max + 1 itself.</summary>
|
||||
public async Task SyncRoomSceneIdSequenceAsync()
|
||||
{
|
||||
if (!db.Database.IsNpgsql()) return;
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
SELECT setval(pg_get_serial_sequence('"RoomScenes"', 'Id'),
|
||||
GREATEST(COALESCE((SELECT MAX("Id") FROM "RoomScenes"), 0), 1),
|
||||
EXISTS (SELECT 1 FROM "RoomScenes"));
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Startup self-heal for sub-rooms (idempotent).
|
||||
///
|
||||
/// <para>Every non-dorm room must own at least one real RoomScenes row,
|
||||
/// and every row must have a name. A room created by cloning a template
|
||||
/// that had no scene rows (the 2023 "create room" flow clones a base room
|
||||
/// such as MakerRoom) ended up with none, so the details builder served a
|
||||
/// synthesised fallback and every sub-room mutation had to create the row
|
||||
/// on the fly; a blank Name renders as an empty row in Room Settings >
|
||||
/// Subrooms. This inserts the missing entry sub-room (OrderIndex 0, the
|
||||
/// room's own location / blob / capacity) and names unnamed rows. Dorms
|
||||
/// are left alone: their details are shaped per player and their single
|
||||
/// scene is synthesised on purpose.</para>
|
||||
///
|
||||
/// <para>It first moves the Postgres id sequence of RoomScenes past the
|
||||
/// highest id (<see cref="SyncRoomSceneIdSequenceAsync"/>), then lets the
|
||||
/// database assign ids like every other scene insert. Every service that
|
||||
/// runs the database bootstrap calls this at startup, so on Postgres the
|
||||
/// whole repair holds the bootstrap's advisory lock and replicas booting
|
||||
/// together take turns.</para>
|
||||
/// Returns (rooms given an entry scene, scenes renamed).</summary>
|
||||
public async Task<(int ScenesAdded, int ScenesRenamed)> RepairRoomScenesAsync()
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
if (db.Database.IsNpgsql())
|
||||
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(1146246987);");
|
||||
|
||||
// The entry sub-room is OrderIndex 0 (its blob is the room's own front
|
||||
// door), so a room missing it is repaired even when it has others.
|
||||
await SyncRoomSceneIdSequenceAsync();
|
||||
|
||||
var roomsWithoutEntry = await db.Rooms
|
||||
.Where(r => !r.IsDormRoom && !db.RoomScenes.Any(s => s.RoomId == r.Id && s.OrderIndex == 0))
|
||||
.ToListAsync();
|
||||
foreach (var room in roomsWithoutEntry)
|
||||
db.RoomScenes.Add(NewHomeScene(room));
|
||||
|
||||
var unnamed = await db.RoomScenes
|
||||
.Where(s => s.Name == null || s.Name.Trim() == string.Empty)
|
||||
.ToListAsync();
|
||||
foreach (var scene in unnamed)
|
||||
scene.Name = DefaultSubRoomName(scene.OrderIndex);
|
||||
|
||||
if (roomsWithoutEntry.Count > 0 || unnamed.Count > 0)
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
return (roomsWithoutEntry.Count, unnamed.Count);
|
||||
}
|
||||
|
||||
/// <summary>Run once at startup to backfill <see cref="RoomEntity"/>
|
||||
/// + <see cref="DormStateEntity"/> rows for every account that
|
||||
/// pre-dates the auto-create-on-signup work. Idempotent — calls
|
||||
@@ -1068,6 +1156,8 @@ public class RoomService(DorkNetDbContext db)
|
||||
};
|
||||
|
||||
db.Rooms.Add(clone);
|
||||
// Every room owns a real entry sub-room (see RepairRoomScenesAsync).
|
||||
db.RoomScenes.Add(NewHomeScene(clone));
|
||||
await db.SaveChangesAsync();
|
||||
return new CloneResult(0 /* Success */, clone);
|
||||
}
|
||||
@@ -1081,6 +1171,25 @@ public class RoomService(DorkNetDbContext db)
|
||||
/// AllowsJuniors, RoomWarningMask, CustomRoomWarning
|
||||
/// Optional: IsDormRoom, SupportsVRLow, SupportsMobile, DisableMicAutoMute.
|
||||
/// </summary>
|
||||
/// <summary>The <c>IsRRO</c> the 2023 client is told: only a first-party
|
||||
/// room (baked/AG and still owned by the system account) is a Rec Room
|
||||
/// Original. A player's room is never one, even when it was cloned from an
|
||||
/// RRO template and keeps <see cref="RoomEntity.IsAGRoom"/> for the baked
|
||||
/// blob handling.
|
||||
///
|
||||
/// <para>The client gates the whole owner UI on it: RoomModel
|
||||
/// get_CanPublishRoom, CanUnpublishRoom, CanDeleteRoom,
|
||||
/// CanChangeRoomCreator, CanChangeRoomSetting, CanChangeCustomWarning,
|
||||
/// CanChangeLoadingScreen and CanCreateNewSubroom all require
|
||||
/// <c>!IsRRO</c> (IsilDump/Assembly-CSharp/RRUI/Data/RoomModel.txt:275,
|
||||
/// 429, 560, 682, 1002, 1473, 1680, 1816), so a clone advertised as RRO
|
||||
/// could never be published ("Cannot publish room") or have its settings
|
||||
/// changed. The room-load pipeline (RecRoom.RoomLoading.Runtime) never
|
||||
/// reads the flag: the scene comes from the sub-room's UnitySceneId and
|
||||
/// the blob step from whether DataBlob is set.</para></summary>
|
||||
public static bool WireIsRRO(RoomEntity r)
|
||||
=> r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId;
|
||||
|
||||
public static object ToWireRoom(RoomEntity r) => new
|
||||
{
|
||||
RoomId = r.Id,
|
||||
@@ -1093,7 +1202,7 @@ public class RoomService(DorkNetDbContext db)
|
||||
Accessibility = r.Accessibility,
|
||||
SupportsLevelVoting = r.SupportsLevelVoting,
|
||||
IsAGRoom = r.IsAGRoom,
|
||||
IsRRO = r.IsAGRoom,
|
||||
IsRRO = WireIsRRO(r),
|
||||
// 2020.12 Room.Deserialize (KLCOGEIGEBJ.PPGFHEDFBEA, the Room base)
|
||||
// reads required keys: RoomId, IsDorm, CloningAllowed, DisableMicAutoMute,
|
||||
// DisableRoomComments, EncryptVoiceChat. The 2020.03 key was IsDormRoom;
|
||||
|
||||
@@ -179,7 +179,11 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
||||
foreach (var row in stale)
|
||||
{
|
||||
var guid = row.Slug.Substring("wardrobe-".Length);
|
||||
if (!safe.Contains(guid)) toDelete.Add(row.Id);
|
||||
// Custom-clothing templates aren't sold; drop rows seeded
|
||||
// before they were excluded.
|
||||
if (!safe.Contains(guid) ||
|
||||
DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.IsBaseAvatarItemGuid(guid))
|
||||
toDelete.Add(row.Id);
|
||||
}
|
||||
if (toDelete.Count > 0)
|
||||
{
|
||||
@@ -690,6 +694,9 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
||||
var added = false;
|
||||
foreach (var (guid, dto) in catalog)
|
||||
{
|
||||
// The UGC templates are the base of custom clothing, not shop
|
||||
// items (the real store never listed them).
|
||||
if (dto.IsBaseAvatarItem) continue;
|
||||
var slug = $"wardrobe-{guid}";
|
||||
if (existingSlugs.Contains(slug)) continue;
|
||||
existingSlugs.Add(slug);
|
||||
|
||||
@@ -28,6 +28,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
|
||||
/// listed; add more as needed.</summary>
|
||||
public static class MessageType
|
||||
{
|
||||
public const int FriendInvite = 4;
|
||||
public const int RequestGameInvite = 10;
|
||||
public const int TextMessage = 30;
|
||||
public const int FriendRequestAccepted = 40;
|
||||
public const int FriendCodeUsed = 41;
|
||||
@@ -39,6 +41,8 @@ public sealed class SystemNotificationService(DorkNetDbContext db, NotificationS
|
||||
public const int CreatorPublishedNewRoom = 70;
|
||||
public const int PlayerAttendingEvent = 80;
|
||||
public const int PlayerEventInvitation = 81;
|
||||
public const int CoachMessage = 100;
|
||||
public const int FriendIntroduction = 130;
|
||||
}
|
||||
|
||||
/// <summary>Persist and push a system notification to
|
||||
|
||||
@@ -15,9 +15,9 @@ namespace DorkNet.Server.Startup;
|
||||
/// <item>SQLite — <c>Database.Migrate()</c> after
|
||||
/// <see cref="BaselineExistingSchemaIfNeeded"/> reconciles any legacy
|
||||
/// migration-history state.</item>
|
||||
/// <item>Postgres — <c>EnsureCreated</c> behind a transaction-scoped
|
||||
/// advisory lock so concurrent replica boots don't race on
|
||||
/// <c>CREATE TABLE</c>. Connect retries cover the
|
||||
/// <item>Postgres — <c>EnsureCreated</c>, with the whole bootstrap
|
||||
/// (schema and seeds) behind a session advisory lock so concurrent
|
||||
/// replica boots take turns instead of racing. Connect retries cover the
|
||||
/// container-orchestrated boot window where Postgres DNS isn't
|
||||
/// resolvable yet.</item>
|
||||
/// </list>
|
||||
@@ -40,61 +40,110 @@ public static class DatabaseBootstrap
|
||||
await ConfigureSqliteForLocalConcurrencyAsync(db);
|
||||
BaselineExistingSchemaIfNeeded(db);
|
||||
db.Database.Migrate();
|
||||
await RunUpgradesAndSeedsAsync(app, scope, db);
|
||||
return;
|
||||
}
|
||||
else
|
||||
|
||||
// Postgres path: schema generated from the EF Core model via
|
||||
// EnsureCreated, then the same upgrades and seeds as SQLite. The
|
||||
// whole bootstrap holds a session-level advisory lock, so replicas
|
||||
// booting against one database take turns: the first creates the
|
||||
// schema and seeds, the rest find everything in place. Holding it
|
||||
// only around EnsureCreated left the seeders racing (duplicate keys
|
||||
// on a fresh database).
|
||||
//
|
||||
// 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. It is released in the finally
|
||||
// below, and Postgres drops it if the replica's connection dies, so
|
||||
// a crashed replica can't wedge the next boot.
|
||||
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<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.Server.Bootstrap");
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
// 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.
|
||||
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<ILoggerFactory>()
|
||||
.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))
|
||||
{
|
||||
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);
|
||||
}
|
||||
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();
|
||||
|
||||
await conn.CloseAsync();
|
||||
}
|
||||
|
||||
// The connection stays open until the finally, so every step below
|
||||
// (they all share this scope's DbContext) runs on the session that
|
||||
// holds the lock. Waiting for it has no command timeout: another
|
||||
// replica may be seeding a fresh database (thumbnail downloads
|
||||
// included) for well over the default 30 s.
|
||||
connectLogger.LogInformation("[bootstrap] waiting for the database bootstrap lock");
|
||||
var previousTimeout = db.Database.GetCommandTimeout();
|
||||
db.Database.SetCommandTimeout(0);
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_lock(1146246987);");
|
||||
}
|
||||
finally
|
||||
{
|
||||
db.Database.SetCommandTimeout(previousTimeout);
|
||||
}
|
||||
connectLogger.LogInformation("[bootstrap] holding the database bootstrap lock");
|
||||
try
|
||||
{
|
||||
// Begin the transaction through EF, not on the raw connection:
|
||||
// EnsureCreated runs its commands through the migration executor,
|
||||
// which starts its own transaction unless EF already has one, and
|
||||
// fails on a fresh database with "A transaction is already in
|
||||
// progress" when the open one is invisible to it.
|
||||
await using (var tx = await db.Database.BeginTransactionAsync())
|
||||
{
|
||||
db.Database.EnsureCreated();
|
||||
await tx.CommitAsync();
|
||||
}
|
||||
await RunUpgradesAndSeedsAsync(app, scope, db);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Explicit: Npgsql pools connections, so closing alone would not
|
||||
// end the session that holds the lock. If the connection is
|
||||
// already broken the unlock fails too; that must not replace the
|
||||
// real bootstrap error, and the connection still gets closed
|
||||
// (Postgres then drops the lock with the session).
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_unlock(1146246987);");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
connectLogger.LogWarning(ex, "[bootstrap] releasing the database bootstrap lock failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await conn.CloseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The idempotent post-schema sequence both providers run:
|
||||
/// column/table upgrades, system account and backfills, legacy data
|
||||
/// transforms, canonical rooms and the per-feature seeders. Flips the
|
||||
/// <c>/healthz</c> readiness flag at the end.</summary>
|
||||
private static async Task RunUpgradesAndSeedsAsync(WebApplication app, IServiceScope scope, DorkNetDbContext db)
|
||||
{
|
||||
// Signup-code + pending-device tables. These post-date the
|
||||
// consolidated Initial migration, and the Postgres path is
|
||||
// EnsureCreated-only (no migration replay), so neither provider
|
||||
@@ -102,6 +151,7 @@ public static class DatabaseBootstrap
|
||||
// idempotent raw SQL that works on a fresh DB too (CREATE TABLE
|
||||
// IF NOT EXISTS no-ops when EnsureCreated already built them).
|
||||
await EnsureSignupCodeTablesAsync(db);
|
||||
await EnsureExternalSignInTablesAsync(db);
|
||||
await EnsureServerSettingsColumnsAsync(db);
|
||||
await EnsureCharadesWordListTableAsync(db);
|
||||
await EnsureInventorySchemaAsync(db);
|
||||
@@ -164,6 +214,11 @@ public static class DatabaseBootstrap
|
||||
await roomService.ApplyCanonicalOverridesAsync(http, imagesDir);
|
||||
}
|
||||
|
||||
// Every non-dorm room needs a real, named entry sub-room (see
|
||||
// RoomService.RepairRoomScenesAsync). Runs after the seed and the
|
||||
// canonical overrides so it only fills what they left missing.
|
||||
await roomService.RepairRoomScenesAsync();
|
||||
|
||||
// Seed default curated playlists, club category tags, store catalog.
|
||||
// Must run after SeedAsync so they can reference RR-Originals.
|
||||
var playlistService = scope.ServiceProvider.GetRequiredService<PlaylistService>();
|
||||
@@ -211,6 +266,66 @@ public static class DatabaseBootstrap
|
||||
/// tables on whichever provider is in use. Safe to run every boot:
|
||||
/// CREATE TABLE / INDEX IF NOT EXISTS no-op once they exist (whether
|
||||
/// created here or by a fresh-DB EnsureCreated).</summary>
|
||||
/// <summary>External sign-in: the identities linked to accounts
|
||||
/// (<c>ExternalLogins</c>) and the data-protection keys that encrypt the
|
||||
/// sign-in cookies (<c>DataProtectionKeys</c>). Both post-date the Initial
|
||||
/// migration, so like the signup-code tables they're created here for
|
||||
/// either provider (a no-op once EnsureCreated or an earlier boot made them).</summary>
|
||||
private static async Task EnsureExternalSignInTablesAsync(DorkNetDbContext db)
|
||||
{
|
||||
var pg = db.Database.IsNpgsql();
|
||||
var statements = pg
|
||||
? new[]
|
||||
{
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "ExternalLogins" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"PlayerId" bigint NOT NULL,
|
||||
"Issuer" text NOT NULL,
|
||||
"Subject" text NOT NULL,
|
||||
"Email" text NOT NULL DEFAULT '',
|
||||
"CreatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"LastUsedAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ExternalLogins_Issuer_Subject"" ON ""ExternalLogins"" (""Issuer"", ""Subject"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_ExternalLogins_PlayerId"" ON ""ExternalLogins"" (""PlayerId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "DataProtectionKeys" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"FriendlyName" text NULL,
|
||||
"Xml" text NULL
|
||||
);
|
||||
""",
|
||||
}
|
||||
: new[]
|
||||
{
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "ExternalLogins" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_ExternalLogins" PRIMARY KEY AUTOINCREMENT,
|
||||
"PlayerId" INTEGER NOT NULL,
|
||||
"Issuer" TEXT NOT NULL,
|
||||
"Subject" TEXT NOT NULL,
|
||||
"Email" TEXT NOT NULL DEFAULT '',
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"LastUsedAt" TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ExternalLogins_Issuer_Subject"" ON ""ExternalLogins"" (""Issuer"", ""Subject"");",
|
||||
@"CREATE INDEX IF NOT EXISTS ""IX_ExternalLogins_PlayerId"" ON ""ExternalLogins"" (""PlayerId"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "DataProtectionKeys" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_DataProtectionKeys" PRIMARY KEY AUTOINCREMENT,
|
||||
"FriendlyName" TEXT NULL,
|
||||
"Xml" TEXT NULL
|
||||
);
|
||||
""",
|
||||
};
|
||||
|
||||
foreach (var sql in statements)
|
||||
await db.Database.ExecuteSqlRawAsync(sql);
|
||||
}
|
||||
|
||||
private static async Task EnsureSignupCodeTablesAsync(DorkNetDbContext db)
|
||||
{
|
||||
var pg = db.Database.IsNpgsql();
|
||||
@@ -899,6 +1014,23 @@ public static class DatabaseBootstrap
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");",
|
||||
// 2023 sub-room role-permission matrix
|
||||
// (PUT rooms/{id}/subrooms/{sub}/permissions → photon_access_token).
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "RoomRolePermissions" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"RoomId" bigint NOT NULL,
|
||||
"SubRoomId" bigint NOT NULL,
|
||||
"Role" integer NOT NULL,
|
||||
"Permission" character varying(128) NOT NULL DEFAULT '',
|
||||
"Type" integer NOT NULL DEFAULT 0,
|
||||
"Override" boolean NOT NULL DEFAULT false,
|
||||
"Value" character varying(4096) NOT NULL DEFAULT '',
|
||||
"UpdatedByPlayerId" bigint NOT NULL DEFAULT 0,
|
||||
"UpdatedAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");",
|
||||
};
|
||||
foreach (var sql in statements)
|
||||
await db.Database.ExecuteSqlRawAsync(sql);
|
||||
@@ -976,6 +1108,21 @@ public static class DatabaseBootstrap
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "RoomRolePermissions" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_RoomRolePermissions" PRIMARY KEY AUTOINCREMENT,
|
||||
"RoomId" INTEGER NOT NULL,
|
||||
"SubRoomId" INTEGER NOT NULL,
|
||||
"Role" INTEGER NOT NULL,
|
||||
"Permission" TEXT NOT NULL DEFAULT '',
|
||||
"Type" INTEGER NOT NULL DEFAULT 0,
|
||||
"Override" INTEGER NOT NULL DEFAULT 0,
|
||||
"Value" TEXT NOT NULL DEFAULT '',
|
||||
"UpdatedByPlayerId" INTEGER NOT NULL DEFAULT 0,
|
||||
"UpdatedAt" TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
""",
|
||||
@"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_RoomRolePermissions_RoomId_SubRoomId_Role_Permission"" ON ""RoomRolePermissions"" (""RoomId"", ""SubRoomId"", ""Role"", ""Permission"");",
|
||||
};
|
||||
// CREATE ... IF NOT EXISTS is already idempotent; a failure here is a
|
||||
// real problem and must fail startup.
|
||||
|
||||
@@ -238,6 +238,9 @@ public static class RequestTracingExtensions
|
||||
return false;
|
||||
|
||||
var path = request.Path.Value ?? "";
|
||||
// Client builds are streamed through (several GB): never buffer them.
|
||||
if (path.StartsWith("/api/site/v1/downloads/files/", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
var ext = Path.GetExtension(path);
|
||||
if (string.IsNullOrEmpty(ext))
|
||||
return true;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.DataProtection.KeyManagement;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -29,6 +34,7 @@ public static class ServiceCollectionExtensions
|
||||
AddVersionCompatibility(builder);
|
||||
var redisConn = AddRedis(builder);
|
||||
AddAuthentication(builder);
|
||||
AddExternalSignIn(builder);
|
||||
AddKestrelAndSignalR(builder, redisConn);
|
||||
}
|
||||
|
||||
@@ -217,6 +223,13 @@ public static class ServiceCollectionExtensions
|
||||
// so a deployment with no GitHub credentials behaves normally.
|
||||
services.AddSingleton<IGitHubIssues, GitHubIssueService>();
|
||||
services.AddHostedService<TestCaseIssueReconciler>();
|
||||
// CI-published client builds for the site's Downloads / Quest pages.
|
||||
// Off (IsConfigured=false) without DorkNet:Downloads:BaseUrl. No client
|
||||
// timeout: a multi-GB file streams for longer than the default 100 s.
|
||||
// DownloadsService limits the wait for headers itself, and the
|
||||
// request's own abort token ends the stream.
|
||||
services.AddHttpClient(DownloadsService.HttpClientName, c => c.Timeout = Timeout.InfiniteTimeSpan);
|
||||
services.AddSingleton<DownloadsService>();
|
||||
// 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
|
||||
@@ -333,9 +346,7 @@ public static class ServiceCollectionExtensions
|
||||
// older Coolify configs.
|
||||
private static void AddAuthentication(WebApplicationBuilder builder)
|
||||
{
|
||||
var jwtSecret = Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
|
||||
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
|
||||
?? builder.Configuration["Jwt:Secret"]
|
||||
var jwtSecret = AuthService.ConfiguredSecret(builder.Configuration)
|
||||
?? 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 " +
|
||||
@@ -390,6 +401,119 @@ public static class ServiceCollectionExtensions
|
||||
opt.JsonSerializerOptions.PropertyNamingPolicy = null);
|
||||
}
|
||||
|
||||
// ── External sign-in (OpenID Connect) ─────────────────────────────────────
|
||||
// Players sign in to the website (and admins to the admin panel) with an
|
||||
// OpenID Connect provider, configured under DorkNet:SignIn. The game
|
||||
// client's own token flow is untouched. Two cookie schemes, both per host
|
||||
// (the hyphen host style puts the admin panel outside the apex's cookie
|
||||
// domain): DorkNet.Web is the signed-in player, DorkNet.External a
|
||||
// provider identity waiting to be linked to an account.
|
||||
private static void AddExternalSignIn(WebApplicationBuilder builder)
|
||||
{
|
||||
var services = builder.Services;
|
||||
var section = builder.Configuration.GetSection("DorkNet:SignIn");
|
||||
services.Configure<ExternalSignInOptions>(section);
|
||||
var signIn = section.Get<ExternalSignInOptions>() ?? new ExternalSignInOptions();
|
||||
var https = !string.Equals(builder.Configuration["Domain:Scheme"] ?? "https", "http", StringComparison.OrdinalIgnoreCase);
|
||||
var secure = https ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
services.AddScoped<ExternalSignInService>();
|
||||
services.AddSingleton<LinkAttemptLimiter>();
|
||||
|
||||
// Cookie encryption keys live in the database, so a redeploy or a
|
||||
// second replica doesn't invalidate everyone's session. They're
|
||||
// stored encrypted with a key derived from the server secret, which
|
||||
// lives outside the database, so a database backup alone can't forge
|
||||
// sessions.
|
||||
services.AddSingleton(new DataProtectionKeyEncryption(
|
||||
AuthService.ConfiguredSecret(builder.Configuration) ?? string.Empty));
|
||||
services.AddDataProtection()
|
||||
.SetApplicationName("DorkNet")
|
||||
.PersistKeysToDbContext<DorkNetDbContext>();
|
||||
services.AddOptions<KeyManagementOptions>()
|
||||
.Configure<DataProtectionKeyEncryption>((o, enc) => o.XmlEncryptor = enc);
|
||||
|
||||
var auth = services.AddAuthentication()
|
||||
.AddCookie(ExternalSignIn.WebScheme, o =>
|
||||
{
|
||||
o.Cookie.Name = "dorknet_session";
|
||||
o.Cookie.HttpOnly = true;
|
||||
o.Cookie.SameSite = SameSiteMode.Lax;
|
||||
o.Cookie.SecurePolicy = secure;
|
||||
o.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
o.SlidingExpiration = true;
|
||||
// API endpoints answer 401/403 instead of redirecting.
|
||||
o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; };
|
||||
o.Events.OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; };
|
||||
// An unlinked identity, a deleted or banned player ends the session.
|
||||
o.Events.OnValidatePrincipal = async ctx =>
|
||||
{
|
||||
var issuer = ctx.Principal?.FindFirst(ExternalSignIn.IssuerClaim)?.Value;
|
||||
var subject = ctx.Principal?.FindFirst(ExternalSignIn.SubjectClaim)?.Value;
|
||||
var svc = ctx.HttpContext.RequestServices.GetRequiredService<ExternalSignInService>();
|
||||
var link = issuer is null || subject is null ? null : await svc.FindAsync(issuer, subject);
|
||||
if (link is null || link.PlayerId != ExternalSignIn.PlayerId(ctx.Principal)
|
||||
|| await svc.ActivePlayerAsync(link) is null)
|
||||
{
|
||||
ctx.RejectPrincipal();
|
||||
await ctx.HttpContext.SignOutAsync(ExternalSignIn.WebScheme);
|
||||
}
|
||||
};
|
||||
})
|
||||
.AddCookie(ExternalSignIn.ExternalScheme, o =>
|
||||
{
|
||||
o.Cookie.Name = "dorknet_external";
|
||||
o.Cookie.HttpOnly = true;
|
||||
o.Cookie.SameSite = SameSiteMode.Lax;
|
||||
o.Cookie.SecurePolicy = secure;
|
||||
o.ExpireTimeSpan = TimeSpan.FromMinutes(15);
|
||||
o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; };
|
||||
});
|
||||
|
||||
if (!signIn.IsConfigured) return;
|
||||
|
||||
auth.AddOpenIdConnect(ExternalSignIn.OidcScheme, o =>
|
||||
{
|
||||
o.Authority = signIn.Authority;
|
||||
o.ClientId = signIn.ClientId;
|
||||
o.ClientSecret = signIn.ClientSecret;
|
||||
o.ResponseType = "code";
|
||||
o.UsePkce = true;
|
||||
o.Scope.Clear();
|
||||
foreach (var scope in signIn.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
o.Scope.Add(scope);
|
||||
o.SignInScheme = ExternalSignIn.ExternalScheme;
|
||||
o.CallbackPath = ExternalSignIn.CallbackPath;
|
||||
o.GetClaimsFromUserInfoEndpoint = signIn.UseUserInfoEndpoint;
|
||||
o.MapInboundClaims = false;
|
||||
o.SaveTokens = false;
|
||||
o.CorrelationCookie.SecurePolicy = secure;
|
||||
o.NonceCookie.SecurePolicy = secure;
|
||||
o.Events.OnRedirectToIdentityProvider = ctx =>
|
||||
{
|
||||
// Behind TLS-terminating proxies the request arrives as http;
|
||||
// the provider must redirect back to the public URL of the
|
||||
// host the sign-in started on (website or admin panel).
|
||||
var domain = ctx.HttpContext.RequestServices.GetRequiredService<DomainConfig>();
|
||||
ctx.ProtocolMessage.RedirectUri = $"{domain.Scheme}://{ctx.Request.Host}{ExternalSignIn.CallbackPath}";
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
o.Events.OnRemoteFailure = ctx =>
|
||||
{
|
||||
// e.g. the player cancelled at the provider: back to the login
|
||||
// page (admin panel) or the site with an error instead of an
|
||||
// exception page. The message is logged, not shown.
|
||||
var domain = ctx.HttpContext.RequestServices.GetRequiredService<DomainConfig>();
|
||||
var admin = string.Equals(ctx.Request.Host.Host, domain.Sub("admin"), StringComparison.OrdinalIgnoreCase);
|
||||
ctx.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("DorkNet.SignIn").LogWarning(ctx.Failure, "[signin] provider sign-in failed");
|
||||
ctx.Response.Redirect((admin ? "/login" : "/") + "?signin_error=provider_failed");
|
||||
ctx.HandleResponse();
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Kestrel + SignalR ────────────────────────────────────────────────────
|
||||
// Kestrel-level limits: defaults trim long-lived sockets earlier than our
|
||||
// SignalR ping interval allows.
|
||||
@@ -416,15 +540,18 @@ public static class ServiceCollectionExtensions
|
||||
kopts.Limits.MinResponseDataRate = null;
|
||||
});
|
||||
|
||||
// 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).
|
||||
// Keepalive cadence: the server pings every 10s, which keeps frame
|
||||
// traffic well below every proxy timeout we've seen in the path
|
||||
// (Cloudflare edge ~100s, cloudflared 600s, Coolify/Traefik 180s,
|
||||
// Kestrel 130s). The 2023 client pings every 15s, but from its main
|
||||
// thread, so a long room-load hitch pauses its pings. It has no
|
||||
// timeout of its own and reconnects at most 3 times per login, so a
|
||||
// healthy socket the server drops can leave it without live
|
||||
// notifications for the rest of the session. Give it 180s.
|
||||
var signalR = builder.Services.AddSignalR(opts =>
|
||||
{
|
||||
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
|
||||
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
|
||||
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(180);
|
||||
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
|
||||
// Allow large gift / community-board payloads (default 32 KB
|
||||
// truncates anything bigger and disconnects the client).
|
||||
|
||||
@@ -49,21 +49,28 @@ export function assetCdnUrl(path: string | null | undefined): string | null {
|
||||
return subdomainUrl('cdn', path, undefined, true);
|
||||
}
|
||||
|
||||
export function adminApex(): string {
|
||||
if (typeof window === 'undefined') return 'rec.net';
|
||||
// The server mounts this SPA on its own `admin` subdomain, so the admin
|
||||
// host tells us the deployment's apex and subdomain style: `admin.<apex>`
|
||||
// (dot style, `img.<apex>`) or `admin-<apex>` (hyphen style, `img-<apex>`).
|
||||
function adminHost(): { apex: string; hyphen: boolean } {
|
||||
if (typeof window === 'undefined') return { apex: 'rec.net', hyphen: false };
|
||||
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
if (host === 'localhost' || host.endsWith('.localhost')) return 'localhost';
|
||||
if (host.endsWith('-dev.example.com')) return 'dev.example.com';
|
||||
if (host === 'localhost' || host.endsWith('.localhost')) return { apex: 'localhost', hyphen: false };
|
||||
|
||||
const parts = host.split('.');
|
||||
return parts.length > 2 ? parts.slice(1).join('.') : host;
|
||||
const [first, ...rest] = host.split('.');
|
||||
if (first.startsWith('admin-') && rest.length > 0)
|
||||
return { apex: [first.slice('admin-'.length), ...rest].join('.'), hyphen: true };
|
||||
return { apex: rest.length >= 2 ? rest.join('.') : host, hyphen: false };
|
||||
}
|
||||
|
||||
function serviceHost(subdomain: string, apex: string): string {
|
||||
return apex === 'dev.example.com'
|
||||
? `${subdomain}-${apex}`
|
||||
: `${subdomain}.${apex}`;
|
||||
export function adminApex(): string {
|
||||
return adminHost().apex;
|
||||
}
|
||||
|
||||
function serviceHost(subdomain: string): string {
|
||||
const { apex, hyphen } = adminHost();
|
||||
return hyphen ? `${subdomain}-${apex}` : `${subdomain}.${apex}`;
|
||||
}
|
||||
|
||||
function subdomainUrl(subdomain: string, path: string | null | undefined, query?: string, preserveSlashes = false): string | null {
|
||||
@@ -74,7 +81,7 @@ function subdomainUrl(subdomain: string, path: string | null | undefined, query?
|
||||
const encodedPath = preserveSlashes
|
||||
? path.split('/').map(encodeURIComponent).join('/')
|
||||
: encodeURIComponent(path);
|
||||
return `${scheme}://${serviceHost(subdomain, apex)}/${encodedPath}${suffix}`;
|
||||
return `${scheme}://${serviceHost(subdomain)}/${encodedPath}${suffix}`;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
|
||||
@@ -5,18 +5,32 @@ import { useToast } from '../components/Toast';
|
||||
|
||||
export function Broadcast() {
|
||||
const [message, setMessage] = useState('');
|
||||
const [minutes, setMinutes] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const startsInMinutes = minutes.trim() === '' ? null : Number(minutes);
|
||||
const minutesValid = startsInMinutes === null || (Number.isInteger(startsInMinutes) && startsInMinutes >= 0);
|
||||
const canSend = !busy && minutesValid && (message.trim() !== '' || startsInMinutes !== null);
|
||||
|
||||
const send = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!message.trim()) return;
|
||||
if (!confirm(`Broadcast this to every connected player?\n\n"${message}"`)) return;
|
||||
if (!canSend) return;
|
||||
const parts = [
|
||||
message.trim() && `"${message.trim()}"`,
|
||||
startsInMinutes !== null &&
|
||||
(startsInMinutes === 0 ? 'Cancel the maintenance countdown' : `Maintenance countdown: ${startsInMinutes} min`),
|
||||
].filter(Boolean);
|
||||
if (!confirm(`Broadcast this to every connected player?\n\n${parts.join('\n')}`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api('/broadcast', { method: 'POST', body: { Message: message } });
|
||||
toast.push('Broadcast sent', 'success');
|
||||
const res = await api<{ recipients: number }>('/broadcast', {
|
||||
method: 'POST',
|
||||
body: { Message: message.trim() || null, StartsInMinutes: startsInMinutes },
|
||||
});
|
||||
toast.push(`Broadcast sent to ${res.recipients} player(s)`, 'success');
|
||||
setMessage('');
|
||||
setMinutes('');
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
@@ -28,7 +42,7 @@ export function Broadcast() {
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Broadcast"
|
||||
blurb="Push a server-maintenance notification to every connected player. The watch shows it as a system toast."
|
||||
blurb="Message every connected player. The message lands in their in-game notifications from the system account; the optional countdown starts the game's own maintenance timer."
|
||||
/>
|
||||
|
||||
<form onSubmit={send} className="card !p-5 max-w-2xl">
|
||||
@@ -39,14 +53,33 @@ export function Broadcast() {
|
||||
rows={5}
|
||||
className="input mt-1.5 font-mono text-sm"
|
||||
placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save."
|
||||
required
|
||||
/>
|
||||
<div className="mt-2 text-xs text-ink-400">
|
||||
{message.length} characters
|
||||
</div>
|
||||
|
||||
<label className="label mt-4">Maintenance countdown (minutes, optional)</label>
|
||||
<input
|
||||
value={minutes}
|
||||
onChange={e => setMinutes(e.target.value)}
|
||||
inputMode="numeric"
|
||||
className="input mt-1.5 w-40 text-sm"
|
||||
placeholder="e.g. 5"
|
||||
/>
|
||||
<div className="mt-2 text-xs text-ink-400">
|
||||
{minutesValid ? 'Leave empty for no countdown; 0 cancels a running one.' : 'Enter a whole number of minutes.'}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setMessage('')} className="btn-ghost text-xs" disabled={busy || !message}>Clear</button>
|
||||
<button className="btn-primary text-xs" disabled={busy || !message.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMessage(''); setMinutes(''); }}
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy || (!message && !minutes)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button className="btn-primary text-xs" disabled={!canSend}>
|
||||
{busy ? 'Sending…' : 'Send broadcast'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { notifyAuthChange, setSession } from '../lib/auth';
|
||||
import { adminApex } from '../lib/types';
|
||||
import { BrandMark } from '../components/BrandMark';
|
||||
|
||||
interface LoginResponse {
|
||||
@@ -12,14 +13,82 @@ interface LoginResponse {
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
// The website, where an identity from the sign-in provider is linked to an
|
||||
// account (the admin panel only signs in identities that are linked already).
|
||||
function siteUrl(path: string): string {
|
||||
const apex = adminApex();
|
||||
return `${apex === 'localhost' ? 'http' : 'https'}://${apex}${path}`;
|
||||
}
|
||||
|
||||
const SSO_ERRORS: Record<string, React.ReactNode> = {
|
||||
not_admin: 'That account is not an admin.',
|
||||
not_signed_in: "Single sign-on didn't finish. Try again.",
|
||||
account_unavailable: 'The account linked to that sign-in is unavailable (deleted or banned).',
|
||||
no_identity: "The sign-in provider didn't return an identity. Try again.",
|
||||
provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.',
|
||||
};
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<React.ReactNode | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Single sign-on through the server's OpenID Connect provider, if set up.
|
||||
const [sso, setSso] = useState<{ provider: string } | null>(null);
|
||||
const ssoHandled = useRef(false);
|
||||
|
||||
const finish = (res: LoginResponse, to: string) => {
|
||||
setSession(res.access_token, {
|
||||
id: res.account_id,
|
||||
username: res.username,
|
||||
displayName: res.display_name,
|
||||
});
|
||||
notifyAuthChange();
|
||||
navigate(to, { replace: true });
|
||||
};
|
||||
|
||||
const errorText = (e: unknown, codes: Record<string, React.ReactNode>): React.ReactNode => {
|
||||
if (e instanceof ApiError && e.body && typeof e.body === 'object' && 'error' in e.body) {
|
||||
const code = (e.body as { error: string }).error;
|
||||
return Object.hasOwn(codes, code) ? codes[code] : e.message;
|
||||
}
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/site/v1/account/me', { cache: 'no-store' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((me) => { if (me?.signInAvailable) setSso({ provider: me.provider }); })
|
||||
.catch(() => { /* no single sign-on: password only */ });
|
||||
}, []);
|
||||
|
||||
// Back from the provider: ?sso=1 swaps the session for the admin token;
|
||||
// ?signin_error=… explains why it didn't work.
|
||||
useEffect(() => {
|
||||
if (ssoHandled.current) return;
|
||||
const signinError = params.get('signin_error');
|
||||
if (signinError) {
|
||||
ssoHandled.current = true;
|
||||
setErr(signinError === 'not_linked'
|
||||
? <>This sign-in isn't linked to a DorkNet account yet. Link it on the <a className="underline" href={siteUrl('/api/auth/external/login?returnUrl=%2Faccount')}>website</a> first, then sign in here again.</>
|
||||
// Only known codes are shown: the query string is attacker-controllable.
|
||||
: Object.hasOwn(SSO_ERRORS, signinError) ? SSO_ERRORS[signinError] : 'Single sign-on failed. Try again.');
|
||||
setParams({}, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (params.get('sso') !== '1') return;
|
||||
ssoHandled.current = true;
|
||||
setLoading(true);
|
||||
api<LoginResponse>('/sso')
|
||||
.then((res) => finish(res, params.get('to') || '/'))
|
||||
.catch((e) => { setErr(errorText(e, SSO_ERRORS)); setParams({}, { replace: true }); })
|
||||
.finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -30,27 +99,19 @@ export function Login() {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
});
|
||||
setSession(res.access_token, {
|
||||
id: res.account_id,
|
||||
username: res.username,
|
||||
displayName: res.display_name,
|
||||
});
|
||||
notifyAuthChange();
|
||||
navigate(from, { replace: true });
|
||||
finish(res, from);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.body && typeof e.body === 'object' && 'error' in e.body) {
|
||||
const code = (e.body as { error: string }).error;
|
||||
if (code === 'not_admin') setErr('That account is not an admin.');
|
||||
else if (code === 'invalid_credentials') setErr('Invalid username or password.');
|
||||
else setErr(e.message);
|
||||
} else setErr(e.message);
|
||||
} else setErr(String(e));
|
||||
setErr(errorText(e, {
|
||||
not_admin: 'That account is not an admin.',
|
||||
invalid_credentials: 'Invalid username or password.',
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ssoHref = `/api/auth/external/login?returnUrl=${encodeURIComponent(`/login?sso=1&to=${encodeURIComponent(from)}`)}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center px-4 py-12 bg-[linear-gradient(135deg,#0a0b0e_0%,#0d1d25_48%,#131418_100%)]">
|
||||
<div className="w-full max-w-sm">
|
||||
@@ -92,6 +153,14 @@ export function Login() {
|
||||
<button type="submit" disabled={loading} className="btn-primary mt-1">
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
{sso && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wide text-ink-500">
|
||||
<span className="h-px flex-1 bg-ink-800" />or<span className="h-px flex-1 bg-ink-800" />
|
||||
</div>
|
||||
<a href={ssoHref} className="btn-secondary text-center">Sign in with {sso.provider}</a>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-[11px] text-ink-500">DorkNet private server · admin console</p>
|
||||
|
||||
Generated
+98
@@ -8,6 +8,11 @@
|
||||
"name": "dorknet-site",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@yume-chan/adb": "2.6.4",
|
||||
"@yume-chan/adb-credential-web": "2.1.0",
|
||||
"@yume-chan/adb-daemon-webusb": "2.3.2",
|
||||
"@yume-chan/android-bin": "2.1.0",
|
||||
"@yume-chan/stream-extra": "2.6.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router": "^8.3.0"
|
||||
@@ -1350,6 +1355,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/w3c-web-usb": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz",
|
||||
"integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||
@@ -1371,6 +1382,93 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@yume-chan/adb": {
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@yume-chan/adb/-/adb-2.6.4.tgz",
|
||||
"integrity": "sha512-aaN21ZOLjWW1nC09C21f0yQkCqxkuMWMqheryGEbmGLPhuzIyOhTurJK8cXh5ZnQ+ptdKBexqvoG6zYoul9XPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yume-chan/async": "^4.1.3",
|
||||
"@yume-chan/event": "^2.0.0",
|
||||
"@yume-chan/no-data-view": "^2.0.0",
|
||||
"@yume-chan/stream-extra": "^2.6.1",
|
||||
"@yume-chan/struct": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@yume-chan/adb-credential-web": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@yume-chan/adb-credential-web/-/adb-credential-web-2.1.0.tgz",
|
||||
"integrity": "sha512-ps5XWt+xxm6Mi9eF80ePprPJ/uRFMhfR437betQE8hixrjdZpkZgMlqZU6GlnuebMi0djrbP8GWB49jJekJVYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yume-chan/adb": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@yume-chan/adb-daemon-webusb": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yume-chan/adb-daemon-webusb/-/adb-daemon-webusb-2.3.2.tgz",
|
||||
"integrity": "sha512-5ANaqsRJWFc5kKMnGALjYCrSXo5eC4u51dvYppvuBCLVjv12n7he2TPy5yEZ3XtlToRFS0kWCQC0XS8M60lRSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/w3c-web-usb": "^1.0.12",
|
||||
"@yume-chan/adb": "^2.3.1",
|
||||
"@yume-chan/event": "^2.0.0",
|
||||
"@yume-chan/stream-extra": "^2.1.0",
|
||||
"@yume-chan/struct": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@yume-chan/android-bin": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@yume-chan/android-bin/-/android-bin-2.1.0.tgz",
|
||||
"integrity": "sha512-is97IQwmSTmGCLe8GKO3rRDib2is6Xumo9KzeArgHwizIF0XnpCUm/xIVJLnpifR9PlMyLldDUttTZMTnaNivg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yume-chan/adb": "^2.1.0",
|
||||
"@yume-chan/stream-extra": "^2.1.0",
|
||||
"@yume-chan/struct": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@yume-chan/async": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@yume-chan/async/-/async-4.1.3.tgz",
|
||||
"integrity": "sha512-0vzhNJMkWUPyjKzUK4rqHEeCU6YQtF78RsB1kFRB6Y2BLupmEQNxcSb0mjKabPL9jZpCCiLa5KL8oTOJClUVaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@yume-chan/event": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@yume-chan/event/-/event-2.0.0.tgz",
|
||||
"integrity": "sha512-z56MDOcX1QlgLUCuA6th3r10negVb7A3gzY//TwSC9ZOvzuRlrAqXcxZf1T3hHfNMk/NFO9RIgQgegXYSfaqLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yume-chan/async": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@yume-chan/no-data-view": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@yume-chan/no-data-view/-/no-data-view-2.0.0.tgz",
|
||||
"integrity": "sha512-0GRJrrt6wtZlbiE92jocHOnaAvjQ+Y7xwwhwOPqLkwf90Kj1JIHJ5Zh4wJVQSQIkzfRSOpM+jeEQdC2K15snlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@yume-chan/stream-extra": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@yume-chan/stream-extra/-/stream-extra-2.6.1.tgz",
|
||||
"integrity": "sha512-GQ+Q0SQf9p6Fl7ZK0UO5vHVGBJgkTJm/WXKurUMpjHQV4VUpjZ2hrQ744aCfT7WwSol7Bz/uEiXWmk240Hr/hg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yume-chan/async": "^4.1.3",
|
||||
"@yume-chan/struct": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@yume-chan/struct": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yume-chan/struct/-/struct-2.3.2.tgz",
|
||||
"integrity": "sha512-afoCnSKV+5HRK7e4innVd9YTYDyNWdjA1CVQa1j8rWYnmr7HGZfdkHMQr+AESLk6GxWFMzOIPQIG6nYOTGMFIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yume-chan/async": "^4.1.3",
|
||||
"@yume-chan/no-data-view": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@yume-chan/adb": "2.6.4",
|
||||
"@yume-chan/adb-credential-web": "2.1.0",
|
||||
"@yume-chan/adb-daemon-webusb": "2.3.2",
|
||||
"@yume-chan/android-bin": "2.1.0",
|
||||
"@yume-chan/stream-extra": "2.6.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router": "^8.3.0"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import { Header } from './components/Header';
|
||||
import { Home } from './pages/Home';
|
||||
@@ -7,8 +8,14 @@ import { Players } from './pages/Players';
|
||||
import { PlayerProfile } from './pages/PlayerProfile';
|
||||
import { Rooms } from './pages/Rooms';
|
||||
import { Join } from './pages/Join';
|
||||
import { Downloads } from './pages/Downloads';
|
||||
import { Link as LinkAccount } from './pages/Link';
|
||||
import { Account } from './pages/Account';
|
||||
import { NotFound } from './pages/NotFound';
|
||||
|
||||
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
|
||||
const Quest = lazy(() => import('./pages/Quest').then((m) => ({ default: m.Quest })));
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
@@ -22,6 +29,10 @@ export function App() {
|
||||
<Route path="/players/:id" element={<PlayerProfile />} />
|
||||
<Route path="/rooms" element={<Rooms />} />
|
||||
<Route path="/join" element={<Join />} />
|
||||
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
|
||||
<Route path="/downloads" element={<Downloads />} />
|
||||
<Route path="/link" element={<LinkAccount />} />
|
||||
<Route path="/account" element={<Account />} />
|
||||
{/* Legacy paths from the vanilla-JS feed.localhost site */}
|
||||
<Route path="/p/:id" element={<RedirectPhoto />} />
|
||||
<Route path="/u/:id" element={<RedirectPlayer />} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NavLink, Link, useLocation, useNavigate } from 'react-router';
|
||||
import { NavLink, Link, useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BrandMark } from './BrandMark';
|
||||
import { getDownloads, SignInRequiredError } from '../lib/downloads';
|
||||
import { errorCopy, getMe, signInUrl, signOut, SIGNIN_ERRORS, type AccountMe } from '../lib/account';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: 'Home', exact: true },
|
||||
@@ -8,7 +10,10 @@ const NAV = [
|
||||
{ to: '/players', label: 'Players' },
|
||||
{ to: '/rooms', label: 'Rooms' },
|
||||
{ to: '/join', label: 'Join' },
|
||||
{ to: '/quest', label: 'Quest' },
|
||||
];
|
||||
// Shown only when the server offers downloads (DorkNet:Downloads:BaseUrl).
|
||||
const DOWNLOADS = { to: '/downloads', label: 'Downloads', exact: false };
|
||||
|
||||
export function Header() {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -16,6 +21,39 @@ export function Header() {
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [hasDownloads, setHasDownloads] = useState(false);
|
||||
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
|
||||
|
||||
const [me, setMe] = useState<AccountMe | null>(null);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const signinError = params.get('signin_error');
|
||||
|
||||
useEffect(() => {
|
||||
getDownloads().then((m) => setHasDownloads(m !== null))
|
||||
.catch((e) => setHasDownloads(e instanceof SignInRequiredError));
|
||||
getMe().then(setMe).catch(() => { /* site works without sign-in */ });
|
||||
}, []);
|
||||
|
||||
const [signOutError, setSignOutError] = useState<string | null>(null);
|
||||
const doSignOut = async () => {
|
||||
setSignOutError(null);
|
||||
try {
|
||||
await signOut();
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
setSignOutError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// Sign in / account, shown only when the server has a sign-in provider.
|
||||
const account = !me?.signInAvailable ? null : me.signedIn && me.player ? (
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<Link to="/account" className="text-ink-200 hover:text-ink-50">{me.player.username}</Link>
|
||||
<button type="button" onClick={doSignOut} className="btn-ghost !px-2 !py-1 text-xs">Sign out</button>
|
||||
</span>
|
||||
) : (
|
||||
<a href={signInUrl()} className="btn-secondary !px-3 !py-1.5 text-sm">Sign in</a>
|
||||
);
|
||||
|
||||
// Close the mobile drawer on route change so tapping a nav item
|
||||
// doesn't leave a half-open overlay covering the page.
|
||||
@@ -36,7 +74,7 @@ export function Header() {
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1 ml-2">
|
||||
{NAV.map(n => (
|
||||
{navItems.map(n => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
@@ -62,6 +100,8 @@ export function Header() {
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="hidden md:flex shrink-0">{account}</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
@@ -84,7 +124,7 @@ export function Header() {
|
||||
/>
|
||||
</form>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{NAV.map(n => (
|
||||
{navItems.map(n => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
@@ -97,6 +137,25 @@ export function Header() {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
{account && <div className="pt-1">{account}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signOutError && (
|
||||
<div className="border-t border-danger/30 bg-danger/10">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-2 flex items-center justify-between gap-3 text-sm text-danger">
|
||||
<span>Couldn't sign out ({signOutError}). You're still signed in; try again.</span>
|
||||
<button type="button" className="btn-ghost !px-2 !py-1 text-xs" onClick={() => setSignOutError(null)}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{signinError && (
|
||||
<div className="border-t border-danger/30 bg-danger/10">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-2 flex items-center justify-between gap-3 text-sm text-danger">
|
||||
<span>Sign-in didn't work: {errorCopy(SIGNIN_ERRORS, signinError, 'something went wrong. Try again.')}</span>
|
||||
<button type="button" className="btn-ghost !px-2 !py-1 text-xs" onClick={() => { params.delete('signin_error'); setParams(params, { replace: true }); }}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Website sign-in through the server's OpenID Connect provider
|
||||
// (ExternalSignInController). The session is an HttpOnly cookie on this host,
|
||||
// so there is no token to keep here: same-origin fetches carry it.
|
||||
import { get, post } from './api';
|
||||
|
||||
export interface AccountMe {
|
||||
signInAvailable: boolean;
|
||||
provider: string;
|
||||
signedIn: boolean;
|
||||
player: { id: string; username: string; email: string } | null;
|
||||
pendingLink: { email: string } | null;
|
||||
}
|
||||
|
||||
let cached: Promise<AccountMe> | null = null;
|
||||
|
||||
/** Who is signed in. Fetched once per page load; `fresh` re-reads it. */
|
||||
export function getMe(fresh = false): Promise<AccountMe> {
|
||||
if (!cached || fresh) cached = get<AccountMe>('/account/me').catch((e) => { cached = null; throw e; });
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** `url` if it is a path on this site, else `/` (the server applies the same
|
||||
* rule): never navigate to an address taken from the query string otherwise. */
|
||||
export function localPath(url: string | null | undefined): string {
|
||||
if (!url || !url.startsWith('/')) return '/';
|
||||
// Parse it the way the browser will (it drops tabs and newlines and reads
|
||||
// \ as /) and keep it only if it stays on this origin.
|
||||
try {
|
||||
const u = new URL(url, window.location.origin);
|
||||
return u.origin === window.location.origin ? u.pathname + u.search + u.hash : '/';
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts sign-in at the provider; comes back to `returnUrl` on this site. */
|
||||
export function signInUrl(returnUrl = window.location.pathname + window.location.search): string {
|
||||
return `/api/auth/external/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
}
|
||||
|
||||
// Each of these changes who is signed in, so the cached answer is dropped.
|
||||
async function changesAccount<T>(request: Promise<T>): Promise<T> {
|
||||
const result = await request;
|
||||
cached = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
export const link = (username: string, password: string) =>
|
||||
changesAccount(post<{ linked: boolean; username: string }>('/account/link', { username, password }));
|
||||
export const signOut = () => changesAccount(post('/account/logout', {}));
|
||||
export const unlink = () => changesAccount(post('/account/unlink', {}));
|
||||
|
||||
// Server error code -> friendly copy.
|
||||
export const LINK_ERRORS: Record<string, string> = {
|
||||
invalid_credentials: "That username and password don't match a DorkNet account.",
|
||||
missing_credentials: 'Enter your DorkNet username and password.',
|
||||
too_many_attempts: 'Too many wrong attempts. Wait 15 minutes and try again.',
|
||||
banned: 'That account is banned.',
|
||||
identity_linked_to_another_account: 'This sign-in is already linked to a different DorkNet account.',
|
||||
no_pending_identity: 'Your sign-in expired. Start again with "Sign in".',
|
||||
};
|
||||
|
||||
/** Friendly copy for a known error code, else `fallback`. For codes read from
|
||||
* the query string, pass a fixed fallback: anyone can put text in a link. */
|
||||
export function errorCopy(map: Record<string, string>, code: string | null | undefined, fallback: string): string {
|
||||
return code && Object.hasOwn(map, code) ? map[code] : fallback;
|
||||
}
|
||||
|
||||
export const SIGNIN_ERRORS: Record<string, string> = {
|
||||
account_unavailable: 'The account linked to this sign-in is unavailable (deleted or banned).',
|
||||
no_identity: "The sign-in provider didn't return an identity. Try again.",
|
||||
provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.',
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Lightweight fetch wrapper for the public-facing site. Talks to the
|
||||
// apex-hosted PublicSiteController at /api/site/v1/*. Anonymous-only —
|
||||
// the public site has no login flow, so there's no Authorization
|
||||
// header or token plumbing.
|
||||
// apex-hosted PublicSiteController at /api/site/v1/*. No Authorization
|
||||
// header or token plumbing: the optional sign-in (lib/account.ts) is an
|
||||
// HttpOnly cookie that same-origin fetches send on their own.
|
||||
|
||||
const BASE = '/api/site/v1';
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// The client builds CI publishes (ci/downloads/publish.py), served through
|
||||
// the server's DownloadsController. The manifest keeps the snake_case keys
|
||||
// publish.py writes.
|
||||
import { ApiError, get } from './api';
|
||||
|
||||
export interface DownloadFile {
|
||||
id: string; // pc-full | pc-patch | quest | quest-installer
|
||||
platform: 'pc' | 'quest' | string;
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
build?: string;
|
||||
commit?: string;
|
||||
built_at?: string;
|
||||
}
|
||||
|
||||
export interface DownloadRelease {
|
||||
id: string;
|
||||
title: string;
|
||||
game_version?: string;
|
||||
pc_exe?: string;
|
||||
files: DownloadFile[];
|
||||
}
|
||||
|
||||
export interface DownloadsManifest {
|
||||
releases: DownloadRelease[];
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
/** The server only serves downloads to signed-in players
|
||||
* (DorkNet:Downloads:RequireSignIn) and nobody is signed in. */
|
||||
export class SignInRequiredError extends Error {
|
||||
constructor() { super('sign_in_required'); this.name = 'SignInRequiredError'; }
|
||||
}
|
||||
|
||||
let cached: Promise<DownloadsManifest | null> | null = null;
|
||||
|
||||
/** The manifest, or null when this server doesn't offer downloads. Fetched
|
||||
* once per page load; pass `fresh` to re-read it. */
|
||||
export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
|
||||
if (!cached || fresh) {
|
||||
cached = get<DownloadsManifest>('/downloads').catch((e) => {
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
if (e instanceof ApiError && e.status === 401) { cached = null; throw new SignInRequiredError(); }
|
||||
cached = null;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export const fileUrl = (f: DownloadFile) =>
|
||||
`/api/site/v1/downloads/files/${f.path.split('/').map(encodeURIComponent).join('/')}`;
|
||||
|
||||
export interface QuestBuild { release: DownloadRelease; file: DownloadFile }
|
||||
|
||||
/** A published Quest package: the one at `path` when given, otherwise the
|
||||
* newest release's. Null when there's no match. */
|
||||
export function questBuild(m: DownloadsManifest | null, path?: string | null): QuestBuild | null {
|
||||
for (const release of m?.releases ?? []) {
|
||||
const file = release.files.find((f) => f.id === 'quest' && (!path || f.path === path));
|
||||
if (file) return { release, file };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Install a DorkNet .xapk onto a Meta Quest from the browser over WebUSB, using
|
||||
// Tango (@yume-chan/adb). Mirrors DorkNet.Quest/scripts/installer/install.ps1:
|
||||
// install the APK, copy each OBB to its install path (skipped when an identical
|
||||
// size is already there), then launch.
|
||||
import { Adb, AdbDaemonTransport } from '@yume-chan/adb';
|
||||
import { AdbDaemonWebUsbDevice, AdbDaemonWebUsbDeviceManager } from '@yume-chan/adb-daemon-webusb';
|
||||
import AdbWebCredentialStore from '@yume-chan/adb-credential-web';
|
||||
import { PackageManager } from '@yume-chan/android-bin';
|
||||
import type { ReadableStream as TangoReadableStream } from '@yume-chan/stream-extra';
|
||||
import { entryStream, type Xapk } from './xapk';
|
||||
|
||||
export const webUsbSupported = () => typeof navigator !== 'undefined' && 'usb' in navigator && !!AdbDaemonWebUsbDeviceManager.BROWSER;
|
||||
|
||||
export interface Headset {
|
||||
adb: Adb;
|
||||
model: string;
|
||||
serial: string;
|
||||
}
|
||||
|
||||
export type Phase = 'app' | 'data' | 'launch';
|
||||
|
||||
export interface Progress {
|
||||
phase: Phase;
|
||||
label: string;
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const credentialStore = new AdbWebCredentialStore('DorkNet Quest installer');
|
||||
|
||||
export async function connectHeadset(): Promise<Headset> {
|
||||
const manager = AdbDaemonWebUsbDeviceManager.BROWSER;
|
||||
if (!manager) throw new Error('This browser does not support WebUSB. Use Chrome or Edge on a computer.');
|
||||
const device = await manager.requestDevice();
|
||||
if (!device) throw new Error('No headset selected.');
|
||||
|
||||
let connection;
|
||||
try {
|
||||
connection = await device.connect();
|
||||
} catch (e) {
|
||||
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
|
||||
throw new Error('The headset is in use by another program. Close SideQuest, Meta Quest Developer Hub or adb (run "adb kill-server"), then try again.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const transport = await AdbDaemonTransport.authenticate({
|
||||
serial: device.serial,
|
||||
connection,
|
||||
credentialStore,
|
||||
});
|
||||
const adb = new Adb(transport);
|
||||
const model = (await adb.getProp('ro.product.model')).trim() || device.name;
|
||||
return { adb, model, serial: device.serial };
|
||||
}
|
||||
|
||||
function counted(stream: ReadableStream<Uint8Array>, onBytes: (n: number) => void): TangoReadableStream<Uint8Array> {
|
||||
const counter = new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
onBytes(chunk.byteLength);
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
return stream.pipeThrough(counter) as unknown as TangoReadableStream<Uint8Array>;
|
||||
}
|
||||
|
||||
async function remoteSize(adb: Adb, path: string): Promise<number | null> {
|
||||
const sync = await adb.sync();
|
||||
try {
|
||||
const st = await sync.lstat(path);
|
||||
return Number(st.size);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await sync.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the package. `confirmUninstall` is asked before removing an existing
|
||||
* copy signed with a different key (that clears its local data).
|
||||
*/
|
||||
export async function installXapk(
|
||||
headset: Headset,
|
||||
xapk: Xapk,
|
||||
onProgress: (p: Progress) => void,
|
||||
confirmUninstall: (pkg: string) => Promise<boolean>,
|
||||
): Promise<void> {
|
||||
const { adb } = headset;
|
||||
const pkg = xapk.manifest.package_name;
|
||||
const pm = new PackageManager(adb);
|
||||
|
||||
// ---- APK ----
|
||||
const installApk = async () => {
|
||||
let done = 0;
|
||||
const total = xapk.apk.size;
|
||||
onProgress({ phase: 'app', label: 'Installing the app', done, total });
|
||||
const stream = counted(await entryStream(xapk.source, xapk.apk), (n) => {
|
||||
done += n;
|
||||
onProgress({ phase: 'app', label: 'Installing the app', done, total });
|
||||
});
|
||||
await pm.installStream(total, stream);
|
||||
};
|
||||
try {
|
||||
await installApk();
|
||||
} catch (e) {
|
||||
const msg = String((e as Error)?.message ?? e);
|
||||
if (!/INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match/i.test(msg)) throw e;
|
||||
if (!(await confirmUninstall(pkg))) throw new Error('Cancelled.');
|
||||
await pm.uninstall(pkg);
|
||||
await installApk();
|
||||
}
|
||||
|
||||
// ---- game data (OBB) ----
|
||||
for (const { entry, installPath } of xapk.expansions) {
|
||||
const dest = `/sdcard/${installPath}`;
|
||||
const total = entry.size;
|
||||
if ((await remoteSize(adb, dest)) === total) {
|
||||
onProgress({ phase: 'data', label: 'Game data already on the headset', done: total, total });
|
||||
continue;
|
||||
}
|
||||
const dir = dest.substring(0, dest.lastIndexOf('/'));
|
||||
await adb.subprocess.noneProtocol.spawnWaitText(['mkdir', '-p', dir]);
|
||||
|
||||
let done = 0;
|
||||
onProgress({ phase: 'data', label: 'Copying game data', done, total });
|
||||
const stream = counted(await entryStream(xapk.source, entry), (n) => {
|
||||
done += n;
|
||||
onProgress({ phase: 'data', label: 'Copying game data', done, total });
|
||||
});
|
||||
const sync = await adb.sync();
|
||||
try {
|
||||
await sync.write({ filename: dest, file: stream, permission: 0o660 });
|
||||
} finally {
|
||||
await sync.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- launch ----
|
||||
onProgress({ phase: 'launch', label: 'Starting the game', done: 1, total: 1 });
|
||||
await adb.subprocess.noneProtocol.spawnWaitText(['monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Read a DorkNet .xapk (zip) without loading it into memory, either from a
|
||||
// File the player picked or from a URL that answers HTTP Range requests (the
|
||||
// server's latest published build). DorkNet.Quest/scripts/build.py stores the
|
||||
// APK and the OBB uncompressed, so those entries are plain byte ranges that
|
||||
// stream straight through; manifest.json is deflated and goes through
|
||||
// DecompressionStream. Handles zip64 so packages over 4 GB still open.
|
||||
|
||||
/** Random access to the package's bytes; `end` is exclusive. */
|
||||
export interface ByteSource {
|
||||
name: string;
|
||||
size: number;
|
||||
read(start: number, end: number): Promise<ArrayBuffer>;
|
||||
stream(start: number, end: number): Promise<ReadableStream<Uint8Array<ArrayBuffer>>>;
|
||||
}
|
||||
|
||||
export function fileSource(file: File): ByteSource {
|
||||
return {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
read: (start, end) => file.slice(start, end).arrayBuffer(),
|
||||
stream: async (start, end) => file.slice(start, end).stream(),
|
||||
};
|
||||
}
|
||||
|
||||
/** A package on the server. `size` comes from the downloads manifest; every
|
||||
* read is one Range request. */
|
||||
export function urlSource(url: string, name: string, size: number): ByteSource {
|
||||
const get = async (start: number, end: number): Promise<Response> => {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { headers: { Range: `bytes=${start}-${end - 1}` }, cache: 'no-store' });
|
||||
} catch {
|
||||
// A cross-origin redirect (an expired sign-in at an access proxy) also lands here.
|
||||
throw new Error("Couldn't reach the server. Reload the page (you may need to sign in again) and try again.");
|
||||
}
|
||||
if (res.status !== 206) {
|
||||
throw new Error(res.status === 200
|
||||
? "The server doesn't support partial downloads, so the package can't be read in place."
|
||||
: `Downloading the package failed (HTTP ${res.status}).`);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
const empty = () => new ReadableStream<Uint8Array<ArrayBuffer>>({ start: (c) => c.close() });
|
||||
return {
|
||||
name,
|
||||
size,
|
||||
read: async (start, end) => (end <= start ? new ArrayBuffer(0) : (await get(start, end)).arrayBuffer()),
|
||||
stream: async (start, end) => {
|
||||
if (end <= start) return empty();
|
||||
const body = (await get(start, end)).body;
|
||||
if (!body) throw new Error('The server sent an empty response.');
|
||||
return body;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface XapkEntry {
|
||||
name: string;
|
||||
method: number; // 0 = stored, 8 = deflate
|
||||
compressedSize: number;
|
||||
size: number;
|
||||
localHeaderOffset: number;
|
||||
}
|
||||
|
||||
export interface XapkExpansion {
|
||||
file: string;
|
||||
install_path: string;
|
||||
}
|
||||
|
||||
export interface XapkManifest {
|
||||
package_name: string;
|
||||
name?: string;
|
||||
version_code?: string;
|
||||
version_name?: string;
|
||||
expansions?: XapkExpansion[];
|
||||
}
|
||||
|
||||
export interface Xapk {
|
||||
source: ByteSource;
|
||||
manifest: XapkManifest;
|
||||
entries: Map<string, XapkEntry>;
|
||||
apk: XapkEntry;
|
||||
expansions: { entry: XapkEntry; installPath: string }[];
|
||||
}
|
||||
|
||||
const EOCD = 0x06054b50;
|
||||
const ZIP64_LOCATOR = 0x07064b50;
|
||||
const ZIP64_EOCD = 0x06064b50;
|
||||
const CENTRAL = 0x02014b50;
|
||||
const LOCAL = 0x04034b50;
|
||||
|
||||
async function bytes(src: ByteSource, start: number, end: number): Promise<DataView> {
|
||||
return new DataView(await src.read(start, end));
|
||||
}
|
||||
|
||||
function u64(v: DataView, at: number): number {
|
||||
return Number(v.getBigUint64(at, true));
|
||||
}
|
||||
|
||||
export async function openXapk(src: ByteSource): Promise<Xapk> {
|
||||
// End of central directory: last 22 bytes plus up to 64 KiB of comment.
|
||||
const tailStart = Math.max(0, src.size - (22 + 0xffff + 20));
|
||||
const tail = await bytes(src, tailStart, src.size);
|
||||
let eocd = -1;
|
||||
for (let i = tail.byteLength - 22; i >= 0; i--) {
|
||||
if (tail.getUint32(i, true) === EOCD) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) throw new Error('This is not a zip/.xapk file.');
|
||||
|
||||
let count = tail.getUint16(eocd + 10, true);
|
||||
let cdSize = tail.getUint32(eocd + 12, true);
|
||||
let cdOffset = tail.getUint32(eocd + 16, true);
|
||||
|
||||
if (cdOffset === 0xffffffff || count === 0xffff || cdSize === 0xffffffff) {
|
||||
const loc = eocd - 20;
|
||||
if (loc < 0 || tail.getUint32(loc, true) !== ZIP64_LOCATOR) throw new Error('Unsupported zip64 layout.');
|
||||
const z64At = u64(tail, loc + 8);
|
||||
const z64 = await bytes(src, z64At, z64At + 56);
|
||||
if (z64.getUint32(0, true) !== ZIP64_EOCD) throw new Error('Unsupported zip64 layout.');
|
||||
count = u64(z64, 32);
|
||||
cdSize = u64(z64, 40);
|
||||
cdOffset = u64(z64, 48);
|
||||
}
|
||||
|
||||
const cd = await bytes(src, cdOffset, cdOffset + cdSize);
|
||||
const entries = new Map<string, XapkEntry>();
|
||||
const decoder = new TextDecoder();
|
||||
let p = 0;
|
||||
for (let n = 0; n < count; n++) {
|
||||
if (cd.getUint32(p, true) !== CENTRAL) throw new Error('Corrupt zip directory.');
|
||||
const method = cd.getUint16(p + 10, true);
|
||||
let compressedSize = cd.getUint32(p + 20, true);
|
||||
let size = cd.getUint32(p + 24, true);
|
||||
const nameLen = cd.getUint16(p + 28, true);
|
||||
const extraLen = cd.getUint16(p + 30, true);
|
||||
const commentLen = cd.getUint16(p + 32, true);
|
||||
let localHeaderOffset = cd.getUint32(p + 42, true);
|
||||
const name = decoder.decode(new Uint8Array(cd.buffer, cd.byteOffset + p + 46, nameLen));
|
||||
|
||||
// zip64 extended info: present fields appear in this order, only for
|
||||
// values saturated in the fixed header.
|
||||
let e = p + 46 + nameLen;
|
||||
const extraEnd = e + extraLen;
|
||||
while (e + 4 <= extraEnd) {
|
||||
const id = cd.getUint16(e, true);
|
||||
const len = cd.getUint16(e + 2, true);
|
||||
if (id === 0x0001) {
|
||||
let q = e + 4;
|
||||
if (size === 0xffffffff) { size = u64(cd, q); q += 8; }
|
||||
if (compressedSize === 0xffffffff) { compressedSize = u64(cd, q); q += 8; }
|
||||
if (localHeaderOffset === 0xffffffff) { localHeaderOffset = u64(cd, q); }
|
||||
}
|
||||
e += 4 + len;
|
||||
}
|
||||
|
||||
entries.set(name, { name, method, compressedSize, size, localHeaderOffset });
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
|
||||
const manifestEntry = entries.get('manifest.json');
|
||||
if (!manifestEntry) throw new Error('Not an .xapk: manifest.json is missing.');
|
||||
const manifest = JSON.parse(await new Response(await entryStream(src, manifestEntry)).text()) as XapkManifest;
|
||||
if (!manifest.package_name) throw new Error('manifest.json has no package_name.');
|
||||
|
||||
const apk = entries.get(`${manifest.package_name}.apk`)
|
||||
?? [...entries.values()].find((x) => x.name.endsWith('.apk'));
|
||||
if (!apk) throw new Error('The .xapk contains no APK.');
|
||||
|
||||
const expansions = (manifest.expansions ?? []).map((x) => {
|
||||
const entry = entries.get(x.file);
|
||||
if (!entry) throw new Error(`The .xapk is missing ${x.file}.`);
|
||||
return { entry, installPath: x.install_path };
|
||||
});
|
||||
|
||||
return { source: src, manifest, entries, apk, expansions };
|
||||
}
|
||||
|
||||
/** The entry's uncompressed bytes as a stream. */
|
||||
export async function entryStream(src: ByteSource, entry: XapkEntry): Promise<ReadableStream<Uint8Array<ArrayBuffer>>> {
|
||||
const local = await bytes(src, entry.localHeaderOffset, entry.localHeaderOffset + 30);
|
||||
if (local.getUint32(0, true) !== LOCAL) throw new Error(`Corrupt entry ${entry.name}.`);
|
||||
const start = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
|
||||
const raw = await src.stream(start, start + entry.compressedSize);
|
||||
if (entry.method === 0) return raw;
|
||||
if (entry.method === 8) return raw.pipeThrough(new DecompressionStream('deflate-raw'));
|
||||
throw new Error(`Unsupported compression in ${entry.name}.`);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router';
|
||||
import { getMe, signInUrl, signOut, unlink, type AccountMe } from '../lib/account';
|
||||
|
||||
// The signed-in account: which DorkNet account and provider email are
|
||||
// linked, plus sign out and unlink.
|
||||
export function Account() {
|
||||
const [me, setMe] = useState<AccountMe | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getMe(true).then(setMe).catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await action();
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (err) return <div className="max-w-lg mx-auto rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>;
|
||||
if (!me) return <p className="max-w-lg mx-auto text-sm text-ink-400">Loading…</p>;
|
||||
|
||||
if (!me.signedIn || !me.player) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
|
||||
<h1 className="text-xl font-semibold text-ink-50">Account</h1>
|
||||
<p>You're not signed in.</p>
|
||||
{me.signInAvailable
|
||||
? <a className="btn-primary inline-block" href={signInUrl('/account')}>Sign in with {me.provider}</a>
|
||||
: <p className="text-ink-400">Sign-in isn't set up on this server.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto space-y-4">
|
||||
<h1 className="text-2xl font-semibold text-ink-50">Account</h1>
|
||||
<div className="card !p-6 space-y-3 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-ink-400">DorkNet account</span>
|
||||
<RouterLink className="text-brand-300 hover:text-brand-200" to={`/players/${me.player.id}`}>{me.player.username}</RouterLink>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-ink-400">Signed in with {me.provider}</span>
|
||||
<span className="text-ink-200">{me.player.email || '—'}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<button type="button" className="btn-secondary" disabled={busy} onClick={() => run(signOut)}>Sign out</button>
|
||||
<button type="button" className="btn-ghost text-danger" disabled={busy}
|
||||
onClick={() => { if (window.confirm(`Unlink this sign-in from ${me.player!.username}? You'll need the account's password to link it again.`)) void run(unlink); }}>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { fileUrl, getDownloads, SignInRequiredError, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
|
||||
import { signInUrl } from '../lib/account';
|
||||
|
||||
const fmtSize = (n: number) =>
|
||||
n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`;
|
||||
const fmtDate = (iso?: string) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(+d) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'pc', title: 'PC', hint: 'Windows, with or without a VR headset.' },
|
||||
{ id: 'quest', title: 'Meta Quest', hint: 'Needs developer mode on your headset and a USB cable.' },
|
||||
];
|
||||
|
||||
// What each file is for, in players' terms. Keyed by the ids publish.py writes.
|
||||
function describe(f: DownloadFile, rel: DownloadRelease): { title: string; primary?: boolean; body: React.ReactNode } {
|
||||
switch (f.id) {
|
||||
case 'pc-full':
|
||||
return {
|
||||
title: 'Full game', primary: true,
|
||||
body: <>Rec Room {rel.title} with DorkNet already set up. Unzip it anywhere and
|
||||
run {rel.pc_exe ? <code className="text-ink-200">{rel.pc_exe}</code> : 'the game'}.</>,
|
||||
};
|
||||
case 'pc-patch':
|
||||
return {
|
||||
title: 'Patch only',
|
||||
body: <>Just the DorkNet part (MelonLoader and the DorkNet mod), for a copy of the game you already have. The
|
||||
DorkNet launcher installs this for you; skip it if you got the full game.</>,
|
||||
};
|
||||
case 'quest':
|
||||
return {
|
||||
title: 'Quest app', primary: true,
|
||||
body: <>Install it straight from this site: plug in your headset and use the <Link className="text-brand-300 hover:text-brand-200" to="/quest">Quest
|
||||
page</Link> (Chrome or Edge). Or download it for SideQuest.</>,
|
||||
};
|
||||
case 'quest-installer':
|
||||
return {
|
||||
title: 'Install scripts',
|
||||
body: <>Prefer the command line? Unzip these next to the Quest app and run <code className="text-ink-200">install.cmd</code> (Windows)
|
||||
or <code className="text-ink-200">install.sh</code> (macOS / Linux). Needs adb.</>,
|
||||
};
|
||||
default:
|
||||
return { title: f.name, body: null };
|
||||
}
|
||||
}
|
||||
|
||||
function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
|
||||
const d = describe(f, rel);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(f.sha256);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* clipboard blocked: the full hash is in the tooltip */ }
|
||||
};
|
||||
return (
|
||||
<article className={`card !p-5 flex flex-col gap-3 ${d.primary ? 'border-brand-500/50 ring-2 ring-brand-500/15' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-ink-50">{d.title}</h3>
|
||||
{d.primary && <span className="badge bg-brand-500/15 text-brand-200">Start here</span>}
|
||||
</div>
|
||||
<p className="text-sm text-ink-300">{d.body}</p>
|
||||
<div className="font-mono text-xs text-ink-400 break-all">{f.name}</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-ink-400">
|
||||
<span>{fmtSize(f.size)}</span>
|
||||
{f.built_at && <span>Updated {fmtDate(f.built_at)}</span>}
|
||||
{f.build && <span>Build {f.build}</span>}
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap items-center gap-2 pt-1">
|
||||
{f.id === 'quest' ? (
|
||||
<>
|
||||
<Link className="btn-primary" to={`/quest?file=${encodeURIComponent(f.path)}`}>Install on Quest</Link>
|
||||
<a className="btn-secondary" href={fileUrl(f)} download={f.name}>Download</a>
|
||||
</>
|
||||
) : (
|
||||
<a className={d.primary ? 'btn-primary' : 'btn-secondary'} href={fileUrl(f)} download={f.name}>Download</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
title={`Copy SHA-256: ${f.sha256}`}
|
||||
className={`rounded-md border border-dashed px-2 py-1 font-mono text-xs transition-colors ${
|
||||
copied ? 'border-success text-success' : 'border-ink-700 text-ink-400 hover:text-ink-100'}`}
|
||||
>
|
||||
{copied ? 'Copied' : `SHA-256 ${f.sha256.slice(0, 12)}…`}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function Downloads() {
|
||||
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [signInNeeded, setSignInNeeded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getDownloads(true).then(setM).catch((e) => {
|
||||
if (e instanceof SignInRequiredError) setSignInNeeded(true);
|
||||
else setErr((e as Error).message);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const releases = (m?.releases ?? []).filter((r) => r.files.length);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-ink-50">Downloads</h1>
|
||||
<p className="text-sm text-ink-400">
|
||||
Rec Room for DorkNet, built from the DorkNet source by the build server. The SHA-256 next to each file
|
||||
lets you check your download.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Couldn't load the builds: {err}</div>}
|
||||
{signInNeeded && (
|
||||
<div className="card !p-5 flex flex-wrap items-center justify-between gap-3 text-sm text-ink-300">
|
||||
<span>Sign in with your DorkNet account to see and download the builds.</span>
|
||||
<a className="btn-primary" href={signInUrl('/downloads')}>Sign in</a>
|
||||
</div>
|
||||
)}
|
||||
{m === undefined && !err && !signInNeeded && <p className="text-sm text-ink-400">Loading builds…</p>}
|
||||
{m === null && <p className="text-sm text-ink-400">This server doesn't offer downloads.</p>}
|
||||
{m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
|
||||
|
||||
{releases.map((rel) => (
|
||||
<section key={rel.id} className="space-y-5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3">
|
||||
<h2 className="text-lg font-semibold text-ink-50">Rec Room {rel.title}</h2>
|
||||
{rel.game_version && <span className="text-xs text-ink-400">Game build of {fmtDate(`${rel.game_version}T12:00:00Z`)}</span>}
|
||||
</div>
|
||||
{PLATFORMS.map((p) => {
|
||||
const files = rel.files.filter((f) => f.platform === p.id);
|
||||
if (!files.length) return null;
|
||||
return (
|
||||
<div key={p.id} className="space-y-2">
|
||||
<div>
|
||||
<h3 className="label">{p.title}</h3>
|
||||
<p className="text-xs text-ink-400">{p.hint}</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{files.map((f) => <FileCard key={f.id} f={f} rel={rel} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{releases.length > 0 && (
|
||||
<p className="border-t border-ink-800 pt-4 text-xs text-ink-400">
|
||||
These builds contain Rec Room's own game files. They're for this community only: please don't re-share them.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router';
|
||||
import { errorCopy, getMe, link, LINK_ERRORS, localPath, signInUrl, type AccountMe } from '../lib/account';
|
||||
|
||||
// After a first sign-in with the provider: prove which DorkNet account this
|
||||
// identity belongs to, once, with that account's password.
|
||||
export function Link() {
|
||||
const [params] = useSearchParams();
|
||||
const returnUrl = localPath(params.get('returnUrl'));
|
||||
const navigate = useNavigate();
|
||||
const [me, setMe] = useState<AccountMe | null>(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getMe(true).then((m) => {
|
||||
// Signed in with nothing waiting to be linked: nothing to do here. (A
|
||||
// signed-in visitor can still link a new identity, e.g. a second
|
||||
// account's.)
|
||||
if (m.signedIn && !m.pendingLink) navigate(returnUrl, { replace: true });
|
||||
else setMe(m);
|
||||
}).catch((e) => setErr((e as Error).message));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await link(username.trim(), password);
|
||||
await getMe(true);
|
||||
window.location.assign(returnUrl); // the header re-reads who is signed in
|
||||
} catch (e) {
|
||||
const key = (e as Error).message;
|
||||
setErr(errorCopy(LINK_ERRORS, key, key));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (me && !me.pendingLink) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
|
||||
<h1 className="text-xl font-semibold text-ink-50">Link your account</h1>
|
||||
<p>Your sign-in expired before it was linked.</p>
|
||||
{me.signInAvailable && <a className="btn-primary inline-block" href={signInUrl(returnUrl)}>Sign in again</a>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-ink-50">Link your DorkNet account</h1>
|
||||
<p className="text-sm text-ink-400">
|
||||
You signed in{me?.pendingLink?.email ? <> as <span className="text-ink-200">{me.pendingLink.email}</span></> : null}.
|
||||
Enter your DorkNet username and password once to connect them; after that, signing in is enough.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="card !p-6 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="label" htmlFor="username">DorkNet username</label>
|
||||
<input id="username" className="input" autoComplete="username" required
|
||||
value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="label" htmlFor="password">Password</label>
|
||||
<input id="password" type="password" className="input" autoComplete="current-password" required
|
||||
value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{err}</div>}
|
||||
<button type="submit" disabled={busy} className="btn-primary w-full">{busy ? 'Linking…' : 'Link account'}</button>
|
||||
<p className="text-xs text-ink-500">
|
||||
No account yet? <RouterLink className="text-brand-300 hover:text-brand-200" to="/join">Join with a signup code</RouterLink>, then come back here.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user