mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6605f61047 | |||
| d7b1722abd | |||
| 806b160acc | |||
| 3edeaa5484 | |||
| 27314c1335 | |||
| 36c5dd5ca9 | |||
| 258183fb94 |
@@ -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_pc.py) ─────────────
|
||||
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
|
||||
- name: Build client patcher zip
|
||||
run: |
|
||||
python tools/build_pc.py patcher --version-key ${{ env.VERSION_KEY }}
|
||||
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
|
||||
|
||||
# ── Publish both as Release assets ───────────────────────────────
|
||||
- name: Upload Release assets
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# context.
|
||||
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG NODE_VERSION=22
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
# ── admin SPA build stage ──────────────────────────────────────────
|
||||
# The Vite + React admin UI under DorkNet.Server/admin-ui builds in a
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
ARG DOTNET_VERSION=9.0
|
||||
ARG NODE_VERSION=22
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine AS admin-ui
|
||||
WORKDIR /ui
|
||||
|
||||
@@ -88,36 +88,6 @@ public class RecNetAccount
|
||||
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// 2023-03-21 account reader (RecNet.Runtime CICAKEDCJLA / MJKDDBKGNOP /
|
||||
// AEGCKMKICFG) registers these keys in three casings each, e.g.
|
||||
// "DisplayEmoji"/"displayEmoji"/"displayemoji" (CICAKEDCJLA.txt:1398-1550).
|
||||
// Missing keys default on the client, which is why profile emoji,
|
||||
// banner, pronouns and identity flags never rendered for anyone.
|
||||
private string displayEmoji = string.Empty;
|
||||
private string bannerImage = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayEmoji")]
|
||||
public string DisplayEmoji
|
||||
{
|
||||
get => displayEmoji;
|
||||
set => displayEmoji = value ?? string.Empty;
|
||||
}
|
||||
|
||||
[JsonPropertyName("bannerImage")]
|
||||
public string BannerImage
|
||||
{
|
||||
get => bannerImage;
|
||||
set => bannerImage = value ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Pronoun flags enum, serialized as its Int32 value.</summary>
|
||||
[JsonPropertyName("personalPronouns")]
|
||||
public int PersonalPronouns { get; set; }
|
||||
|
||||
/// <summary>Identity flags enum, serialized as its Int32 value.</summary>
|
||||
[JsonPropertyName("identityFlags")]
|
||||
public int IdentityFlags { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -142,13 +112,6 @@ public class RecNetSelfAccount : RecNetAccount
|
||||
|
||||
[JsonPropertyName("parentAccountId")]
|
||||
public int? ParentAccountId { get; set; } = null;
|
||||
|
||||
// 2023 self-account reader key (CICAKEDCJLA.txt:1310). An absent key
|
||||
// defaults to 0, which the settings UI renders as "no username changes
|
||||
// left". DorkNet does not rate-limit username changes, so report a
|
||||
// generous allowance.
|
||||
[JsonPropertyName("availableUsernameChanges")]
|
||||
public int AvailableUsernameChanges { get; set; } = 99;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
*.sh text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
@@ -1,9 +0,0 @@
|
||||
# Operator secrets and build outputs — never commit.
|
||||
dorknet-quest.cfg
|
||||
*.keystore
|
||||
*.so
|
||||
build/
|
||||
*_generated.h
|
||||
|
||||
# The resolved per-build target tables ARE committed (targets/<ver>-<code>.json);
|
||||
# only the generated C header is ignored.
|
||||
@@ -1,234 +0,0 @@
|
||||
# DorkNet.Quest
|
||||
|
||||
Sideload-patches the **Meta Quest** build of Rec Room **2023.03.21**
|
||||
(versionName `20230317`, versionCode `2128210`) to connect to a DorkNet server.
|
||||
|
||||
It is the Quest counterpart of `DorkNet.ClientMod` (the PC MelonLoader mod) plus
|
||||
`tools/patch-client.ps1` (the PC Photon byte-patch). Android/IL2CPP has no
|
||||
MelonLoader, so everything is done in **one injected native library**
|
||||
(`libdorknet.so`): it is added as a `DT_NEEDED` entry on `libmain.so` so the
|
||||
loader maps it before Unity, then it installs arm64 inline hooks at addresses
|
||||
resolved from the build's own `dump.cs`.
|
||||
|
||||
This build reports appVersion **`20230317`** — the *same* version the PC client
|
||||
reports — so a Quest player patched with this tool shares rooms, presence and
|
||||
Photon shards with PC DorkNet players with no server-side version change.
|
||||
|
||||
> This ships **no** Rec Room code or assets. You supply your own extracted store
|
||||
> APK and OBB. It disables the client's certificate pinning and image-signature
|
||||
> checks so a self-hosted server works; do not redistribute game binaries.
|
||||
|
||||
## What it does
|
||||
|
||||
| Hook | Effect | PC equivalent |
|
||||
| --- | --- | --- |
|
||||
| `HTTPManager.SendRequest` | rewrite `<svc>.rec.net` → `<svc>.<ServerHost>`, or into a `{service}` template such as `{service}-dev.example.com` for hyphen-style hosts (the name server supplies every other URL) | `ClientMod` UriPatches |
|
||||
| Photon `AppSettings` factory | inject the operator's Realtime/Voice/Chat app ids and fixed region (`PhotonRegion`, must match the PC clients) | `tools/patch-client.ps1` |
|
||||
| `PlatformSettings.get_AppID` / `get_MobileAppID` | return the operator's Meta app id (only when set) | — |
|
||||
| `UnityTlsContext.VerifyCallback` + BouncyCastle `NotifyServerCertificate` | accept the self-hosted TLS chain | `ClientMod` TlsPatches |
|
||||
| three certificate-pin verifiers | accept the chain (pins never match a self-hosted host) | `ClientMod` DisableTLSPinning |
|
||||
| image RSA/SHA1 verify | accept Content-Signatures the server signs with its own key | PC ImageSigning |
|
||||
| hile-warning / unknown-DLL / file-hash / ToxMod callbacks | neutralised so a re-signed, hook-loaded client is not warned/quit | `ClientMod` AntiTamper/ToxMod/FileHash |
|
||||
| integrity report + quit funnels | **log only** — names any new rejection in logcat | — |
|
||||
| Oculus `ovr_*` platform calls | (no-Meta build only) steer the entitlement/login path to the client's own credential screen | — |
|
||||
|
||||
### Two builds, selected by `OculusAppId`
|
||||
|
||||
- **Set** — the *Meta build*: Meta's real entitlement + login run against the
|
||||
app you registered; the platform hooks stay disarmed. Players need a Meta
|
||||
account, and your package + signing cert must be registered with that app.
|
||||
- **Blank** — the *no-Meta-account build*: the platform path is steered past
|
||||
Meta's checks to Rec Room's own username/password screen. It authenticates
|
||||
nothing — every headset sends the placeholder platform user id `1`. DorkNet's
|
||||
server treats platform id `1` (and `0`/blank) as a non-identity and keys
|
||||
accounts by `deviceId` instead (`PlayerService.IsPlaceholderPlatformId`), so
|
||||
players are **not** merged into one account and are never offered each other's
|
||||
"remembered logins". **Never** add a server rule that accepts platform id `1`
|
||||
as an identity.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Android NDK** r27c-class (`ANDROID_NDK`, or `ndk/<ver>` under
|
||||
`ANDROID_SDK_ROOT`) — compiles the hook library against bionic.
|
||||
- **Android SDK build-tools** (`zipalign`, `apksigner`) — `ANDROID_SDK_ROOT`.
|
||||
- **apktool** `apktool.jar` (`APKTOOL_JAR`, or next to `ANDROID_SDK_ROOT`).
|
||||
- **JDK** (`keytool` for the debug keystore; apksigner needs a JRE).
|
||||
- **Python 3** with **LIEF** (`pip install lief`) — the `DT_NEEDED` injection.
|
||||
- **[Il2CppDumper](https://github.com/Perfare/Il2CppDumper)** — to produce
|
||||
`dump.cs` for the target APK (see below).
|
||||
- Your own **store APK** (`com.AgainstGravity.RecRoom`, versionCode `2128210`)
|
||||
and its matching **OBB**.
|
||||
|
||||
> On Windows with Controlled Folder Access on, the NDK linker and apksigner
|
||||
> can't write under `Documents`. `build.py` builds in `%TEMP%` by default;
|
||||
> keep the work dir outside protected folders.
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
# 1. Dump the target build (Il2CppDumper), once, into a scratch dir:
|
||||
dotnet Il2CppDumper.dll `
|
||||
<extracted>\lib\arm64-v8a\libil2cpp.so `
|
||||
<extracted>\assets\bin\Data\Managed\Metadata\global-metadata.dat `
|
||||
<scratch>\dump
|
||||
|
||||
# 2. Configure:
|
||||
copy DorkNet.Quest\dorknet-quest.cfg.example DorkNet.Quest\dorknet-quest.cfg
|
||||
# edit ServerHost, Photon app ids, (optional) OculusAppId, PackageName
|
||||
|
||||
# 3. Build the signed APK (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'
|
||||
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)
|
||||
```
|
||||
|
||||
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 |
|
||||
| --- | --- |
|
||||
| `DorkNet-20230317.xapk` | standard XAPK: `manifest.json`, the APK, the OBB at its install path, `icon.png` |
|
||||
| `install.cmd` / `install.ps1` | one-command install on Windows (double-click `install.cmd`) |
|
||||
| `install.sh` | one-command install on macOS / Linux |
|
||||
| `README.txt` | player instructions |
|
||||
|
||||
To package an APK you already built, run `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.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). 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.
|
||||
|
||||
### Rebasing onto a different build
|
||||
|
||||
Obfuscated Assembly-CSharp names are re-randomised each release but shared with
|
||||
the PC build of the same release, so the PC decompile is the oracle. For a new
|
||||
build:
|
||||
|
||||
1. Dump it with Il2CppDumper.
|
||||
2. Re-run `scripts/resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so
|
||||
--version-name <v> --version-code <c> --objdump <ndk>/llvm-objdump` and
|
||||
confirm every target resolves, is ≥ 20 bytes, and has a real frame-setup
|
||||
prologue. Adjust `targets/spec.json` signatures if a name/shape changed
|
||||
(cross-check against the PC decompile first).
|
||||
3. Commit the new `targets/<v>-<c>.json`.
|
||||
|
||||
The library refuses to arm any target whose 20-byte fingerprint does not match
|
||||
the running `libil2cpp.so`, so a table can never be applied to the wrong build.
|
||||
|
||||
## Install
|
||||
|
||||
**From 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):
|
||||
|
||||
```powershell
|
||||
.\install.cmd # Windows (or double-click it)
|
||||
./install.sh # macOS / Linux
|
||||
```
|
||||
|
||||
Both take an optional package path, `-Serial`/`--serial` for a specific headset
|
||||
and `-NoLaunch`/`--no-launch`. SideQuest and other XAPK installers can open the
|
||||
`.xapk` directly. The app shows up as `AppName` (default **DorkNet**) under
|
||||
Library > Unknown Sources.
|
||||
|
||||
By hand:
|
||||
|
||||
```sh
|
||||
PKG=net.dorknet.RecRoom # your PackageName
|
||||
VC=2128210
|
||||
adb uninstall $PKG 2>/dev/null
|
||||
adb install -r dorknet-recroom-20230317.apk
|
||||
adb shell mkdir -p /sdcard/Android/obb/$PKG
|
||||
adb push main.2128210.com.AgainstGravity.RecRoom.obb \
|
||||
/sdcard/Android/obb/$PKG/main.$VC.$PKG.obb
|
||||
```
|
||||
|
||||
The on-device OBB filename **must** be `main.<versionCode>.<package>.obb` —
|
||||
Unity rebuilds that exact path at runtime, so the source file's own name is
|
||||
irrelevant, only the destination is.
|
||||
|
||||
## Check it on the headset
|
||||
|
||||
**Wear the headset** — off-head it sleeps within seconds and never finishes
|
||||
init, which looks like a crash.
|
||||
|
||||
```sh
|
||||
adb logcat -c
|
||||
adb shell am start -n net.dorknet.RecRoom/com.unity3d.player.UnityPlayerActivity
|
||||
adb logcat -d -s DORKNET
|
||||
```
|
||||
|
||||
A healthy boot logs: `DorkNet.Quest hooks loaded`, `init: build 20230317/...`,
|
||||
every `armed <target>`, then `intercepted https://ns.rec.net/ -> https://ns.<host>/`,
|
||||
`unitytls verify: forcing 0x8 -> SUCCESS`, `[http<-] 200 ...`, and
|
||||
`photon appsettings: RT=... Voice=... Chat=...`.
|
||||
|
||||
| Symptom | Likely cause |
|
||||
| --- | --- |
|
||||
| `REFUSING <target>: ... fingerprint mismatch` | wrong APK for this target table — rebuild from the matching store APK, or rebase |
|
||||
| install fails `-124` / `NO_CERTIFICATES` | signing/align step — rebuild with `build.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` |
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `src/dorknet_quest.cpp` | all hooks + init |
|
||||
| `src/config.h` / `config.cpp` | compile-time defaults + runtime cfg loader |
|
||||
| `src/third_party/And64InlineHook/` | vendored arm64 inline hooker (MIT, © Rprop) |
|
||||
| `targets/spec.json` | target signatures (class header + method), build-independent |
|
||||
| `targets/<ver>-<code>.json` | resolved RVAs + fingerprints for one build |
|
||||
| `scripts/resolve_targets.py` | dump.cs + libil2cpp.so → target table (+ prologue check) |
|
||||
| `scripts/gen_targets_header.py` | target table → `targets_generated.h` |
|
||||
| `scripts/gen_config_header.py` | `dorknet-quest.cfg` → `config_generated.h` |
|
||||
| `scripts/inject_needed.py` | LIEF `DT_NEEDED` injection |
|
||||
| `scripts/build.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) |
|
||||
|
||||
## Credits
|
||||
|
||||
- [RecFlare](https://github.com/recflare) by djdevin. Their Quest client
|
||||
showed the way this patcher is built: one native hook library loaded
|
||||
through a `DT_NEEDED` entry on `libmain.so`, and which client checks need
|
||||
hooking. They target a newer build (20230414), so every hook target here
|
||||
was re-derived from the 20230317 build; no RecFlare code is included.
|
||||
- [And64InlineHook](https://github.com/Rprop/And64InlineHook) © Rprop (MIT) —
|
||||
vendored under `src/third_party/`.
|
||||
- [Il2CppDumper](https://github.com/Perfare/Il2CppDumper), [LIEF](https://lief.re).
|
||||
@@ -1,74 +0,0 @@
|
||||
# DorkNet.Quest build configuration.
|
||||
#
|
||||
# Copy to dorknet-quest.cfg (git-ignored) and edit. scripts/build.ps1 bakes
|
||||
# these into libdorknet.so at compile time. Omit a key to keep the built-in
|
||||
# default. This same file can be pushed to
|
||||
# /sdcard/Android/data/<PackageName>/files/dorknet-quest.cfg
|
||||
# to override on-device without a rebuild.
|
||||
|
||||
# ---- deployment ----
|
||||
|
||||
# The apex your DorkNet server is reachable at. The client's service hosts are
|
||||
# rewritten onto it: api.rec.net -> api.<ServerHost>, auth.rec.net ->
|
||||
# auth.<ServerHost>, ns.rec.net -> ns.<ServerHost>, and so on. The ns response
|
||||
# then supplies every other service URL. Your DNS must resolve each subdomain
|
||||
# (ns, api, auth, match, chat, cdn, config, ...) and the TLS cert must cover
|
||||
# them. No default -- required.
|
||||
#
|
||||
# For a server on DorkNet's hyphen host style (Domain:HostStyle = hyphen, e.g.
|
||||
# ns-dev.example.com), give a template instead: ServerHost = {service}-dev.example.com
|
||||
ServerHost = example.com
|
||||
|
||||
# Android package name for the patched build, so it coexists with the store
|
||||
# app and uses its own OBB path. Keep this stable: Meta ties platform tokens to
|
||||
# package + signing cert, and the runtime-config path derives from it.
|
||||
PackageName = net.dorknet.RecRoom
|
||||
|
||||
# Name shown in the headset library (Unknown Sources). Build-time only.
|
||||
AppName = DorkNet
|
||||
|
||||
# ---- Meta (Oculus) platform ----
|
||||
|
||||
# Your registered Meta app id. Set it for the Meta build: real entitlement and
|
||||
# Meta login run against an app your players own. Leave BLANK for the
|
||||
# no-Meta-account build -- the platform entitlement/login path is steered to the
|
||||
# client's own username/password screen. Blank sends platform id 1 from every
|
||||
# headset; DorkNet's server treats that as a placeholder and keys accounts by
|
||||
# device id (see PlayerService.IsPlaceholderPlatformId), so players are NOT
|
||||
# merged. Never add a server rule that accepts platform id 1 as an identity.
|
||||
OculusAppId =
|
||||
|
||||
# ---- Photon ----
|
||||
|
||||
# Your own Photon app ids (dashboard.photonengine.com). Rec Room's originals
|
||||
# reject external clients. Leave an id blank to keep the client's own (dead)
|
||||
# value -- multiplayer will not connect until these are set. No defaults.
|
||||
PhotonAppId =
|
||||
PhotonVoiceAppId =
|
||||
PhotonChatAppId =
|
||||
|
||||
# Optional self-hosted Photon name server (blank / 0 = Photon Cloud).
|
||||
PhotonNameServer =
|
||||
PhotonNameServerPort = 0
|
||||
|
||||
# Photon Cloud region. Must match the PC clients' PhotonCloudRegion (ClientMod
|
||||
# config), or Quest and PC players connect to different masters and never meet.
|
||||
PhotonRegion = us
|
||||
|
||||
# ---- client behaviour ----
|
||||
|
||||
# Accept the self-hosted TLS chain and skip Rec Room's certificate pinning.
|
||||
# Required for a self-hosted server; turn off only if you front it with a cert
|
||||
# chain the client's bundled CA set trusts AND you reproduce Rec Room's pins.
|
||||
EnableTlsTrustBypass = true
|
||||
|
||||
# Accept image Content-Signatures the server signs with its own key.
|
||||
AcceptUnsignedImages = true
|
||||
|
||||
# ---- diagnostics (adb logcat -s DORKNET) ----
|
||||
|
||||
LogHttp = true
|
||||
# WARNING: Debug logs request/response BODIES, including auth tokens and
|
||||
# passwords. Leave off except when debugging, and don't share the logs.
|
||||
Debug = false
|
||||
LogHttpBodyMax = 10000
|
||||
@@ -1,472 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate config_generated.h from dorknet-quest.cfg.
|
||||
|
||||
Bakes the operator's config into the library at compile time. Keys absent from
|
||||
the cfg are simply not emitted, so the built-in defaults in config.h apply.
|
||||
Secrets (Photon ids, Meta app id) live only in the git-ignored cfg, never in a
|
||||
committed file.
|
||||
|
||||
Usage: gen_config_header.py <dorknet-quest.cfg> <package> <out/config_generated.h>
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# cfg key -> (macro, kind) where kind is "str", "bool" or "int".
|
||||
KEYS = {
|
||||
"ServerHost": ("DN_SERVER_HOST", "str"),
|
||||
"OculusAppId": ("DN_OCULUS_APPID", "str"),
|
||||
"PhotonAppId": ("DN_PHOTON_REALTIME", "str"),
|
||||
"PhotonVoiceAppId": ("DN_PHOTON_VOICE", "str"),
|
||||
"PhotonChatAppId": ("DN_PHOTON_CHAT", "str"),
|
||||
"PhotonNameServer": ("DN_PHOTON_SERVER", "str"),
|
||||
"PhotonNameServerPort": ("DN_PHOTON_PORT", "int"),
|
||||
"PhotonRegion": ("DN_PHOTON_REGION", "str"),
|
||||
"EnableTlsTrustBypass": ("DN_TLS_TRUST_BYPASS", "bool"),
|
||||
"AcceptUnsignedImages": ("DN_ACCEPT_UNSIGNED_IMAGES", "bool"),
|
||||
"LogHttp": ("DN_LOG_HTTP", "bool"),
|
||||
"Debug": ("DN_DEBUG_HTTP", "bool"),
|
||||
"LogHttpBodyMax": ("DN_LOG_BODY_MAX", "int"),
|
||||
}
|
||||
TRUE = {"true", "1", "yes", "on"}
|
||||
FALSE = {"false", "0", "no", "off"}
|
||||
|
||||
|
||||
def c_string(v):
|
||||
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
raise SystemExit("usage: gen_config_header.py <cfg> <package> <out.h>")
|
||||
cfg, package, out = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3])
|
||||
lines = ["// Generated by scripts/gen_config_header.py -- do not edit, do not commit.",
|
||||
"#ifndef DORKNET_QUEST_CONFIG_GENERATED_H",
|
||||
"#define DORKNET_QUEST_CONFIG_GENERATED_H",
|
||||
f"#define DN_PACKAGE {c_string(package)}", ""]
|
||||
|
||||
if cfg.exists():
|
||||
for raw in cfg.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line or "=" not in line or line.startswith("["):
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
key, val = key.strip(), val.strip()
|
||||
if key not in KEYS or val == "":
|
||||
continue
|
||||
macro, kind = KEYS[key]
|
||||
if kind == "str":
|
||||
lines.append(f"#define {macro} {c_string(val)}")
|
||||
elif kind == "int":
|
||||
lines.append(f"#define {macro} {int(val)}")
|
||||
elif kind == "bool":
|
||||
low = val.lower()
|
||||
if low in TRUE:
|
||||
lines.append(f"#define {macro} true")
|
||||
elif low in FALSE:
|
||||
lines.append(f"#define {macro} false")
|
||||
else:
|
||||
raise SystemExit(f"{key}: not a boolean: {val}")
|
||||
else:
|
||||
print(f"note: {cfg} not found; baking package only, all values default", file=sys.stderr)
|
||||
|
||||
lines += ["", "#endif"]
|
||||
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"wrote {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate targets_generated.h from a resolved target table.
|
||||
|
||||
The header holds each target's RVA, length and 20-byte FNV-1a fingerprint as a
|
||||
named struct field, plus the build's version. The hook library checks the
|
||||
fingerprint against the running libil2cpp.so before arming, so a table can only
|
||||
ever be applied to the build it was resolved from.
|
||||
|
||||
Usage: gen_targets_header.py <targets/NAME-CODE.json> <out/targets_generated.h>
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: gen_targets_header.py <table.json> <out.h>")
|
||||
table = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
ids = list(table["targets"].keys())
|
||||
|
||||
lines = [
|
||||
"// Generated by scripts/gen_targets_header.py -- do not edit.",
|
||||
f"// Build {table['versionName']} / versionCode {table['versionCode']}",
|
||||
f"// libil2cpp.so sha256 {table['il2cppSha256']}",
|
||||
"#ifndef DORKNET_QUEST_TARGETS_GENERATED_H",
|
||||
"#define DORKNET_QUEST_TARGETS_GENERATED_H",
|
||||
"#include <stdint.h>",
|
||||
"",
|
||||
f'#define DN_TARGET_VERSION_NAME "{table["versionName"]}"',
|
||||
f'#define DN_TARGET_VERSION_CODE "{table["versionCode"]}"',
|
||||
f"#define DN_HOOK_BYTES {table['hookBytes']}",
|
||||
"",
|
||||
"typedef struct { const char *id; uint32_t rva; uint64_t fingerprint; uint32_t length; } DnTarget;",
|
||||
"",
|
||||
"typedef struct {",
|
||||
]
|
||||
for tid in ids:
|
||||
lines.append(f" DnTarget {tid};")
|
||||
lines.append("} DnTargets;")
|
||||
lines.append("")
|
||||
lines.append("static const DnTargets dn_targets = {")
|
||||
for tid in ids:
|
||||
e = table["targets"][tid]
|
||||
length = e["length"] if e["length"] is not None else 0
|
||||
lines.append(
|
||||
f' .{tid} = {{ "{tid}", {e["rva"]:#x}u, {e["fingerprint"]}ull, {length}u }},')
|
||||
lines.append("};")
|
||||
lines.append("")
|
||||
lines.append("#endif")
|
||||
Path(sys.argv[2]).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"wrote {sys.argv[2]} ({len(ids)} targets)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add a DT_NEEDED entry to libmain.so so the Android loader pulls in
|
||||
libdorknet.so before Unity starts.
|
||||
|
||||
libmain.so is tiny (the Unity bootstrap shim) and lists libil2cpp etc. as
|
||||
DT_NEEDED; adding one more makes the loader map our hook library first. This is
|
||||
the same mechanism RecFlare uses on Quest. Idempotent: a second run is a no-op.
|
||||
|
||||
Usage: inject_needed.py <libmain.so> <needed-soname> (e.g. libdorknet.so)
|
||||
"""
|
||||
import sys
|
||||
|
||||
import lief
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: inject_needed.py <libmain.so> <soname>")
|
||||
path, soname = sys.argv[1], sys.argv[2]
|
||||
binary = lief.ELF.parse(path)
|
||||
if binary is None:
|
||||
raise SystemExit(f"could not parse {path}")
|
||||
|
||||
existing = [e.name for e in binary.dynamic_entries
|
||||
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
|
||||
if soname in existing:
|
||||
print(f"{soname} already a DT_NEEDED of {path}; nothing to do")
|
||||
return
|
||||
|
||||
binary.add_library(soname)
|
||||
binary.write(path)
|
||||
print(f"added DT_NEEDED {soname} to {path}")
|
||||
|
||||
check = lief.ELF.parse(path)
|
||||
names = [e.name for e in check.dynamic_entries
|
||||
if e.tag == lief.ELF.DynamicEntry.TAG.NEEDED]
|
||||
if soname not in names:
|
||||
raise SystemExit("injection did not stick")
|
||||
print("verified:", ", ".join(names))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +0,0 @@
|
||||
@echo off
|
||||
rem One-click installer: runs install.ps1 next to this file.
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" %*
|
||||
echo.
|
||||
pause
|
||||
@@ -1,145 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Install a DorkNet Rec Room .xapk onto a Meta Quest over adb.
|
||||
|
||||
.DESCRIPTION
|
||||
Installs the APK, copies the OBB to the path the game expects, then launches
|
||||
the app. Works in Windows PowerShell 5.1 and PowerShell 7. Run it with no
|
||||
arguments from the package folder, or double-click install.cmd.
|
||||
|
||||
.PARAMETER Xapk
|
||||
The package to install. Default: the only .xapk next to this script.
|
||||
|
||||
.PARAMETER Serial
|
||||
adb serial of the headset when more than one device is connected.
|
||||
|
||||
.PARAMETER NoLaunch
|
||||
Install only; don't start the app afterwards.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Xapk,
|
||||
[string]$Serial,
|
||||
[switch]$NoLaunch
|
||||
)
|
||||
|
||||
# adb writes progress to stderr; under 'Stop' Windows PowerShell would treat
|
||||
# that as a failure, so exit codes are checked by hand instead.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Set-StrictMode -Version 2
|
||||
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
|
||||
|
||||
function Say($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Fail($m) { Write-Host "ERROR: $m" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# ---- find the package ----------------------------------------------------
|
||||
if (-not $Xapk) {
|
||||
$found = @(Get-ChildItem -Path $PSScriptRoot -Filter *.xapk -File)
|
||||
if ($found.Count -eq 0) { Fail "no .xapk found next to $PSCommandPath; pass -Xapk <file>." }
|
||||
if ($found.Count -gt 1) { Fail "more than one .xapk here; pass -Xapk <file>." }
|
||||
$Xapk = $found[0].FullName
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $Xapk)) { Fail "package not found: $Xapk" }
|
||||
$Xapk = (Resolve-Path -LiteralPath $Xapk).Path
|
||||
|
||||
# ---- find adb -----------------------------------------------------------
|
||||
$adb = $null
|
||||
$cmd = Get-Command adb -ErrorAction SilentlyContinue
|
||||
if ($cmd) { $adb = $cmd.Source }
|
||||
if (-not $adb) {
|
||||
$roots = @($env:ANDROID_SDK_ROOT, $env:ANDROID_HOME, (Join-Path $env:LOCALAPPDATA 'Android\Sdk'), $PSScriptRoot) | Where-Object { $_ }
|
||||
foreach ($r in $roots) {
|
||||
foreach ($c in @((Join-Path $r 'platform-tools\adb.exe'), (Join-Path $r 'adb.exe'))) {
|
||||
if (Test-Path $c) { $adb = $c; break }
|
||||
}
|
||||
if ($adb) { break }
|
||||
}
|
||||
}
|
||||
if (-not $adb) {
|
||||
Fail ("adb not found. Install Android platform-tools from " +
|
||||
"https://developer.android.com/tools/releases/platform-tools and put adb on PATH " +
|
||||
"(or unzip platform-tools next to this script).")
|
||||
}
|
||||
|
||||
# ---- pick the headset ----------------------------------------------------
|
||||
$lines = & $adb devices | Select-Object -Skip 1 | Where-Object { $_ -match '\S' }
|
||||
$ready = @($lines | Where-Object { $_ -match "^(\S+)\s+device$" } | ForEach-Object { ($_ -split '\s+')[0] })
|
||||
$unauth = @($lines | Where-Object { $_ -match '\sunauthorized$' })
|
||||
if ($Serial) {
|
||||
if ($ready -notcontains $Serial) { Fail "device $Serial is not connected and authorised (adb devices)." }
|
||||
} elseif ($ready.Count -eq 1) {
|
||||
$Serial = $ready[0]
|
||||
} elseif ($ready.Count -gt 1) {
|
||||
Fail ("more than one device connected: " + ($ready -join ', ') + ". Pass -Serial <id>.")
|
||||
} elseif ($unauth.Count -gt 0) {
|
||||
Fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
|
||||
} else {
|
||||
Fail 'no headset found. Enable developer mode, connect it over USB, and accept "Allow USB debugging" in the headset.'
|
||||
}
|
||||
function Adb { & $adb -s $Serial @args }
|
||||
$model = (Adb shell getprop ro.product.model | Out-String).Trim()
|
||||
Say "headset: $model ($Serial)"
|
||||
|
||||
# ---- read the package ----------------------------------------------------
|
||||
$zip = [System.IO.Compression.ZipFile]::OpenRead($Xapk)
|
||||
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dorknet-install-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $tmp | Out-Null
|
||||
try {
|
||||
$me = $zip.GetEntry('manifest.json')
|
||||
if (-not $me) { Fail 'not an XAPK: manifest.json missing.' }
|
||||
$sr = New-Object System.IO.StreamReader($me.Open())
|
||||
$manifest = $sr.ReadToEnd() | ConvertFrom-Json
|
||||
$sr.Dispose()
|
||||
$pkg = $manifest.package_name
|
||||
Say "package: $($manifest.name) $pkg $($manifest.version_name) ($($manifest.version_code))"
|
||||
|
||||
function Extract($entry) {
|
||||
$out = Join-Path $tmp ([System.IO.Path]::GetFileName($entry.FullName))
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $out, $true)
|
||||
return $out
|
||||
}
|
||||
|
||||
# ---- APK -------------------------------------------------------------
|
||||
$apkEntry = $zip.GetEntry("$pkg.apk")
|
||||
if (-not $apkEntry) { $apkEntry = @($zip.Entries | Where-Object { $_.FullName -like '*.apk' })[0] }
|
||||
if (-not $apkEntry) { Fail 'no APK inside the package.' }
|
||||
Say 'installing the app'
|
||||
$apkFile = Extract $apkEntry
|
||||
$res = (Adb install -r $apkFile 2>&1 | Out-String)
|
||||
if ($res -match 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match') {
|
||||
Write-Host "An older copy of $pkg is installed with a different signing key." -ForegroundColor Yellow
|
||||
Write-Host "It has to be removed first. Your account lives on the server, but local settings on the headset are lost." -ForegroundColor Yellow
|
||||
$ans = Read-Host 'Uninstall it and continue? [y/N]'
|
||||
if ($ans -notmatch '^(y|yes)$') { Fail 'cancelled.' }
|
||||
Adb uninstall $pkg | Out-Null
|
||||
$res = (Adb install -r $apkFile 2>&1 | Out-String)
|
||||
}
|
||||
if ($res -notmatch 'Success') { Fail ("app install failed:`n" + $res.Trim()) }
|
||||
Remove-Item -Force $apkFile
|
||||
|
||||
# ---- OBB (expansion files) ------------------------------------------
|
||||
foreach ($x in @($manifest.expansions)) {
|
||||
if (-not $x) { continue }
|
||||
$entry = $zip.GetEntry($x.file)
|
||||
if (-not $entry) { Fail "package is missing $($x.file)." }
|
||||
$dest = '/sdcard/' + $x.install_path
|
||||
$have = (Adb shell "stat -c %s '$dest' 2>/dev/null" | Out-String).Trim()
|
||||
if ($have -eq [string]$entry.Length) { Say "game data already on the headset, skipping ($([System.IO.Path]::GetFileName($dest)))"; continue }
|
||||
Say ("copying game data ({0:N2} GB) - this takes a few minutes" -f ($entry.Length / 1GB))
|
||||
$f = Extract $entry
|
||||
Adb shell "mkdir -p '$([System.IO.Path]::GetDirectoryName($dest).Replace('\','/'))'" | Out-Null
|
||||
Adb push $f $dest
|
||||
if ($LASTEXITCODE) { Fail 'copying the game data failed (is there enough free space on the headset?).' }
|
||||
Remove-Item -Force $f
|
||||
}
|
||||
} finally {
|
||||
$zip.Dispose()
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Installed. In the headset: Library > Unknown Sources > $($manifest.name)." -ForegroundColor Green
|
||||
if (-not $NoLaunch) {
|
||||
Say 'launching (put the headset on)'
|
||||
Adb shell monkey -p $pkg -c android.intent.category.LAUNCHER 1 2>&1 | Out-Null
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install a DorkNet Rec Room .xapk onto a Meta Quest over adb (macOS / Linux).
|
||||
#
|
||||
# ./install.sh [package.xapk] [--serial <id>] [--no-launch]
|
||||
#
|
||||
# Needs adb (Android platform-tools) and unzip.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
xapk="" serial="" launch=1
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--serial) serial="$2"; shift 2 ;;
|
||||
--no-launch) launch=0; shift ;;
|
||||
*) xapk="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[36m==> %s\033[0m\n' "$*"; }
|
||||
fail() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
if [ -z "$xapk" ]; then
|
||||
set -- "$here"/*.xapk
|
||||
[ -e "$1" ] || fail "no .xapk found next to this script; pass it as an argument."
|
||||
[ $# -eq 1 ] || fail "more than one .xapk here; pass the one to install."
|
||||
xapk="$1"
|
||||
fi
|
||||
[ -f "$xapk" ] || fail "package not found: $xapk"
|
||||
command -v unzip >/dev/null || fail "unzip is required."
|
||||
|
||||
adb="$(command -v adb || true)"
|
||||
for c in "${ANDROID_SDK_ROOT:-}/platform-tools/adb" "${ANDROID_HOME:-}/platform-tools/adb" \
|
||||
"$HOME/Library/Android/sdk/platform-tools/adb" "$HOME/Android/Sdk/platform-tools/adb" \
|
||||
"$here/platform-tools/adb"; do
|
||||
[ -n "$adb" ] && break
|
||||
[ -x "$c" ] && adb="$c"
|
||||
done
|
||||
[ -n "$adb" ] || fail "adb not found. Install Android platform-tools: https://developer.android.com/tools/releases/platform-tools"
|
||||
|
||||
if [ -z "$serial" ]; then
|
||||
ready=$("$adb" devices | awk 'NR>1 && $2=="device"{print $1}')
|
||||
count=$(printf '%s' "$ready" | grep -c . || true)
|
||||
if [ "$count" -eq 0 ]; then
|
||||
if "$adb" devices | grep -q unauthorized; then
|
||||
fail 'the headset is connected but not authorised: put it on and accept "Allow USB debugging".'
|
||||
fi
|
||||
fail 'no headset found. Enable developer mode, connect over USB, and accept "Allow USB debugging".'
|
||||
fi
|
||||
[ "$count" -eq 1 ] || fail "more than one device connected: $(echo $ready). Pass --serial <id>."
|
||||
serial="$ready"
|
||||
fi
|
||||
a() { "$adb" -s "$serial" "$@"; }
|
||||
say "headset: $(a shell getprop ro.product.model | tr -d '\r') ($serial)"
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
manifest="$(unzip -p "$xapk" manifest.json)" || fail "not an XAPK: manifest.json missing."
|
||||
field() { printf '%s' "$manifest" | grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed 's/.*:[[:space:]]*"\(.*\)"/\1/'; }
|
||||
pkg="$(field package_name)"
|
||||
[ -n "$pkg" ] || fail "manifest.json has no package_name."
|
||||
say "package: $(field name) $pkg $(field version_name) ($(field version_code))"
|
||||
|
||||
say "installing the app"
|
||||
unzip -q -o "$xapk" "$pkg.apk" -d "$tmp"
|
||||
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
|
||||
if printf '%s' "$out" | grep -qE 'INSTALL_FAILED_UPDATE_INCOMPATIBLE|signatures do not match'; then
|
||||
echo "An older copy of $pkg is installed with a different signing key; it has to be removed first."
|
||||
echo "Your account lives on the server, but local settings on the headset are lost."
|
||||
read -r -p "Uninstall it and continue? [y/N] " ans
|
||||
case "$ans" in y|Y|yes) ;; *) fail "cancelled." ;; esac
|
||||
a uninstall "$pkg" >/dev/null || true
|
||||
out="$(a install -r "$tmp/$pkg.apk" 2>&1 || true)"
|
||||
fi
|
||||
printf '%s' "$out" | grep -q Success || fail "app install failed: $out"
|
||||
rm -f "$tmp/$pkg.apk"
|
||||
|
||||
# Expansion files: every "install_path" in the manifest.
|
||||
printf '%s' "$manifest" | grep -o '"install_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"\(.*\)"/\1/' |
|
||||
while read -r path; do
|
||||
dest="/sdcard/$path"
|
||||
size="$(unzip -l "$xapk" "$path" | awk 'NR==4{print $1}')"
|
||||
have="$(a shell "stat -c %s '$dest' 2>/dev/null" | tr -d '\r' || true)"
|
||||
if [ -n "$size" ] && [ "$have" = "$size" ]; then say "game data already on the headset, skipping"; continue; fi
|
||||
say "copying game data - this takes a few minutes"
|
||||
unzip -q -o "$xapk" "$path" -d "$tmp"
|
||||
a shell "mkdir -p '$(dirname "$dest")'"
|
||||
a push "$tmp/$path" "$dest" || fail "copying the game data failed (is there enough free space on the headset?)."
|
||||
rm -f "$tmp/$path"
|
||||
done
|
||||
|
||||
echo
|
||||
printf '\033[32mInstalled. In the headset: Library > Unknown Sources > %s.\033[0m\n' "$(field name)"
|
||||
if [ "$launch" -eq 1 ]; then
|
||||
say "launching (put the headset on)"
|
||||
a shell monkey -p "$pkg" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve DorkNet.Quest hook targets for one Rec Room Quest build.
|
||||
|
||||
Reads targets/spec.json, finds every target in an Il2CppDumper dump.cs by its
|
||||
class header and method signature, and verifies each against the build's
|
||||
libil2cpp.so:
|
||||
|
||||
* exactly one method matches the signature,
|
||||
* the method body is at least 20 bytes (the inline hook rewrites the first
|
||||
five instructions),
|
||||
* the first instructions are disassembled (llvm-objdump, when provided) so
|
||||
the prologue can be eyeballed before a build.
|
||||
|
||||
Output is a per-build table (targets/<versionName>-<versionCode>.json) holding
|
||||
each target's RVA, body length and an FNV-1a fingerprint of the first 20 code
|
||||
bytes. The hook library refuses to arm a target whose fingerprint does not
|
||||
match the running libil2cpp.so, so a table can never be applied to the wrong
|
||||
build. Only RVAs, lengths and hashes are written -- no game code or metadata.
|
||||
|
||||
Usage:
|
||||
python resolve_targets.py --dump dump.cs --il2cpp libil2cpp.so \
|
||||
--version-name 20230317 --version-code 2128210 \
|
||||
[--objdump path/to/llvm-objdump] [--out targets/20230317-2128210.json]
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
HOOK_BYTES = 20
|
||||
RVA_RE = re.compile(r"^\s*// RVA: 0x([0-9A-Fa-f]+) Offset: 0x([0-9A-Fa-f]+)")
|
||||
TYPE_RE = re.compile(r"^(public|internal|private|protected)\b.*\b(class|struct|interface|enum)\b ")
|
||||
|
||||
|
||||
def fnv1a64(data):
|
||||
h = 0xCBF29CE484222325
|
||||
for b in data:
|
||||
h ^= b
|
||||
h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
||||
return h
|
||||
|
||||
|
||||
def sha256(path):
|
||||
d = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
d.update(chunk)
|
||||
return d.hexdigest()
|
||||
|
||||
|
||||
def scan_dump(dump, spec):
|
||||
"""One pass over dump.cs collecting (rva, offset) for each spec match and
|
||||
for the NEXT method after it (to bound the body length)."""
|
||||
wanted = {t["id"]: (t["class"], t["method"]) for t in spec["targets"]}
|
||||
hits = {} # id -> {rva, offset, ...}
|
||||
all_rvas = set() # every method RVA, for next-greater length bounding
|
||||
rva = off = None
|
||||
cur_class = None
|
||||
lines = dump.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
for ln in lines:
|
||||
m = RVA_RE.match(ln)
|
||||
if m:
|
||||
rva, off = int(m.group(1), 16), int(m.group(2), 16)
|
||||
if rva:
|
||||
all_rvas.add(rva)
|
||||
continue
|
||||
if TYPE_RE.match(ln):
|
||||
# dump.cs class headers end with "// TypeDefIndex: N"; drop it.
|
||||
cur_class = ln.split("// TypeDefIndex")[0].rstrip()
|
||||
continue
|
||||
# dump.cs method lines end with " { }" (or "; " for abstract); drop it.
|
||||
body = ln.strip()
|
||||
body = body.split("// RVA")[0].rstrip()
|
||||
if body.endswith("{ }"):
|
||||
body = body[:-3].rstrip()
|
||||
elif body.endswith(";"):
|
||||
body = body[:-1].rstrip()
|
||||
for tid, (cls, meth) in wanted.items():
|
||||
if tid in hits:
|
||||
continue
|
||||
# Class header may carry a base/interface list ("X : Base, IFoo").
|
||||
# Match the exact class, or that class followed by " : <bases>",
|
||||
# but never a nested "X.Y" (which continues with "." not " ").
|
||||
spec_class = cls.rstrip()
|
||||
class_ok = cur_class is not None and (
|
||||
cur_class == spec_class or cur_class.startswith(spec_class + " "))
|
||||
if class_ok and body == meth.rstrip():
|
||||
if rva is None:
|
||||
raise SystemExit(f"{tid}: matched signature with no preceding RVA line")
|
||||
hits[tid] = {"rva": rva, "offset": off, "signature": meth,
|
||||
"class": cls.rstrip()}
|
||||
# Bound each body by the next-greater RVA anywhere in the dump: dump.cs
|
||||
# lists methods in declaration order, so the following *line* is not the
|
||||
# following *function* in memory.
|
||||
ordered = sorted(all_rvas)
|
||||
import bisect
|
||||
for h in hits.values():
|
||||
i = bisect.bisect_right(ordered, h["rva"])
|
||||
h["end_rva"] = ordered[i] if i < len(ordered) else None
|
||||
return hits
|
||||
|
||||
|
||||
def objdump_prologue(objdump, il2cpp, rva, count=6):
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[objdump, "-d", f"--start-address={rva:#x}",
|
||||
f"--stop-address={rva + count * 4:#x}", str(il2cpp)],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
return f"(objdump failed: {e})"
|
||||
return "\n".join(l for l in out.splitlines() if ":\t" in l)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dump", required=True, type=Path)
|
||||
ap.add_argument("--il2cpp", required=True, type=Path)
|
||||
ap.add_argument("--version-name", required=True)
|
||||
ap.add_argument("--version-code", required=True)
|
||||
ap.add_argument("--objdump")
|
||||
ap.add_argument("--spec", type=Path,
|
||||
default=Path(__file__).resolve().parent.parent / "targets" / "spec.json")
|
||||
ap.add_argument("--out", type=Path)
|
||||
args = ap.parse_args()
|
||||
|
||||
spec = json.loads(args.spec.read_text(encoding="utf-8"))
|
||||
hits = scan_dump(args.dump, spec)
|
||||
|
||||
code = args.il2cpp.read_bytes()
|
||||
missing = [t["id"] for t in spec["targets"] if t["id"] not in hits]
|
||||
if missing:
|
||||
raise SystemExit("targets not found in dump: " + ", ".join(missing))
|
||||
|
||||
entries = {}
|
||||
ok = True
|
||||
for t in spec["targets"]:
|
||||
tid = t["id"]
|
||||
h = hits[tid]
|
||||
rva = h["rva"]
|
||||
# In this ELF Offset == VA == RVA, so the file offset is the RVA.
|
||||
length = (h["end_rva"] - rva) if h["end_rva"] else None
|
||||
fp = fnv1a64(code[rva:rva + HOOK_BYTES])
|
||||
short = length is not None and length < HOOK_BYTES
|
||||
if short:
|
||||
ok = False
|
||||
entries[tid] = {
|
||||
"rva": rva,
|
||||
"length": length,
|
||||
"fingerprint": f"{fp:#018x}",
|
||||
"why": t["why"],
|
||||
}
|
||||
flag = " TOO SHORT" if short else ""
|
||||
print(f"{tid:28s} rva={rva:#010x} len={length}{flag}")
|
||||
if args.objdump:
|
||||
print(objdump_prologue(args.objdump, args.il2cpp, rva))
|
||||
|
||||
table = {
|
||||
"versionName": args.version_name,
|
||||
"versionCode": args.version_code,
|
||||
"il2cppSha256": sha256(args.il2cpp),
|
||||
"hookBytes": HOOK_BYTES,
|
||||
"targets": entries,
|
||||
}
|
||||
out = args.out or (args.spec.parent / f"{args.version_name}-{args.version_code}.json")
|
||||
out.write_text(json.dumps(table, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"\nwrote {out}")
|
||||
if not ok:
|
||||
raise SystemExit("one or more targets are shorter than the hook patch; refuse to build")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -1,88 +0,0 @@
|
||||
// Compile-time configuration for libdorknet.so.
|
||||
//
|
||||
// scripts/gen_config_header.py turns dorknet-quest.cfg into config_generated.h
|
||||
// (put on the include path by the build), baking the operator's values into the
|
||||
// APK. Any key left out of the cfg falls back to the built-in default below.
|
||||
// The same cfg can also be pushed to
|
||||
// /sdcard/Android/data/<package>/files/dorknet-quest.cfg
|
||||
// to override at runtime without a rebuild (dn_load_runtime_config).
|
||||
#ifndef DORKNET_QUEST_CONFIG_H
|
||||
#define DORKNET_QUEST_CONFIG_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#if defined(__has_include)
|
||||
# if __has_include("config_generated.h")
|
||||
# include "config_generated.h"
|
||||
# endif
|
||||
#endif
|
||||
|
||||
// The apex the client's *.rec.net hosts are rewritten onto. "api.rec.net"
|
||||
// becomes "api.<this>", "auth.rec.net" -> "auth.<this>", etc. No default: a
|
||||
// build with no server configured would do nothing useful.
|
||||
#ifndef DN_SERVER_HOST
|
||||
# define DN_SERVER_HOST ""
|
||||
#endif
|
||||
// Empty = keep the client's own (Rec Room's) Meta app id, which selects the
|
||||
// no-Meta-account build (platform entitlement/login are steered past).
|
||||
#ifndef DN_OCULUS_APPID
|
||||
# define DN_OCULUS_APPID ""
|
||||
#endif
|
||||
// Photon app ids: per-deployment, no default. Empty leaves the client's own
|
||||
// (dead) value in place, so multiplayer will not connect until set.
|
||||
#ifndef DN_PHOTON_REALTIME
|
||||
# define DN_PHOTON_REALTIME ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_VOICE
|
||||
# define DN_PHOTON_VOICE ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_CHAT
|
||||
# define DN_PHOTON_CHAT ""
|
||||
#endif
|
||||
#ifndef DN_PHOTON_SERVER
|
||||
# define DN_PHOTON_SERVER "" // "" = Photon Cloud
|
||||
#endif
|
||||
#ifndef DN_PHOTON_PORT
|
||||
# define DN_PHOTON_PORT 0
|
||||
#endif
|
||||
// Photon Cloud fixed region. Must match the PC clients' region (ClientMod
|
||||
// PhotonCloudRegion), or Quest and PC players land on different masters.
|
||||
#ifndef DN_PHOTON_REGION
|
||||
# define DN_PHOTON_REGION "us"
|
||||
#endif
|
||||
#ifndef DN_TLS_TRUST_BYPASS
|
||||
# define DN_TLS_TRUST_BYPASS true // accept a self-hosted TLS chain + skip pinning
|
||||
#endif
|
||||
#ifndef DN_ACCEPT_UNSIGNED_IMAGES
|
||||
# define DN_ACCEPT_UNSIGNED_IMAGES true
|
||||
#endif
|
||||
#ifndef DN_LOG_HTTP
|
||||
# define DN_LOG_HTTP true
|
||||
#endif
|
||||
#ifndef DN_DEBUG_HTTP
|
||||
# define DN_DEBUG_HTTP false // also log request/response bodies (incl. tokens!)
|
||||
#endif
|
||||
#ifndef DN_LOG_BODY_MAX
|
||||
# define DN_LOG_BODY_MAX 10000
|
||||
#endif
|
||||
|
||||
#ifndef DN_RUNTIME_CFG_NAME
|
||||
# define DN_RUNTIME_CFG_NAME "dorknet-quest.cfg"
|
||||
#endif
|
||||
// Package the runtime cfg is read from; the build's gen step overrides this.
|
||||
#ifndef DN_PACKAGE
|
||||
# define DN_PACKAGE "net.dorknet.RecRoom"
|
||||
#endif
|
||||
|
||||
// Apply /sdcard/Android/data/<DN_PACKAGE>/files/dorknet-quest.cfg over the
|
||||
// baked defaults. Each out pointer already holds the compiled-in value.
|
||||
void dn_load_runtime_config(char *server_host, size_t server_host_cap,
|
||||
char *oculus_appid, size_t oculus_appid_cap,
|
||||
char *photon_realtime, char *photon_voice, char *photon_chat,
|
||||
char *photon_server, size_t photon_server_cap, int *photon_port,
|
||||
char *photon_region, size_t photon_region_cap,
|
||||
bool *tls_bypass, bool *accept_images,
|
||||
bool *log_http, bool *debug_http, int *body_max);
|
||||
|
||||
#endif // DORKNET_QUEST_CONFIG_H
|
||||
@@ -1,613 +0,0 @@
|
||||
// DorkNet.Quest native hook library (libdorknet.so).
|
||||
//
|
||||
// Loaded via a DT_NEEDED entry added to libmain.so, so it runs before Unity.
|
||||
// It waits for libil2cpp.so, then installs arm64 inline hooks (And64InlineHook)
|
||||
// at RVAs resolved from the build's dump.cs by scripts/resolve_targets.py and
|
||||
// baked into targets_generated.h at compile time. It is the Quest counterpart
|
||||
// of DorkNet.ClientMod (the PC MelonLoader mod) and does the same jobs:
|
||||
//
|
||||
// * rewrite <service>.rec.net hosts to <service>.<ServerHost> (the ns
|
||||
// response then supplies every other service URL) -- like ClientMod's
|
||||
// UriPatches,
|
||||
// * inject the operator's Photon app ids -- like the PC byte patch
|
||||
// tools/patch-client.ps1,
|
||||
// * optionally set the operator's Meta (Oculus) app id,
|
||||
// * accept the self-hosted TLS chain and the image signature the operator's
|
||||
// server produces -- like ClientMod's TlsPatches / the PC ImageSigning,
|
||||
// * neutralise the integrity callbacks that would reject a re-signed,
|
||||
// hook-loaded client -- like ClientMod's AntiTamper/ToxMod/FileHash patches,
|
||||
// * when no Meta app id is set, steer the Oculus platform path past its
|
||||
// entitlement/login checks to the client's own credential screen.
|
||||
//
|
||||
// Every RVA is checked against a 20-byte FNV-1a fingerprint from the target
|
||||
// table before it is hooked, so the wrong build is refused rather than
|
||||
// miswritten. Obfuscated Assembly-CSharp names are re-randomised per release
|
||||
// but shared with the PC build of the same release; this build (20230317 /
|
||||
// versionCode 2128210) shares its names with the PC 2023-03-21 decompile.
|
||||
//
|
||||
// No Rec Room code or assets are included. Third-party inline hooker in
|
||||
// src/third_party/And64InlineHook (MIT, (c) Rprop).
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "third_party/And64InlineHook/And64InlineHook.hpp"
|
||||
#include "config.h"
|
||||
#include "targets_generated.h"
|
||||
|
||||
extern "C" int __android_log_print(int prio, const char *tag, const char *fmt, ...);
|
||||
#define LOGI(...) __android_log_print(4, "DORKNET", __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(6, "DORKNET", __VA_ARGS__)
|
||||
|
||||
// --------------------------------------------------------------- config -----
|
||||
|
||||
static const char *kOfficialSuffix = ".rec.net";
|
||||
|
||||
static char g_server_host[160] = DN_SERVER_HOST; // <service>.rec.net -> <service>.<this>
|
||||
static char g_oculus_appid[64] = DN_OCULUS_APPID; // "" = keep the client's own
|
||||
static char g_photon_realtime[64] = DN_PHOTON_REALTIME;
|
||||
static char g_photon_voice[64] = DN_PHOTON_VOICE;
|
||||
static char g_photon_chat[64] = DN_PHOTON_CHAT;
|
||||
static char g_photon_server[128] = DN_PHOTON_SERVER; // "" = Photon Cloud
|
||||
static int g_photon_port = DN_PHOTON_PORT;
|
||||
static char g_photon_region[16] = DN_PHOTON_REGION;
|
||||
static bool g_tls_bypass = DN_TLS_TRUST_BYPASS;
|
||||
static bool g_accept_images = DN_ACCEPT_UNSIGNED_IMAGES;
|
||||
static bool g_log_http = DN_LOG_HTTP;
|
||||
static bool g_debug_http = DN_DEBUG_HTTP;
|
||||
static int g_body_max = DN_LOG_BODY_MAX;
|
||||
|
||||
// AppSettings field offsets (dump.cs; unchanged across the 2023 builds).
|
||||
static const size_t kAppIdRealtimeOff = 0x10;
|
||||
static const size_t kAppIdChatOff = 0x18;
|
||||
static const size_t kAppIdVoiceOff = 0x20;
|
||||
static const size_t kUseNameServerOff = 0x30; // bool
|
||||
static const size_t kFixedRegionOff = 0x38;
|
||||
static const size_t kServerOff = 0x48;
|
||||
static const size_t kPortOff = 0x50; // int
|
||||
static const size_t kProtocolOff = 0x60; // int (Udp = 0)
|
||||
|
||||
// HTTPRequest / HTTPResponse field offsets (dump.cs).
|
||||
static const size_t kUriFieldOffset = 0x10; // HTTPRequest.<Uri>
|
||||
static const size_t kReqMethodOffset = 0x18; // HTTPRequest.<MethodType> (byte)
|
||||
static const size_t kReqResponseOffset = 0x70; // HTTPRequest.<Response>
|
||||
static const size_t kReqExceptionOffset = 0x80; // HTTPRequest.<Exception>
|
||||
static const size_t kReqStateOffset = 0xB4; // HTTPRequest.<State> (int)
|
||||
static const size_t kRespStatusOffset = 0x18; // HTTPResponse.<StatusCode> (int)
|
||||
static const size_t kRespDataOffset = 0x48; // HTTPResponse.<Data> (byte[])
|
||||
|
||||
// Il2CppArray data layout on 64-bit.
|
||||
static const size_t kArrayLengthOff = 0x18;
|
||||
static const size_t kArrayDataOff = 0x20;
|
||||
|
||||
// Oculus platform message types (dump.cs ONGCNIAMKCG.DJCEHCFKHME).
|
||||
static const unsigned kMsgEntitlementGetIsViewerEntitled = 409688241u;
|
||||
static const unsigned kMsgUserGetUserProof = 578880643u;
|
||||
static const char *kPlaceholderNonce = "dorknet-no-platform-account";
|
||||
static const unsigned long long kNoPlatformUserId = 1ULL;
|
||||
|
||||
static const size_t kLogChunk = 1500;
|
||||
static const uint32_t kUnityTlsSuccess = 0; // UNITYTLS_X509VERIFY_SUCCESS / UNITYTLS_SUCCESS
|
||||
static const size_t kErrorStateCodeOff = 0x4;
|
||||
|
||||
// --------------------------------------------------------------- il2cpp ------
|
||||
|
||||
typedef void *(*il2cpp_object_get_class_t)(void *obj);
|
||||
typedef void *(*il2cpp_class_get_method_from_name_t)(void *klass, const char *name, int argc);
|
||||
typedef void *(*il2cpp_object_new_t)(void *klass);
|
||||
typedef void *(*il2cpp_string_new_t)(const char *str);
|
||||
typedef void *(*il2cpp_runtime_invoke_t)(void *method, void *obj, void **params, void **exc);
|
||||
|
||||
static il2cpp_object_get_class_t il2cpp_object_get_class;
|
||||
static il2cpp_class_get_method_from_name_t il2cpp_class_get_method_from_name;
|
||||
static il2cpp_object_new_t il2cpp_object_new;
|
||||
static il2cpp_string_new_t il2cpp_string_new;
|
||||
static il2cpp_runtime_invoke_t il2cpp_runtime_invoke;
|
||||
|
||||
static uintptr_t g_il2cpp_base;
|
||||
|
||||
// Il2CppString: [klass][monitor][int32 length][char16 chars...]
|
||||
static void str_to_utf8(void *s, char *out, size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (!s || cap == 0) return;
|
||||
int32_t len = *(int32_t *)((char *)s + 0x10);
|
||||
if (len < 0) return;
|
||||
const uint16_t *chars = (const uint16_t *)((char *)s + 0x14);
|
||||
size_t i = 0;
|
||||
for (; i < (size_t)len && i + 1 < cap; i++)
|
||||
out[i] = chars[i] < 0x80 ? (char)chars[i] : '?';
|
||||
out[i] = '\0';
|
||||
}
|
||||
|
||||
static const unsigned char *invoke_bytes(void *obj, const char *method, size_t *len) {
|
||||
*len = 0;
|
||||
if (!obj) return nullptr;
|
||||
void *cls = il2cpp_object_get_class(obj);
|
||||
if (!cls) return nullptr;
|
||||
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||
if (!m) return nullptr;
|
||||
void *exc = nullptr;
|
||||
void *arr = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||
if (!arr || exc) return nullptr;
|
||||
*len = (size_t) * (uint64_t *)((char *)arr + kArrayLengthOff);
|
||||
return (const unsigned char *)((char *)arr + kArrayDataOff);
|
||||
}
|
||||
|
||||
static void invoke_str(void *obj, const char *method, char *out, size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (!obj) return;
|
||||
void *cls = il2cpp_object_get_class(obj);
|
||||
if (!cls) return;
|
||||
void *m = il2cpp_class_get_method_from_name(cls, method, 0);
|
||||
if (!m) return;
|
||||
void *exc = nullptr;
|
||||
void *s = il2cpp_runtime_invoke(m, obj, nullptr, &exc);
|
||||
if (s && !exc) str_to_utf8(s, out, cap);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- logging ----
|
||||
|
||||
static void log_chunked(const char *prefix, const char *text) {
|
||||
size_t n = strlen(text);
|
||||
if (n <= kLogChunk) { LOGI("%s%s", prefix, text); return; }
|
||||
char buf[kLogChunk + 1];
|
||||
for (size_t off = 0, part = 1; off < n; off += kLogChunk, part++) {
|
||||
size_t take = n - off < kLogChunk ? n - off : kLogChunk;
|
||||
memcpy(buf, text + off, take);
|
||||
buf[take] = '\0';
|
||||
LOGI("%s[%zu] %s", prefix, part, buf);
|
||||
}
|
||||
}
|
||||
|
||||
static bool looks_binary(const unsigned char *d, size_t n) {
|
||||
if (n == 0) return false;
|
||||
size_t sample = n < 4096 ? n : 4096, nontext = 0;
|
||||
for (size_t i = 0; i < sample; i++) {
|
||||
unsigned char b = d[i];
|
||||
if (b == 0) return true;
|
||||
if (b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D) nontext++;
|
||||
}
|
||||
return nontext * 100 / sample > 10;
|
||||
}
|
||||
|
||||
static void format_body(const unsigned char *d, size_t n, char *out, size_t cap) {
|
||||
if (!d || n == 0) { snprintf(out, cap, "<empty>"); return; }
|
||||
size_t limit = (size_t)g_body_max;
|
||||
if (limit > cap - 64) limit = cap - 64;
|
||||
if (!looks_binary(d, n)) {
|
||||
size_t take = n < limit ? n : limit;
|
||||
memcpy(out, d, take);
|
||||
out[take] = '\0';
|
||||
if (take < n) snprintf(out + take, cap - take, "... <truncated %zu bytes>", n - take);
|
||||
return;
|
||||
}
|
||||
int w = snprintf(out, cap, "<binary %zu bytes>", n);
|
||||
out[w < 0 ? 0 : (size_t)w] = '\0';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- hooks -------
|
||||
|
||||
static void *(*orig_SendRequest)(void *request);
|
||||
|
||||
static void *hk_SendRequest(void *request) {
|
||||
if (!request) return orig_SendRequest(request);
|
||||
void *uri = *(void **)((char *)request + kUriFieldOffset);
|
||||
if (!uri) return orig_SendRequest(request);
|
||||
void *uri_class = il2cpp_object_get_class(uri);
|
||||
if (!uri_class) return orig_SendRequest(request);
|
||||
|
||||
void *m_abs = il2cpp_class_get_method_from_name(uri_class, "get_AbsoluteUri", 0);
|
||||
if (!m_abs) return orig_SendRequest(request);
|
||||
void *exc = nullptr;
|
||||
void *s = il2cpp_runtime_invoke(m_abs, uri, nullptr, &exc);
|
||||
if (!s || exc) return orig_SendRequest(request);
|
||||
|
||||
char url[2048];
|
||||
str_to_utf8(s, url, sizeof(url));
|
||||
|
||||
if (g_log_http) {
|
||||
int method = *(unsigned char *)((char *)request + kReqMethodOffset);
|
||||
static const char *methods[] = {"GET", "HEAD", "POST", "PUT", "DELETE",
|
||||
"PATCH", "MERGE", "OPTIONS", "CONNECT"};
|
||||
const char *mname = (method >= 0 && method < 9) ? methods[method] : "?";
|
||||
LOGI("[http] %s %s", mname, url);
|
||||
if (g_debug_http) {
|
||||
size_t blen = 0;
|
||||
const unsigned char *body = invoke_bytes(request, "GetEntityBody", &blen);
|
||||
if (body && blen) {
|
||||
size_t cap = (size_t)g_body_max + 128;
|
||||
char *text = (char *)malloc(cap);
|
||||
if (text) { format_body(body, blen, text, cap); log_chunked("[http] body=", text); free(text); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite any "<service>.rec.net" host to "<service>.<ServerHost>".
|
||||
const char *scheme_end = strstr(url, "://");
|
||||
if (!scheme_end) return orig_SendRequest(request);
|
||||
const char *host = scheme_end + 3;
|
||||
const char *host_end = host;
|
||||
while (*host_end && *host_end != '/' && *host_end != ':' && *host_end != '?') host_end++;
|
||||
size_t host_len = (size_t)(host_end - host);
|
||||
size_t suf = strlen(kOfficialSuffix);
|
||||
if (host_len <= suf || strncasecmp(host_end - suf, kOfficialSuffix, suf) != 0)
|
||||
return orig_SendRequest(request);
|
||||
|
||||
char newurl[1024];
|
||||
size_t head = (size_t)(host - url); // "https://"
|
||||
size_t service_len = host_len - suf; // "api", "auth", ...
|
||||
// ServerHost is either a plain apex ("example.com" -> "api.example.com") or a
|
||||
// template with "{service}" for servers on DorkNet's hyphen host style
|
||||
// ("{service}-dev.example.com" -> "ns-dev.example.com").
|
||||
static const char kServiceToken[] = "{service}";
|
||||
const char *token = strstr(g_server_host, kServiceToken);
|
||||
size_t new_host_len = token ? strlen(g_server_host) - (sizeof(kServiceToken) - 1) + service_len
|
||||
: service_len + 1 + strlen(g_server_host);
|
||||
if (head + new_host_len + strlen(host_end) + 1 >= sizeof(newurl))
|
||||
return orig_SendRequest(request);
|
||||
memcpy(newurl, url, head);
|
||||
size_t p = head;
|
||||
if (token) {
|
||||
size_t before = (size_t)(token - g_server_host);
|
||||
memcpy(newurl + p, g_server_host, before);
|
||||
p += before;
|
||||
memcpy(newurl + p, host, service_len);
|
||||
p += service_len;
|
||||
strcpy(newurl + p, token + sizeof(kServiceToken) - 1);
|
||||
} else {
|
||||
memcpy(newurl + p, host, service_len);
|
||||
p += service_len;
|
||||
newurl[p++] = '.';
|
||||
strcpy(newurl + p, g_server_host);
|
||||
}
|
||||
strcat(newurl + head, host_end);
|
||||
|
||||
void *new_str = il2cpp_string_new(newurl);
|
||||
void *new_uri = il2cpp_object_new(uri_class);
|
||||
void *ctor = il2cpp_class_get_method_from_name(uri_class, ".ctor", 1);
|
||||
if (!new_str || !new_uri || !ctor) return orig_SendRequest(request);
|
||||
void *args[1] = {new_str};
|
||||
exc = nullptr;
|
||||
il2cpp_runtime_invoke(ctor, new_uri, args, &exc);
|
||||
if (exc) return orig_SendRequest(request);
|
||||
*(void **)((char *)request + kUriFieldOffset) = new_uri;
|
||||
LOGI("intercepted %s -> %s", url, newurl);
|
||||
return orig_SendRequest(request);
|
||||
}
|
||||
|
||||
static void (*orig_CallCallback)(void *thiz, void *method);
|
||||
static void hk_CallCallback(void *thiz, void *method) {
|
||||
if (!thiz || !g_log_http) { orig_CallCallback(thiz, method); return; }
|
||||
char url[2048];
|
||||
invoke_str(*(void **)((char *)thiz + kUriFieldOffset), "get_AbsoluteUri", url, sizeof(url));
|
||||
int state = *(int *)((char *)thiz + kReqStateOffset);
|
||||
void *resp = *(void **)((char *)thiz + kReqResponseOffset);
|
||||
if (!resp) {
|
||||
void *ex = *(void **)((char *)thiz + kReqExceptionOffset);
|
||||
if (ex) {
|
||||
char msg[900];
|
||||
invoke_str(ex, "ToString", msg, sizeof(msg));
|
||||
LOGI("[http<-] NO-RESPONSE state=%d %s | %s", state, url, msg);
|
||||
} else {
|
||||
LOGI("[http<-] NO-RESPONSE state=%d %s | (no exception)", state, url);
|
||||
}
|
||||
} else {
|
||||
int status = *(int *)((char *)resp + kRespStatusOffset);
|
||||
LOGI("[http<-] %d %s", status, url);
|
||||
if (g_debug_http || status < 200 || status >= 300) {
|
||||
size_t blen = 0;
|
||||
const unsigned char *data = invoke_bytes(resp, "get_Data", &blen);
|
||||
size_t cap = (size_t)g_body_max + 128;
|
||||
char *text = (char *)malloc(cap);
|
||||
if (text) { format_body(data, blen, text, cap); log_chunked("[http<-] body=", text); free(text); }
|
||||
}
|
||||
}
|
||||
orig_CallCallback(thiz, method);
|
||||
}
|
||||
|
||||
static void *hk_get_AppID(void *method) {
|
||||
(void)method;
|
||||
return il2cpp_string_new(g_oculus_appid);
|
||||
}
|
||||
|
||||
static void *(*orig_Photon)(void *thiz, void *method);
|
||||
static void *hk_Photon(void *thiz, void *method) {
|
||||
void *s = orig_Photon(thiz, method);
|
||||
if (s) {
|
||||
if (g_photon_realtime[0]) *(void **)((char *)s + kAppIdRealtimeOff) = il2cpp_string_new(g_photon_realtime);
|
||||
if (g_photon_chat[0]) *(void **)((char *)s + kAppIdChatOff) = il2cpp_string_new(g_photon_chat);
|
||||
if (g_photon_voice[0]) *(void **)((char *)s + kAppIdVoiceOff) = il2cpp_string_new(g_photon_voice);
|
||||
*(unsigned char *)((char *)s + kUseNameServerOff) = 1;
|
||||
*(void **)((char *)s + kFixedRegionOff) = il2cpp_string_new(g_photon_region);
|
||||
*(int *)((char *)s + kProtocolOff) = 0;
|
||||
if (g_photon_server[0]) {
|
||||
*(void **)((char *)s + kServerOff) = il2cpp_string_new(g_photon_server);
|
||||
*(int *)((char *)s + kPortOff) = g_photon_port ? g_photon_port : 4533;
|
||||
}
|
||||
static int logged = 0;
|
||||
if (logged < 2) {
|
||||
logged++;
|
||||
LOGI("photon appsettings: RT=%s Voice=%s Chat=%s region=%s server=%s port=%d",
|
||||
g_photon_realtime[0] ? g_photon_realtime : "(unset)",
|
||||
g_photon_voice[0] ? g_photon_voice : "(unset)",
|
||||
g_photon_chat[0] ? g_photon_chat : "(unset)", g_photon_region,
|
||||
g_photon_server[0] ? g_photon_server : "(cloud)", g_photon_port);
|
||||
if (!g_photon_realtime[0])
|
||||
LOGE("photon app ids are not configured; multiplayer will try Rec Room's dead apps");
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static uint32_t (*orig_UnityTls)(void *thiz, uint64_t chain, void *es, void *method);
|
||||
static uint32_t hk_UnityTls(void *thiz, uint64_t chain, void *es, void *method) {
|
||||
uint32_t r = orig_UnityTls(thiz, chain, es, method);
|
||||
if (r != kUnityTlsSuccess) {
|
||||
static int logged = 0;
|
||||
if (logged < 3) { logged++; LOGI("unitytls verify: forcing 0x%x -> SUCCESS", r); }
|
||||
if (es) *(uint32_t *)((char *)es + kErrorStateCodeOff) = kUnityTlsSuccess;
|
||||
}
|
||||
return kUnityTlsSuccess;
|
||||
}
|
||||
|
||||
// void NotifyServerCertificate(Certificate): a no-op accepts the chain.
|
||||
static void hk_NotifyServerCertificate(void *thiz, void *cert, void *method) {
|
||||
(void)thiz; (void)cert; (void)method;
|
||||
}
|
||||
|
||||
// bool pin verifiers: accept unconditionally (replace-only, so no mismatch
|
||||
// report is ever built and the client never quits).
|
||||
static bool hk_pin_true4(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
|
||||
static bool hk_pin_true4b(void *a, void *b, void *c, void *d) { (void)a;(void)b;(void)c;(void)d; return true; }
|
||||
|
||||
static bool hk_image_true(void *data, void *sig, void *method) {
|
||||
(void)data; (void)sig; (void)method;
|
||||
return true;
|
||||
}
|
||||
|
||||
// void callbacks the client runs on an integrity rejection -- no-op them so a
|
||||
// re-signed, hook-loaded sideload is not warned/quit (as DorkNet.ClientMod does
|
||||
// on PC). The quit funnels and the report are left to run but are logged.
|
||||
static void hk_void0(void *thiz, void *method) { (void)thiz; (void)method; }
|
||||
static void hk_void1(void *thiz, void *arg, void *method) { (void)thiz; (void)arg; (void)method; }
|
||||
|
||||
static bool hk_toxmod_false(void *method) { (void)method; return false; }
|
||||
|
||||
static const char *cheat_reason(int r) {
|
||||
switch (r) {
|
||||
case 0: return "Obscured"; case 1: return "Time"; case 2: return "Inject";
|
||||
case 3: return "GiftCount"; case 4: return "Engine"; case 5: return "UnknownDll";
|
||||
case 6: return "ImageSignature"; case 7: return "AvatarHack";
|
||||
case 100: return "NetworkCertificatePublicKey"; case 101: return "NetworkCertificateIssuer";
|
||||
case 102: return "NetworkCertificateMissing"; case 103: return "NetworkCertificateMismatch";
|
||||
case 150: return "AutosaveChecksumMismatch"; case 151: return "AutosaveSubRoomIdMismatch";
|
||||
case 152: return "AutosaveChecksumException"; case 200: return "Photon_MissingHash";
|
||||
case 201: return "Photon_CorruptHash"; case 203: return "Photon_DifferentHash";
|
||||
case 300: return "AppData_Runtime_LengthMismatch"; case 301: return "AppData_Runtime_LastWriteTimeMismatch";
|
||||
case 302: return "AppData_Runtime_FileModified"; case 310: return "AppData_Boot_InvalidSignature";
|
||||
case 311: return "AppData_Boot_UnableToVerifySignatures"; case 320: return "Config_MissingHash";
|
||||
case 321: return "Config_DifferentHash"; case 400: return "Photon_InstantiateTool";
|
||||
case 500: return "Memory_Hash_Mismatch"; case 600: return "Driver_Invalid_Signature";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
static void log_caller(const char *what, void *ret) {
|
||||
uintptr_t a = (uintptr_t)ret;
|
||||
if (g_il2cpp_base && a > g_il2cpp_base)
|
||||
LOGI("[caller] %s <- libil2cpp rva 0x%lx", what, (unsigned long)(a - g_il2cpp_base));
|
||||
else
|
||||
LOGI("[caller] %s <- %p", what, ret);
|
||||
}
|
||||
|
||||
static void *(*orig_CheatReport)(int reason, void *msg, uint64_t code, void *method);
|
||||
static void *hk_CheatReport(int reason, void *msg, uint64_t code, void *method) {
|
||||
char text[512];
|
||||
str_to_utf8(msg, text, sizeof(text));
|
||||
LOGI("[integrity] report reason=%d (%s) msg=\"%s\"", reason, cheat_reason(reason), text);
|
||||
log_caller("report", __builtin_return_address(0));
|
||||
return orig_CheatReport(reason, msg, code, method);
|
||||
}
|
||||
|
||||
static void (*orig_FatalQuit)(int code, void *reason, void *method);
|
||||
static void hk_FatalQuit(int code, void *reason, void *method) {
|
||||
char msg[512];
|
||||
str_to_utf8(reason, msg, sizeof(msg));
|
||||
LOGI("[exit] FatalApplicationQuit(%d, \"%s\")", code, msg);
|
||||
log_caller("fatal quit", __builtin_return_address(0));
|
||||
orig_FatalQuit(code, reason, method);
|
||||
}
|
||||
|
||||
static void (*orig_TryQuit)(int code, void *method);
|
||||
static void hk_TryQuit(int code, void *method) {
|
||||
LOGI("[exit] TryApplicationQuit(%d)", code);
|
||||
log_caller("try quit", __builtin_return_address(0));
|
||||
orig_TryQuit(code, method);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- oculus platform -----
|
||||
|
||||
typedef bool (*ovr_IsError_t)(void *m);
|
||||
typedef unsigned (*ovr_GetType_t)(void *m);
|
||||
static ovr_IsError_t orig_ovr_IsError;
|
||||
static ovr_GetType_t ovr_GetType;
|
||||
|
||||
static bool hk_ovr_IsError(void *m) {
|
||||
bool real = orig_ovr_IsError(m);
|
||||
if (!real || !ovr_GetType) return real;
|
||||
unsigned t = ovr_GetType(m);
|
||||
if (t == kMsgEntitlementGetIsViewerEntitled || t == kMsgUserGetUserProof) return false;
|
||||
return real;
|
||||
}
|
||||
|
||||
static void *(*orig_ovr_GetUserProof)(void *m);
|
||||
static void *hk_ovr_GetUserProof(void *m) {
|
||||
void *p = orig_ovr_GetUserProof ? orig_ovr_GetUserProof(m) : nullptr;
|
||||
return p ? p : (void *)0x1;
|
||||
}
|
||||
|
||||
static const char *(*orig_ovr_GetNonce)(void *proof);
|
||||
static const char *hk_ovr_GetNonce(void *proof) {
|
||||
if (proof && proof != (void *)0x1 && orig_ovr_GetNonce) {
|
||||
const char *n = orig_ovr_GetNonce(proof);
|
||||
if (n && n[0]) return n;
|
||||
}
|
||||
return kPlaceholderNonce;
|
||||
}
|
||||
|
||||
static unsigned long long (*orig_ovr_GetLoggedInUserID)();
|
||||
static unsigned long long hk_ovr_GetLoggedInUserID() {
|
||||
unsigned long long real = orig_ovr_GetLoggedInUserID ? orig_ovr_GetLoggedInUserID() : 0;
|
||||
return real ? real : kNoPlatformUserId;
|
||||
}
|
||||
|
||||
static unsigned long long (*orig_ovr_User_GetID)(void *u);
|
||||
static unsigned long long hk_ovr_User_GetID(void *u) {
|
||||
unsigned long long real = orig_ovr_User_GetID ? orig_ovr_User_GetID(u) : 0;
|
||||
return real ? real : kNoPlatformUserId;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- init ------
|
||||
|
||||
static uintptr_t module_base(const char *name) {
|
||||
FILE *f = fopen("/proc/self/maps", "r");
|
||||
if (!f) return 0;
|
||||
char line[512];
|
||||
uintptr_t base = 0;
|
||||
while (fgets(line, sizeof(line), f))
|
||||
if (strstr(line, name)) { base = (uintptr_t)strtoull(line, nullptr, 16); break; }
|
||||
fclose(f);
|
||||
return base;
|
||||
}
|
||||
|
||||
static uint64_t fnv1a(const unsigned char *d, size_t n) {
|
||||
uint64_t h = 0xCBF29CE484222325ULL;
|
||||
for (size_t i = 0; i < n; i++) { h ^= d[i]; h *= 0x100000001B3ULL; }
|
||||
return h;
|
||||
}
|
||||
|
||||
// Arm a target by id: verify the 20-byte fingerprint, then install the hook.
|
||||
static bool arm(const DnTarget *t, void *hook, void **orig) {
|
||||
void *addr = (void *)(g_il2cpp_base + t->rva);
|
||||
uint64_t fp = fnv1a((const unsigned char *)addr, DN_HOOK_BYTES);
|
||||
if (fp != t->fingerprint) {
|
||||
LOGE("REFUSING %s: rva 0x%lx fingerprint 0x%llx != expected 0x%llx (wrong build?)",
|
||||
t->id, (unsigned long)t->rva, (unsigned long long)fp,
|
||||
(unsigned long long)t->fingerprint);
|
||||
return false;
|
||||
}
|
||||
void *dummy = nullptr;
|
||||
A64HookFunction(addr, hook, orig ? orig : &dummy);
|
||||
LOGI("armed %s @ rva 0x%lx", t->id, (unsigned long)t->rva);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool resolve_il2cpp(void *h) {
|
||||
#define SYM(n) n = (n##_t)dlsym(h, #n); if (!n) { LOGE("dlsym " #n " failed"); return false; }
|
||||
SYM(il2cpp_object_get_class)
|
||||
SYM(il2cpp_class_get_method_from_name)
|
||||
SYM(il2cpp_object_new)
|
||||
SYM(il2cpp_string_new)
|
||||
SYM(il2cpp_runtime_invoke)
|
||||
#undef SYM
|
||||
return true;
|
||||
}
|
||||
|
||||
static void arm_oculus_platform_bypass() {
|
||||
void *ovr = nullptr;
|
||||
for (int i = 0; i < 600; i++) {
|
||||
ovr = dlopen("libovrplatformloader.so", RTLD_NOLOAD | RTLD_NOW);
|
||||
if (ovr) break;
|
||||
usleep(100 * 1000);
|
||||
}
|
||||
if (!ovr) { LOGE("libovrplatformloader.so never appeared; platform bypass not armed"); return; }
|
||||
ovr_GetType = (ovr_GetType_t)dlsym(ovr, "ovr_Message_GetType");
|
||||
void *isErr = dlsym(ovr, "ovr_Message_IsError");
|
||||
if (ovr_GetType && isErr) {
|
||||
A64HookFunction(isErr, (void *)hk_ovr_IsError, (void **)&orig_ovr_IsError);
|
||||
LOGI("platform entitlement/proof bypass armed (no Meta app id configured)");
|
||||
} else {
|
||||
LOGE("ovr symbols missing; platform bypass not armed");
|
||||
return;
|
||||
}
|
||||
void *a;
|
||||
if ((a = dlsym(ovr, "ovr_GetLoggedInUserID")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetLoggedInUserID, (void **)&orig_ovr_GetLoggedInUserID);
|
||||
if ((a = dlsym(ovr, "ovr_User_GetID")))
|
||||
A64HookFunction(a, (void *)hk_ovr_User_GetID, (void **)&orig_ovr_User_GetID);
|
||||
if ((a = dlsym(ovr, "ovr_Message_GetUserProof")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetUserProof, (void **)&orig_ovr_GetUserProof);
|
||||
if ((a = dlsym(ovr, "ovr_UserProof_GetNonce")))
|
||||
A64HookFunction(a, (void *)hk_ovr_GetNonce, (void **)&orig_ovr_GetNonce);
|
||||
}
|
||||
|
||||
static void *init_thread(void *) {
|
||||
void *h = nullptr;
|
||||
for (int i = 0; i < 600; i++) {
|
||||
h = dlopen("libil2cpp.so", RTLD_NOLOAD | RTLD_NOW);
|
||||
if (h) break;
|
||||
usleep(100 * 1000);
|
||||
}
|
||||
if (!h) { LOGE("libil2cpp.so never appeared; aborting"); return nullptr; }
|
||||
g_il2cpp_base = module_base("libil2cpp.so");
|
||||
if (!g_il2cpp_base) { LOGE("could not find libil2cpp.so base"); return nullptr; }
|
||||
if (!resolve_il2cpp(h)) return nullptr;
|
||||
dn_load_runtime_config(g_server_host, sizeof(g_server_host), g_oculus_appid, sizeof(g_oculus_appid),
|
||||
g_photon_realtime, g_photon_voice, g_photon_chat,
|
||||
g_photon_server, sizeof(g_photon_server), &g_photon_port,
|
||||
g_photon_region, sizeof(g_photon_region),
|
||||
&g_tls_bypass, &g_accept_images, &g_log_http, &g_debug_http, &g_body_max);
|
||||
|
||||
LOGI("DorkNet.Quest init: build %s/%s il2cpp @ 0x%lx; host-rewrite -> *.%s",
|
||||
DN_TARGET_VERSION_NAME, DN_TARGET_VERSION_CODE, (unsigned long)g_il2cpp_base, g_server_host);
|
||||
|
||||
arm(&dn_targets.http_send_request, (void *)hk_SendRequest, (void **)&orig_SendRequest);
|
||||
if (g_log_http)
|
||||
arm(&dn_targets.http_call_callback, (void *)hk_CallCallback, (void **)&orig_CallCallback);
|
||||
arm(&dn_targets.photon_app_settings, (void *)hk_Photon, (void **)&orig_Photon);
|
||||
|
||||
if (g_oculus_appid[0]) {
|
||||
arm(&dn_targets.oculus_get_app_id, (void *)hk_get_AppID, nullptr);
|
||||
arm(&dn_targets.oculus_get_mobile_app_id, (void *)hk_get_AppID, nullptr);
|
||||
LOGI("oculus app id override: %s", g_oculus_appid);
|
||||
}
|
||||
|
||||
if (g_tls_bypass) {
|
||||
arm(&dn_targets.tls_unity_verify, (void *)hk_UnityTls, (void **)&orig_UnityTls);
|
||||
arm(&dn_targets.tls_bc_notify_server_certificate, (void *)hk_NotifyServerCertificate, nullptr);
|
||||
arm(&dn_targets.pin_nameserver, (void *)hk_pin_true4, nullptr);
|
||||
arm(&dn_targets.pin_https, (void *)hk_pin_true4b, nullptr);
|
||||
arm(&dn_targets.pin_bc_verifier, (void *)hk_pin_true4, nullptr);
|
||||
}
|
||||
|
||||
if (g_accept_images)
|
||||
arm(&dn_targets.image_signature_verify, (void *)hk_image_true, nullptr);
|
||||
|
||||
// Integrity callbacks that would reject a re-signed, hook-loaded client.
|
||||
arm(&dn_targets.hile_warning_callback, (void *)hk_void1, nullptr);
|
||||
arm(&dn_targets.unknown_dll_callback, (void *)hk_void0, nullptr);
|
||||
arm(&dn_targets.file_hash_callback, (void *)hk_void1, nullptr);
|
||||
arm(&dn_targets.toxmod_can_initialize, (void *)hk_toxmod_false, nullptr);
|
||||
|
||||
// Log-only diagnostics.
|
||||
arm(&dn_targets.cheat_report, (void *)hk_CheatReport, (void **)&orig_CheatReport);
|
||||
arm(&dn_targets.fatal_application_quit, (void *)hk_FatalQuit, (void **)&orig_FatalQuit);
|
||||
arm(&dn_targets.try_application_quit, (void *)hk_TryQuit, (void **)&orig_TryQuit);
|
||||
|
||||
if (!g_oculus_appid[0]) arm_oculus_platform_bypass();
|
||||
LOGI("DorkNet.Quest init complete");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void on_load() {
|
||||
LOGI("DorkNet.Quest hooks loaded (build " __DATE__ " " __TIME__ ")");
|
||||
pthread_t t;
|
||||
pthread_create(&t, nullptr, init_thread, nullptr);
|
||||
pthread_detach(t);
|
||||
}
|
||||
@@ -1,596 +0,0 @@
|
||||
/*
|
||||
* @date : 2018/04/18
|
||||
* @author : Rprop (r_prop@outlook.com)
|
||||
* https://github.com/Rprop/And64InlineHook
|
||||
*/
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Rprop (r_prop@outlook.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
#define __STDC_FORMAT_MACROS
|
||||
#include <inttypes.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <sys/mman.h>
|
||||
#include <android/log.h>
|
||||
|
||||
#if defined(__aarch64__)
|
||||
|
||||
#include "And64InlineHook.hpp"
|
||||
#define A64_MAX_INSTRUCTIONS 5
|
||||
#define A64_MAX_REFERENCES (A64_MAX_INSTRUCTIONS * 2)
|
||||
#define A64_NOP 0xd503201fu
|
||||
#define A64_JNIEXPORT __attribute__((visibility("default")))
|
||||
#define A64_LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, "A64_HOOK", __VA_ARGS__))
|
||||
#ifndef NDEBUG
|
||||
# define A64_LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "A64_HOOK", __VA_ARGS__))
|
||||
#else
|
||||
# define A64_LOGI(...) ((void)0)
|
||||
#endif // NDEBUG
|
||||
typedef uint32_t *__restrict *__restrict instruction;
|
||||
struct context
|
||||
{
|
||||
struct fix_info
|
||||
{
|
||||
uint32_t *bp;
|
||||
uint32_t ls; // left-shift counts
|
||||
uint32_t ad; // & operand
|
||||
};
|
||||
struct insns_info
|
||||
{
|
||||
union
|
||||
{
|
||||
uint64_t insu;
|
||||
int64_t ins;
|
||||
void *insp;
|
||||
};
|
||||
fix_info fmap[A64_MAX_REFERENCES];
|
||||
};
|
||||
int64_t basep;
|
||||
int64_t endp;
|
||||
insns_info dat[A64_MAX_INSTRUCTIONS];
|
||||
|
||||
public:
|
||||
inline bool is_in_fixing_range(const int64_t absolute_addr) {
|
||||
return absolute_addr >= this->basep && absolute_addr < this->endp;
|
||||
}
|
||||
inline intptr_t get_ref_ins_index(const int64_t absolute_addr) {
|
||||
return static_cast<intptr_t>((absolute_addr - this->basep) / sizeof(uint32_t));
|
||||
}
|
||||
inline intptr_t get_and_set_current_index(uint32_t *__restrict inp, uint32_t *__restrict outp) {
|
||||
intptr_t current_idx = this->get_ref_ins_index(reinterpret_cast<int64_t>(inp));
|
||||
this->dat[current_idx].insp = outp;
|
||||
return current_idx;
|
||||
}
|
||||
inline void reset_current_ins(const intptr_t idx, uint32_t *__restrict outp) {
|
||||
this->dat[idx].insp = outp;
|
||||
}
|
||||
void insert_fix_map(const intptr_t idx, uint32_t *bp, uint32_t ls = 0u, uint32_t ad = 0xffffffffu) {
|
||||
for (auto &f : this->dat[idx].fmap) {
|
||||
if (f.bp == NULL) {
|
||||
f.bp = bp;
|
||||
f.ls = ls;
|
||||
f.ad = ad;
|
||||
return;
|
||||
} //if
|
||||
}
|
||||
// What? GGing..
|
||||
}
|
||||
void process_fix_map(const intptr_t idx) {
|
||||
for (auto &f : this->dat[idx].fmap) {
|
||||
if (f.bp == NULL) break;
|
||||
*(f.bp) = *(f.bp) | (((int32_t(this->dat[idx].ins - reinterpret_cast<int64_t>(f.bp)) >> 2) << f.ls) & f.ad);
|
||||
f.bp = NULL;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
#define __intval(p) reinterpret_cast<intptr_t>(p)
|
||||
#define __uintval(p) reinterpret_cast<uintptr_t>(p)
|
||||
#define __ptr(p) reinterpret_cast<void *>(p)
|
||||
#define __page_size 4096
|
||||
#define __page_align(n) __align_up(static_cast<uintptr_t>(n), __page_size)
|
||||
#define __ptr_align(x) __ptr(__align_down(reinterpret_cast<uintptr_t>(x), __page_size))
|
||||
#define __align_up(x, n) (((x) + ((n) - 1)) & ~((n) - 1))
|
||||
#define __align_down(x, n) ((x) & -(n))
|
||||
#define __countof(x) static_cast<intptr_t>(sizeof(x) / sizeof((x)[0])) // must be signed
|
||||
#define __atomic_increase(p) __sync_add_and_fetch(p, 1)
|
||||
#define __sync_cmpswap(p, v, n) __sync_bool_compare_and_swap(p, v, n)
|
||||
#define __predict_true(exp) __builtin_expect((exp) != 0, 1)
|
||||
#define __flush_cache(c, n) __builtin___clear_cache(reinterpret_cast<char *>(c), reinterpret_cast<char *>(c) + n)
|
||||
#define __make_rwx(p, n) ::mprotect(__ptr_align(p), \
|
||||
__page_align(__uintval(p) + n) != __page_align(__uintval(p)) ? __page_align(n) + __page_size : __page_align(n), \
|
||||
PROT_READ | PROT_WRITE | PROT_EXEC)
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_branch_imm(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
static constexpr uint32_t mbits = 6u;
|
||||
static constexpr uint32_t mask = 0xfc000000u; // 0b11111100000000000000000000000000
|
||||
static constexpr uint32_t rmask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
static constexpr uint32_t op_b = 0x14000000u; // "b" ADDR_PCREL26
|
||||
static constexpr uint32_t op_bl = 0x94000000u; // "bl" ADDR_PCREL26
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
const uint32_t opc = ins & mask;
|
||||
switch (opc) {
|
||||
case op_b:
|
||||
case op_bl:
|
||||
{
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>(ins << mbits) >> (mbits - 2u)); // sign-extended
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
// whether the branch should be converted to absolute jump
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (rmask >> 1)) {
|
||||
bool b_aligned = (reinterpret_cast<uint64_t>(*outpp + 2) & 7u) == 0u;
|
||||
if (opc == op_b) {
|
||||
if (b_aligned != true) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = 0x58000051u; // LDR X17, #0x8
|
||||
(*outpp)[1] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 4;
|
||||
} else {
|
||||
if (b_aligned == true) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = 0x58000071u; // LDR X17, #12
|
||||
(*outpp)[1] = 0x1000009eu; // ADR X30, #16
|
||||
(*outpp)[2] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 3, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 5;
|
||||
} //if
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, 0u, rmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = opc | (new_pc_offset & ~mask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_cond_comp_test_branch(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t lmask01 = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t mask0 = 0xff000010u; // 0b11111111000000000000000000010000
|
||||
static constexpr uint32_t op_bc = 0x54000000u; // "b.c" ADDR_PCREL19
|
||||
static constexpr uint32_t mask1 = 0x7f000000u; // 0b01111111000000000000000000000000
|
||||
static constexpr uint32_t op_cbz = 0x34000000u; // "cbz" Rt, ADDR_PCREL19
|
||||
static constexpr uint32_t op_cbnz = 0x35000000u; // "cbnz" Rt, ADDR_PCREL19
|
||||
static constexpr uint32_t lmask2 = 0xfff8001fu; // 0b11111111111110000000000000011111
|
||||
static constexpr uint32_t mask2 = 0x7f000000u; // 0b01111111000000000000000000000000
|
||||
static constexpr uint32_t op_tbz = 0x36000000u; // 0b00110110000000000000000000000000 "tbz" Rt, BIT_NUM, ADDR_PCREL14
|
||||
static constexpr uint32_t op_tbnz = 0x37000000u; // 0b00110111000000000000000000000000 "tbnz" Rt, BIT_NUM, ADDR_PCREL14
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
uint32_t lmask = lmask01;
|
||||
if ((ins & mask0) != op_bc) {
|
||||
uint32_t opc = ins & mask1;
|
||||
if (opc != op_cbz && opc != op_cbnz) {
|
||||
opc = ins & mask2;
|
||||
if (opc != op_tbz && opc != op_tbnz) {
|
||||
return false;
|
||||
} //if
|
||||
lmask = lmask2;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
const uint32_t msb = __builtin_clz(~lmask);
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (static_cast<int32_t>((ins & ~lmask) << msb) >> (lsb - 2u + msb));
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (~lmask >> (lsb + 1))) {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 4) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
(*outpp)[0] = (((8u >> 2u) << lsb) & ~lmask) | (ins & lmask); // B.C #0x8
|
||||
(*outpp)[1] = 0x14000005u; // B #0x14
|
||||
(*outpp)[2] = 0x58000051u; // LDR X17, #0x8
|
||||
(*outpp)[3] = 0xd61f0220u; // BR X17
|
||||
memcpy(*outpp + 4, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 6;
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, lsb, ~lmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~lmask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_loadlit(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
const uint32_t ins = *(*inpp);
|
||||
|
||||
// memory prefetch("prfm"), just skip it
|
||||
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897420050.html
|
||||
if ((ins & 0xff000000u) == 0xd8000000u) {
|
||||
ctxp->process_fix_map(ctxp->get_and_set_current_index(*inpp, *outpp));
|
||||
++(*inpp);
|
||||
return true;
|
||||
} //if
|
||||
|
||||
static constexpr uint32_t msb = 8u;
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t mask_30 = 0x40000000u; // 0b01000000000000000000000000000000
|
||||
static constexpr uint32_t mask_31 = 0x80000000u; // 0b10000000000000000000000000000000
|
||||
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t mask_ldr = 0xbf000000u; // 0b10111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldr = 0x18000000u; // 0b00011000000000000000000000000000 "LDR Wt/Xt, label" | ADDR_PCREL19
|
||||
static constexpr uint32_t mask_ldrv = 0x3f000000u; // 0b00111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldrv = 0x1c000000u; // 0b00011100000000000000000000000000 "LDR St/Dt/Qt, label" | ADDR_PCREL19
|
||||
static constexpr uint32_t mask_ldrsw = 0xff000000u; // 0b11111111000000000000000000000000
|
||||
static constexpr uint32_t op_ldrsw = 0x98000000u; // "LDRSW Xt, label" | ADDR_PCREL19 | load register signed word
|
||||
// LDR S0, #0 | 0b00011100000000000000000000000000 | 32-bit
|
||||
// LDR D0, #0 | 0b01011100000000000000000000000000 | 64-bit
|
||||
// LDR Q0, #0 | 0b10011100000000000000000000000000 | 128-bit
|
||||
// INVALID | 0b11011100000000000000000000000000 | may be 256-bit
|
||||
|
||||
uint32_t mask = mask_ldr;
|
||||
uintptr_t faligned = (ins & mask_30) ? 7u : 3u;
|
||||
if ((ins & mask_ldr) != op_ldr) {
|
||||
mask = mask_ldrv;
|
||||
if (faligned != 7u)
|
||||
faligned = (ins & mask_31) ? 15u : 3u;
|
||||
if ((ins & mask_ldrv) != op_ldrv) {
|
||||
if ((ins & mask_ldrsw) != op_ldrsw) {
|
||||
return false;
|
||||
} //if
|
||||
mask = mask_ldrsw;
|
||||
faligned = 7u;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
intptr_t current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + ((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u);
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2; // shifted
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
// special_fix_type may encounter issue when there are mixed data and code
|
||||
if (special_fix_type || (llabs(new_pc_offset) + (faligned + 1u - 4u) / 4u) >= (~lmask >> (lsb + 1))) { // inaccurate, but it works
|
||||
while ((reinterpret_cast<uint64_t>(*outpp + 2) & faligned) != 0u) {
|
||||
*(*outpp)++ = A64_NOP;
|
||||
}
|
||||
ctxp->reset_current_ins(current_idx, *outpp);
|
||||
|
||||
// Note that if memory at absolute_addr is writeable (non-const), we will fail to fetch it.
|
||||
// And what's worse, we may unexpectedly overwrite something if special_fix_type is true...
|
||||
uint32_t ns = static_cast<uint32_t>((faligned + 1) / sizeof(uint32_t));
|
||||
(*outpp)[0] = (((8u >> 2u) << lsb) & ~mask) | (ins & lmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000001u + ns; // B #0xc
|
||||
memcpy(*outpp + 2, reinterpret_cast<void *>(absolute_addr), faligned + 1);
|
||||
*outpp += 2 + ns;
|
||||
} else {
|
||||
faligned >>= 2; // new_pc_offset is shifted and 4-byte aligned
|
||||
while ((new_pc_offset & faligned) != 0) {
|
||||
*(*outpp)++ = A64_NOP;
|
||||
new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp)) >> 2;
|
||||
}
|
||||
ctxp->reset_current_ins(current_idx, *outpp);
|
||||
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << lsb) & ~mask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
|
||||
++(*inpp);
|
||||
return ctxp->process_fix_map(current_idx), true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static bool __fix_pcreladdr(instruction inpp, instruction outpp, context *ctxp)
|
||||
{
|
||||
// Load a PC-relative address into a register
|
||||
// http://infocenter.arm.com/help/topic/com.arm.doc.100069_0608_00_en/pge1427897645644.html
|
||||
static constexpr uint32_t msb = 8u;
|
||||
static constexpr uint32_t lsb = 5u;
|
||||
static constexpr uint32_t mask = 0x9f000000u; // 0b10011111000000000000000000000000
|
||||
static constexpr uint32_t rmask = 0x0000001fu; // 0b00000000000000000000000000011111
|
||||
static constexpr uint32_t lmask = 0xff00001fu; // 0b11111111000000000000000000011111
|
||||
static constexpr uint32_t fmask = 0x00ffffffu; // 0b00000000111111111111111111111111
|
||||
static constexpr uint32_t max_val = 0x001fffffu; // 0b00000000000111111111111111111111
|
||||
static constexpr uint32_t op_adr = 0x10000000u; // "adr" Rd, ADDR_PCREL21
|
||||
static constexpr uint32_t op_adrp = 0x90000000u; // "adrp" Rd, ADDR_ADRP
|
||||
|
||||
const uint32_t ins = *(*inpp);
|
||||
intptr_t current_idx;
|
||||
switch (ins & mask) {
|
||||
case op_adr:
|
||||
{
|
||||
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int64_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
|
||||
int64_t absolute_addr = reinterpret_cast<int64_t>(*inpp) + (((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes);
|
||||
int64_t new_pc_offset = static_cast<int64_t>(absolute_addr - reinterpret_cast<int64_t>(*outpp));
|
||||
bool special_fix_type = ctxp->is_in_fixing_range(absolute_addr);
|
||||
if (!special_fix_type && llabs(new_pc_offset) >= (max_val >> 1)) {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000003u; // B #0xc
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr));
|
||||
*outpp += 4;
|
||||
} else {
|
||||
if (special_fix_type) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr & ~3ull);
|
||||
if (ref_idx <= current_idx) {
|
||||
new_pc_offset = static_cast<int64_t>(ctxp->dat[ref_idx].ins - reinterpret_cast<int64_t>(*outpp));
|
||||
} else {
|
||||
ctxp->insert_fix_map(ref_idx, *outpp, lsb, fmask);
|
||||
new_pc_offset = 0;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
// the lsb_bytes will never be changed, so we can use lmask to keep it
|
||||
(*outpp)[0] = (static_cast<uint32_t>(new_pc_offset << (lsb - 2u)) & fmask) | (ins & lmask);
|
||||
++(*outpp);
|
||||
} //if
|
||||
}
|
||||
break;
|
||||
case op_adrp:
|
||||
{
|
||||
current_idx = ctxp->get_and_set_current_index(*inpp, *outpp);
|
||||
int32_t lsb_bytes = static_cast<uint32_t>(ins << 1u) >> 30u;
|
||||
int64_t absolute_addr = (reinterpret_cast<int64_t>(*inpp) & ~0xfffll) + ((((static_cast<int32_t>(ins << msb) >> (msb + lsb - 2u)) & ~3u) | lsb_bytes) << 12);
|
||||
A64_LOGI("ins = 0x%.8X, pc = %p, abs_addr = %p",
|
||||
ins, *inpp, reinterpret_cast<int64_t *>(absolute_addr));
|
||||
if (ctxp->is_in_fixing_range(absolute_addr)) {
|
||||
intptr_t ref_idx = ctxp->get_ref_ins_index(absolute_addr/* & ~3ull*/);
|
||||
if (ref_idx > current_idx) {
|
||||
// the bottom 12 bits of absolute_addr are masked out,
|
||||
// so ref_idx must be less than or equal to current_idx!
|
||||
A64_LOGE("ref_idx must be less than or equal to current_idx!");
|
||||
} //if
|
||||
|
||||
// *absolute_addr may be changed due to relocation fixing
|
||||
A64_LOGI("What is the correct way to fix this?");
|
||||
*(*outpp)++ = ins; // 0x90000000u;
|
||||
} else {
|
||||
if ((reinterpret_cast<uint64_t>(*outpp + 2) & 7u) != 0u) {
|
||||
(*outpp)[0] = A64_NOP;
|
||||
ctxp->reset_current_ins(current_idx, ++(*outpp));
|
||||
} //if
|
||||
|
||||
(*outpp)[0] = 0x58000000u | (((8u >> 2u) << lsb) & ~mask) | (ins & rmask); // LDR #0x8
|
||||
(*outpp)[1] = 0x14000003u; // B #0xc
|
||||
memcpy(*outpp + 2, &absolute_addr, sizeof(absolute_addr)); // potential overflow?
|
||||
*outpp += 4;
|
||||
} //if
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
ctxp->process_fix_map(current_idx);
|
||||
++(*inpp);
|
||||
return true;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static void __fix_instructions(uint32_t *__restrict inp, int32_t count, uint32_t *__restrict outp)
|
||||
{
|
||||
context ctx;
|
||||
ctx.basep = reinterpret_cast<int64_t>(inp);
|
||||
ctx.endp = reinterpret_cast<int64_t>(inp + count);
|
||||
memset(ctx.dat, 0, sizeof(ctx.dat));
|
||||
static_assert(sizeof(ctx.dat) / sizeof(ctx.dat[0]) == A64_MAX_INSTRUCTIONS,
|
||||
"please use A64_MAX_INSTRUCTIONS!");
|
||||
#ifndef NDEBUG
|
||||
if (count > A64_MAX_INSTRUCTIONS) {
|
||||
A64_LOGE("too many fixing instructions!");
|
||||
} //if
|
||||
#endif // NDEBUG
|
||||
|
||||
uint32_t *const outp_base = outp;
|
||||
|
||||
while (--count >= 0) {
|
||||
if (__fix_branch_imm(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_cond_comp_test_branch(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_loadlit(&inp, &outp, &ctx)) continue;
|
||||
if (__fix_pcreladdr(&inp, &outp, &ctx)) continue;
|
||||
|
||||
// without PC-relative offset
|
||||
ctx.process_fix_map(ctx.get_and_set_current_index(inp, outp));
|
||||
*(outp++) = *(inp++);
|
||||
}
|
||||
|
||||
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
auto callback = reinterpret_cast<int64_t>(inp);
|
||||
auto pc_offset = static_cast<int64_t>(callback - reinterpret_cast<int64_t>(outp)) >> 2;
|
||||
if (llabs(pc_offset) >= (mask >> 1)) {
|
||||
if ((reinterpret_cast<uint64_t>(outp + 2) & 7u) != 0u) {
|
||||
outp[0] = A64_NOP;
|
||||
++outp;
|
||||
} //if
|
||||
outp[0] = 0x58000051u; // LDR X17, #0x8
|
||||
outp[1] = 0xd61f0220u; // BR X17
|
||||
*reinterpret_cast<int64_t *>(outp + 2) = callback;
|
||||
outp += 4;
|
||||
} else {
|
||||
outp[0] = 0x14000000u | (pc_offset & mask); // "B" ADDR_PCREL26
|
||||
++outp;
|
||||
} //if
|
||||
|
||||
const uintptr_t total = (outp - outp_base) * sizeof(uint32_t);
|
||||
__flush_cache(outp_base, total); // necessary
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
extern "C" {
|
||||
static __attribute__((__aligned__(__page_size))) uint32_t __insns_pool[A64_MAX_BACKUPS][A64_MAX_INSTRUCTIONS * 10];
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
class A64HookInit
|
||||
{
|
||||
public:
|
||||
A64HookInit()
|
||||
{
|
||||
__make_rwx(__insns_pool, sizeof(__insns_pool));
|
||||
A64_LOGI("insns pool initialized.");
|
||||
}
|
||||
};
|
||||
static A64HookInit __init;
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
static uint32_t *FastAllocateTrampoline()
|
||||
{
|
||||
static_assert((A64_MAX_INSTRUCTIONS * 10 * sizeof(uint32_t)) % 8 == 0, "8-byte align");
|
||||
static volatile int32_t __index = -1;
|
||||
|
||||
int32_t i = __atomic_increase(&__index);
|
||||
if (__predict_true(i >= 0 && i < __countof(__insns_pool))) {
|
||||
return __insns_pool[i];
|
||||
} //if
|
||||
|
||||
A64_LOGE("failed to allocate trampoline!");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
A64_JNIEXPORT void *A64HookFunctionV(void *const symbol, void *const replace,
|
||||
void *const rwx, const uintptr_t rwx_size)
|
||||
{
|
||||
static constexpr uint_fast64_t mask = 0x03ffffffu; // 0b00000011111111111111111111111111
|
||||
|
||||
uint32_t *trampoline = static_cast<uint32_t *>(rwx), *original = static_cast<uint32_t *>(symbol);
|
||||
|
||||
static_assert(A64_MAX_INSTRUCTIONS >= 5, "please fix A64_MAX_INSTRUCTIONS!");
|
||||
auto pc_offset = static_cast<int64_t>(__intval(replace) - __intval(symbol)) >> 2;
|
||||
if (llabs(pc_offset) >= (mask >>1)) {
|
||||
int32_t count = (reinterpret_cast<uint64_t>(original + 2) & 7u) != 0u ? 5 : 4;
|
||||
if (trampoline) {
|
||||
if (rwx_size < count * 10u) {
|
||||
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", count * 10u);
|
||||
return NULL;
|
||||
} //if
|
||||
__fix_instructions(original, count, trampoline);
|
||||
} //if
|
||||
|
||||
if (__make_rwx(original, 5 * sizeof(uint32_t)) == 0) {
|
||||
if (count == 5) {
|
||||
original[0] = A64_NOP;
|
||||
++original;
|
||||
} //if
|
||||
original[0] = 0x58000051u; // LDR X17, #0x8
|
||||
original[1] = 0xd61f0220u; // BR X17
|
||||
*reinterpret_cast<int64_t *>(original + 2) = __intval(replace);
|
||||
__flush_cache(symbol, 5 * sizeof(uint32_t));
|
||||
|
||||
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
|
||||
symbol, replace, 5 * sizeof(uint32_t));
|
||||
} else {
|
||||
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
|
||||
errno, original, 5 * sizeof(uint32_t));
|
||||
trampoline = NULL;
|
||||
} //if
|
||||
} else {
|
||||
if (trampoline) {
|
||||
if (rwx_size < 1u * 10u) {
|
||||
A64_LOGE("rwx size is too small to hold %u bytes backup instructions!", 1u * 10u);
|
||||
return NULL;
|
||||
} //if
|
||||
__fix_instructions(original, 1, trampoline);
|
||||
} //if
|
||||
|
||||
if (__make_rwx(original, 1 * sizeof(uint32_t)) == 0) {
|
||||
__sync_cmpswap(original, *original, 0x14000000u | (pc_offset & mask)); // "B" ADDR_PCREL26
|
||||
__flush_cache(symbol, 1 * sizeof(uint32_t));
|
||||
|
||||
A64_LOGI("inline hook %p->%p successfully! %zu bytes overwritten",
|
||||
symbol, replace, 1 * sizeof(uint32_t));
|
||||
} else {
|
||||
A64_LOGE("mprotect failed with errno = %d, p = %p, size = %zu",
|
||||
errno, original, 1 * sizeof(uint32_t));
|
||||
trampoline = NULL;
|
||||
} //if
|
||||
} //if
|
||||
|
||||
return trampoline;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
A64_JNIEXPORT void A64HookFunction(void *const symbol, void *const replace, void **result)
|
||||
{
|
||||
void *trampoline = NULL;
|
||||
if (result != NULL) {
|
||||
trampoline = FastAllocateTrampoline();
|
||||
*result = trampoline;
|
||||
if (trampoline == NULL) return;
|
||||
} //if
|
||||
|
||||
// fix Android 10 .text segment is read-only by default
|
||||
__make_rwx(symbol, 5 * sizeof(size_t));
|
||||
|
||||
trampoline = A64HookFunctionV(symbol, replace, trampoline, A64_MAX_INSTRUCTIONS * 10u);
|
||||
if (trampoline == NULL && result != NULL) {
|
||||
*result = NULL;
|
||||
} //if
|
||||
}
|
||||
}
|
||||
|
||||
#endif // defined(__aarch64__)
|
||||
@@ -1,42 +0,0 @@
|
||||
/*
|
||||
* @date : 2018/04/18
|
||||
* @author : Rprop (r_prop@outlook.com)
|
||||
* https://github.com/Rprop/And64InlineHook
|
||||
*/
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Rprop (r_prop@outlook.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
#pragma once
|
||||
#define A64_MAX_BACKUPS 256
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
void A64HookFunction(void *const symbol, void *const replace, void **result);
|
||||
void *A64HookFunctionV(void *const symbol, void *const replace,
|
||||
void *const rwx, const uintptr_t rwx_size);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 RLib
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"versionName": "20230317",
|
||||
"versionCode": "2128210",
|
||||
"il2cppSha256": "842a496cc59948af03fe435e0abe0a173cbf2a6bf439be48096ad2b2c2dccbb9",
|
||||
"hookBytes": 20,
|
||||
"targets": {
|
||||
"http_send_request": {
|
||||
"rva": 108025384,
|
||||
"length": 456,
|
||||
"fingerprint": "0x8cd03afe41eecdef",
|
||||
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
|
||||
},
|
||||
"http_call_callback": {
|
||||
"rva": 108061608,
|
||||
"length": 424,
|
||||
"fingerprint": "0x98ed61ac83527269",
|
||||
"why": "Logs each finished request's status (and the body of non-2xx responses)."
|
||||
},
|
||||
"oculus_get_app_id": {
|
||||
"rva": 166405008,
|
||||
"length": 32,
|
||||
"fingerprint": "0xaa5a50a8d4246c46",
|
||||
"why": "Returns the operator's Meta app id when OculusAppId is set."
|
||||
},
|
||||
"oculus_get_mobile_app_id": {
|
||||
"rva": 166380836,
|
||||
"length": 32,
|
||||
"fingerprint": "0x5f5d3bc64dedb8de",
|
||||
"why": "Same as get_AppID; the Quest build reads this getter."
|
||||
},
|
||||
"photon_app_settings": {
|
||||
"rva": 69268008,
|
||||
"length": 196,
|
||||
"fingerprint": "0xc8050d166579e43b",
|
||||
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
|
||||
},
|
||||
"tls_unity_verify": {
|
||||
"rva": 159789160,
|
||||
"length": 884,
|
||||
"fingerprint": "0xf8d4a696f15e6f0e",
|
||||
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
|
||||
},
|
||||
"tls_bc_notify_server_certificate": {
|
||||
"rva": 103301824,
|
||||
"length": 284,
|
||||
"fingerprint": "0x7f061bfc843e881f",
|
||||
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
|
||||
},
|
||||
"pin_nameserver": {
|
||||
"rva": 96989180,
|
||||
"length": 332,
|
||||
"fingerprint": "0x659c928fe96e1008",
|
||||
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
|
||||
},
|
||||
"pin_https": {
|
||||
"rva": 103249928,
|
||||
"length": 1032,
|
||||
"fingerprint": "0x943a238f04d54362",
|
||||
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
|
||||
},
|
||||
"pin_bc_verifier": {
|
||||
"rva": 82659376,
|
||||
"length": 1128,
|
||||
"fingerprint": "0x60415d1d29a7315d",
|
||||
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
|
||||
},
|
||||
"image_signature_verify": {
|
||||
"rva": 97302848,
|
||||
"length": 732,
|
||||
"fingerprint": "0xe1549ef6f4691a33",
|
||||
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
|
||||
},
|
||||
"hile_warning_callback": {
|
||||
"rva": 75750004,
|
||||
"length": 352,
|
||||
"fingerprint": "0xccf510feeada40d9",
|
||||
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
|
||||
},
|
||||
"unknown_dll_callback": {
|
||||
"rva": 57998052,
|
||||
"length": 112,
|
||||
"fingerprint": "0x84e1fed3a70e7560",
|
||||
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
|
||||
},
|
||||
"file_hash_callback": {
|
||||
"rva": 52872472,
|
||||
"length": 1052,
|
||||
"fingerprint": "0x03a8b6f1b373e600",
|
||||
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
|
||||
},
|
||||
"toxmod_can_initialize": {
|
||||
"rva": 55928080,
|
||||
"length": 1040,
|
||||
"fingerprint": "0xf72dd6f5dbb16216",
|
||||
"why": "Third-party voice moderation service; forced false, as on PC."
|
||||
},
|
||||
"cheat_report": {
|
||||
"rva": 106127656,
|
||||
"length": 2248,
|
||||
"fingerprint": "0x9c1e0ef53e5a3ec8",
|
||||
"why": "Log-only: names any integrity report so a new one is visible in logcat."
|
||||
},
|
||||
"fatal_application_quit": {
|
||||
"rva": 55748824,
|
||||
"length": 348,
|
||||
"fingerprint": "0x8cd03afe41eecdef",
|
||||
"why": "Log-only: prints the quit reason and caller."
|
||||
},
|
||||
"try_application_quit": {
|
||||
"rva": 55748412,
|
||||
"length": 412,
|
||||
"fingerprint": "0xb12b0044ac4f844f",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Hook targets, identified by the class header and method signature exactly as",
|
||||
"Il2CppDumper prints them in dump.cs. scripts/resolve_targets.py turns this into a",
|
||||
"per-build table (RVA, length, code fingerprint). Obfuscated names are fixed per",
|
||||
"release and shared with the PC build of the same release; when rebasing, re-check",
|
||||
"each one against the PC decompile and update the signature here."
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"id": "http_send_request",
|
||||
"class": "public static class HTTPManager ",
|
||||
"method": "public static HTTPRequest SendRequest(HTTPRequest request)",
|
||||
"why": "Rewrites <service>.rec.net hosts to <service>.<ServerHost> before a request is queued."
|
||||
},
|
||||
{
|
||||
"id": "http_call_callback",
|
||||
"class": "public sealed class HTTPRequest ",
|
||||
"method": "internal void CallCallback()",
|
||||
"why": "Logs each finished request's status (and the body of non-2xx responses)."
|
||||
},
|
||||
{
|
||||
"id": "oculus_get_app_id",
|
||||
"class": "public sealed class PlatformSettings ",
|
||||
"method": "public static string get_AppID()",
|
||||
"why": "Returns the operator's Meta app id when OculusAppId is set."
|
||||
},
|
||||
{
|
||||
"id": "oculus_get_mobile_app_id",
|
||||
"class": "public sealed class PlatformSettings ",
|
||||
"method": "public static string get_MobileAppID()",
|
||||
"why": "Same as get_AppID; the Quest build reads this getter."
|
||||
},
|
||||
{
|
||||
"id": "photon_app_settings",
|
||||
"class": "internal class NFOMFIAOKFO ",
|
||||
"method": "public AppSettings LBKJOHGFHOO()",
|
||||
"why": "Builds Photon.Realtime.AppSettings with Rec Room's hardcoded Realtime/Voice app ids (PC: same class+method). Postfixed to set the operator's ids."
|
||||
},
|
||||
{
|
||||
"id": "tls_unity_verify",
|
||||
"class": "internal class UnityTlsContext ",
|
||||
"method": "private UnityTls.unitytls_x509verify_result VerifyCallback(UnityTls.unitytls_x509list_ref chain, UnityTls.unitytls_errorstate* errorState)",
|
||||
"why": "Platform TLS trust decision (Mono's bundled CA set). Forced to success when TlsTrustBypass is on."
|
||||
},
|
||||
{
|
||||
"id": "tls_bc_notify_server_certificate",
|
||||
"class": "public class LegacyTlsAuthentication ",
|
||||
"method": "public virtual void NotifyServerCertificate(Certificate serverCertificate)",
|
||||
"why": "BestHTTP BouncyCastle trust hook (PC ClientMod skips the same method). No-op when TlsTrustBypass is on."
|
||||
},
|
||||
{
|
||||
"id": "pin_nameserver",
|
||||
"class": "public class PAPLNIPKAMG ",
|
||||
"method": "private static bool GKHFNFDOKDK(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
|
||||
"why": "Name-server client's certificate callback: chain status + SPKI pin compare. Always accepted (pins never match a self-hosted server)."
|
||||
},
|
||||
{
|
||||
"id": "pin_https",
|
||||
"class": "public static class LJEHIHEHMCN ",
|
||||
"method": "public static bool JFIODJJKMBF(HTTPRequest ODNHKCNDAKN, X509Certificate PGKJCEFFIPB, X509Chain AJJPNBICGLG)",
|
||||
"why": "SPKI pinning for every RecNet request; a miss reports NetworkCertificateMismatch through FPIBGPIAOBI. Always accepted."
|
||||
},
|
||||
{
|
||||
"id": "pin_bc_verifier",
|
||||
"class": "public class LJEHIHEHMCN.NALNEIDKBOC ",
|
||||
"method": "public bool IsValid(Uri HEEAJLIFCHN, X509CertificateStructure[] MFLGMAEAFON)",
|
||||
"why": "The same pin check as an ICertificateVerifyer for the BouncyCastle path. Always accepted."
|
||||
},
|
||||
{
|
||||
"id": "image_signature_verify",
|
||||
"class": "public class KLJOGJHBONK ",
|
||||
"method": "private static bool PBLLFOKJAMK(byte[] HBMMCJPOJGP, byte[] JJGOGMKDLGI)",
|
||||
"why": "RSA-SHA1 check of image Content-Signature against Rec Room's baked public key. Accepted when AcceptUnsignedImages is on."
|
||||
},
|
||||
{
|
||||
"id": "hile_warning_callback",
|
||||
"class": "private sealed class FPIBGPIAOBI.KHIIHOIFDMP ",
|
||||
"method": "internal void <CreateHileWarning>b__0(bool shouldQuit)",
|
||||
"why": "Cheat-warning dialog/quit callback. Skipped, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "unknown_dll_callback",
|
||||
"class": "private sealed class CheatManager.NOBCKPOGHEB ",
|
||||
"method": "internal void <OnUnknownDLLDetected>b__0()",
|
||||
"why": "Unknown-library detection callback (the injected hook library is one). Skipped, as on PC (PC closure class is PNHDGEKCJCH)."
|
||||
},
|
||||
{
|
||||
"id": "file_hash_callback",
|
||||
"class": "private sealed class BEPEAHOOLDB.PBENKLNHBHL ",
|
||||
"method": "internal void <InitializeFileHashChecker>b__0(JFDPIJFDHAD.ACCABIOJGNE result)",
|
||||
"why": "File-hash checker result callback (the APK is re-signed). Skipped, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "toxmod_can_initialize",
|
||||
"class": "public class ToxModVoiceComponent ",
|
||||
"method": "public static bool CanInitializeToxMod()",
|
||||
"why": "Third-party voice moderation service; forced false, as on PC."
|
||||
},
|
||||
{
|
||||
"id": "cheat_report",
|
||||
"class": "public class FPIBGPIAOBI ",
|
||||
"method": "public static LDGADANDBIO IEIHCFLCHBE(FPIBGPIAOBI.HHKINDNBLEE EHPFPOFFCEJ, string EPAIMCEIMPA, Nullable<int> BNCEPENKLDF)",
|
||||
"why": "Log-only: names any integrity report so a new one is visible in logcat."
|
||||
},
|
||||
{
|
||||
"id": "fatal_application_quit",
|
||||
"class": "public class SessionManager ",
|
||||
"method": "public static void FatalApplicationQuit(int DKOGJICFAEP, string EPAIMCEIMPA = \"\")",
|
||||
"why": "Log-only: prints the quit reason and caller."
|
||||
},
|
||||
{
|
||||
"id": "try_application_quit",
|
||||
"class": "public class SessionManager ",
|
||||
"method": "public static void TryApplicationQuit(int DKOGJICFAEP)",
|
||||
"why": "Log-only: prints the quit code and caller."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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,254 +0,0 @@
|
||||
using DorkNet.Server.Services;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The March-2023 client budgets every CircuitsV2 network send against
|
||||
/// <c>RecRoom.CircuitsV2.Config.LimitConfig</c>, which it fills from the
|
||||
/// <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. Retail ships 5 RPCs per
|
||||
/// frame; past that the send is dropped and the room save is flagged
|
||||
/// <c>cv2_rpc_limited</c>. DorkNet serves budgets that lift the cap, and
|
||||
/// these tests pin both the wire shape the client's Newtonsoft reader
|
||||
/// binds and the clamps that keep a hand-edited value from crashing it.
|
||||
/// </summary>
|
||||
public sealed class CircuitsV2LimitsTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public CircuitsV2LimitsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Gameconfigs_endpoint_serves_the_network_budget()
|
||||
{
|
||||
using var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
|
||||
var rows = await client.GetFromJsonAsync<List<GameConfigurationSetting>>(
|
||||
"api/gameconfigs/v1/all");
|
||||
|
||||
var network = rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Network");
|
||||
var entry = JsonDocument.Parse(network.Value).RootElement.EnumerateArray().Single();
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||
// A server that never saved limits (empty column) serves the
|
||||
// current defaults, not the retail values.
|
||||
Assert.Equal("30000", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.Chips").Value);
|
||||
Assert.Equal("0", rows!.Single(r => r.Key == "UGC.CircuitsV2.Limit.InkPerChip").Value);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GameConfigurationSetting> Emit(CircuitsV2LimitSettings limits) =>
|
||||
ServerSettingsService.ToGameConfigurations(
|
||||
ServerSettingsService.DefaultRecCenterDoors(),
|
||||
ServerSettingsService.DefaultDiscoveredGameConfigs(),
|
||||
limits);
|
||||
|
||||
private static string ValueOf(IReadOnlyList<GameConfigurationSetting> rows, string key) =>
|
||||
rows.Single(r => r.Key == key).Value;
|
||||
|
||||
private static JsonElement NetworkOf(CircuitsV2LimitSettings limits) =>
|
||||
JsonDocument.Parse(ValueOf(Emit(limits), "UGC.CircuitsV2.Limit.Network")).RootElement;
|
||||
|
||||
[Fact]
|
||||
public void Defaults_lift_the_rpc_and_byte_budgets()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits());
|
||||
|
||||
Assert.Equal(JsonValueKind.Array, network.ValueKind);
|
||||
var entry = Assert.Single(network.EnumerateArray());
|
||||
// Retail is 5 RPCs / 1024 bytes; anything at those values means the
|
||||
// budget never actually got lifted.
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||
Assert.True(entry.GetProperty("Bytes").GetInt32() >= 1_000_000);
|
||||
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Network_json_uses_the_field_names_the_client_binds()
|
||||
{
|
||||
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits())
|
||||
.EnumerateArray().First();
|
||||
|
||||
// LimitConfig.NetworkConfig's il2cpp field names, verbatim.
|
||||
Assert.True(entry.TryGetProperty("NumPlayersForActivation", out _));
|
||||
Assert.True(entry.TryGetProperty("Bytes", out _));
|
||||
Assert.True(entry.TryGetProperty("BytesTimeWindowFrame60s", out _));
|
||||
Assert.True(entry.TryGetProperty("Rpcs", out _));
|
||||
Assert.True(entry.TryGetProperty("RpcTimeWindowFrame60s", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scalar_limits_serialize_invariant_without_separators()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Chips = 123456,
|
||||
CloudValuesAutosaveSeconds = 300f,
|
||||
});
|
||||
|
||||
// The client parses these with int/float.TryParse under
|
||||
// InvariantCulture, so a grouped or comma-decimal value silently
|
||||
// falls back to its baked default.
|
||||
Assert.Equal("123456", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
|
||||
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
|
||||
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_raise_the_room_size_limits_past_retail()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits());
|
||||
|
||||
// Retail: Compute 50000, Chips 3000, CloudValues 100,
|
||||
// Unculled/VisibleChips 500, InkPerChip 15.
|
||||
Assert.Equal("500000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||
Assert.Equal("30000", ValueOf(rows, "UGC.CircuitsV2.Limit.Chips"));
|
||||
Assert.Equal("1000", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
|
||||
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.UnculledChips"));
|
||||
Assert.Equal("3000", ValueOf(rows, "UGC.CircuitsV2.Limit.VisibleChips"));
|
||||
// InkPerChip is a cost, so the lift is downward.
|
||||
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
// A save cadence, not a capacity limit: left at retail.
|
||||
Assert.Equal("300", ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_visible_chips_do_not_exceed_the_chip_cap()
|
||||
{
|
||||
var limits = ServerSettingsService.DefaultCircuitsV2Limits();
|
||||
|
||||
// The add-chip path only charges InkPerChip when Chips < VisibleChips
|
||||
// (CircuitsV2Manager_NestedType_AMEJFMPHNEO.txt ISIL 228-259), so the
|
||||
// defaults keep that path off as well as zeroing the cost.
|
||||
Assert.True(limits.VisibleChips <= limits.Chips);
|
||||
Assert.True(limits.UnculledChips <= limits.Chips);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ink_cost_and_cloud_values_are_capped_so_their_product_fits_an_int()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
InkPerChip = int.MaxValue,
|
||||
CloudValues = int.MaxValue,
|
||||
});
|
||||
|
||||
// The client computes InkPerChip * Math.Min(numChips, CloudValues)
|
||||
// in plain int; InkPerChip past the hard-coded 30000-ink room cap
|
||||
// also makes a single chip unaffordable.
|
||||
var ink = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
var cloud = int.Parse(ValueOf(rows, "UGC.CircuitsV2.Limit.CloudValues"));
|
||||
Assert.Equal(30_000, ink);
|
||||
Assert.Equal(10_000, cloud);
|
||||
Assert.True((long)ink * cloud < int.MaxValue / 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_ink_cost_is_floored_at_zero()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { InkPerChip = -5 });
|
||||
|
||||
Assert.Equal("0", ValueOf(rows, "UGC.CircuitsV2.Limit.InkPerChip"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chips_are_bounded_because_the_culling_manager_preallocates_by_them()
|
||||
{
|
||||
var low = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = 0 });
|
||||
var high = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Chips = int.MaxValue });
|
||||
|
||||
// Four List<Chip>(Chips) at culling-manager construction: 0 or less
|
||||
// throws, and int.MaxValue is an out-of-memory kill.
|
||||
Assert.Equal("1", ValueOf(low, "UGC.CircuitsV2.Limit.Chips"));
|
||||
Assert.Equal("1000000", ValueOf(high, "UGC.CircuitsV2.Limit.Chips"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compute_stays_finite_because_it_is_the_runaway_loop_guard()
|
||||
{
|
||||
var rows = Emit(ServerSettingsService.DefaultCircuitsV2Limits() with { Compute = int.MaxValue });
|
||||
|
||||
Assert.Equal("10000000", ValueOf(rows, "UGC.CircuitsV2.Limit.Compute"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_rpcs_is_clamped_because_the_debug_subscreen_divides_by_it()
|
||||
{
|
||||
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network = [new(0, 1024, 60, Rpcs: 0, RpcTimeWindowFrame60s: 1)],
|
||||
}).EnumerateArray().First();
|
||||
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Time_windows_are_capped_so_the_ring_buffer_stays_small()
|
||||
{
|
||||
var entry = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network = [new(0, 1024, int.MaxValue, 5, int.MaxValue)],
|
||||
}).EnumerateArray().First();
|
||||
|
||||
// Each window frame is one int in a client-side ring buffer, so an
|
||||
// unbounded window is an out-of-memory kill at circuit init.
|
||||
Assert.Equal(3600, entry.GetProperty("RpcTimeWindowFrame60s").GetInt32());
|
||||
Assert.Equal(3600, entry.GetProperty("BytesTimeWindowFrame60s").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lowest_entry_is_forced_to_activate_at_zero_players()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network =
|
||||
[
|
||||
new(20, 4096, 60, 50, 1),
|
||||
new(8, 2048, 60, 25, 1),
|
||||
],
|
||||
});
|
||||
|
||||
// Without an entry at 0 the client rejects the whole array and
|
||||
// falls back to the retail 5-RPC budget.
|
||||
var entries = network.EnumerateArray().ToList();
|
||||
Assert.Equal(0, entries[0].GetProperty("NumPlayersForActivation").GetInt32());
|
||||
// The rewrite keeps that entry's own budget.
|
||||
Assert.Equal(25, entries[0].GetProperty("Rpcs").GetInt32());
|
||||
Assert.Equal(20, entries[1].GetProperty("NumPlayersForActivation").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_thresholds_collapse_and_entries_sort_ascending()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network =
|
||||
[
|
||||
new(16, 4096, 60, 50, 1),
|
||||
new(0, 1024, 60, 10, 1),
|
||||
new(16, 8192, 60, 99, 1),
|
||||
],
|
||||
});
|
||||
|
||||
var thresholds = network.EnumerateArray()
|
||||
.Select(e => e.GetProperty("NumPlayersForActivation").GetInt32())
|
||||
.ToList();
|
||||
Assert.Equal([0, 16], thresholds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_network_list_falls_back_to_the_default_budget()
|
||||
{
|
||||
var network = NetworkOf(ServerSettingsService.DefaultCircuitsV2Limits() with
|
||||
{
|
||||
Network = [],
|
||||
});
|
||||
|
||||
var entry = Assert.Single(network.EnumerateArray());
|
||||
Assert.Equal(0, entry.GetProperty("NumPlayersForActivation").GetInt32());
|
||||
Assert.True(entry.GetProperty("Rpcs").GetInt32() >= 1_000_000);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>GET api/incentivizedreferrals/referrals?take=&continuationToken=</c> is
|
||||
/// assembled by the client's URL builder (RecNet.Runtime/LINKIECPDML.txt:677-737),
|
||||
/// so it never showed up as a route literal and had no handler. The reply is the
|
||||
/// GPJIMAHMAIO page: <c>{Referrals:[{InviteeAccountId, CreatedAt, VerifiedAt}],
|
||||
/// ContinuationToken}</c> (KOEEACLMAFE.txt:203-238, ADAFNGNADFH.txt:279-346).
|
||||
/// </summary>
|
||||
public sealed class IncentivizedReferralsTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public IncentivizedReferralsTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Referrals_page_lists_credited_invitees_with_a_continuation_token()
|
||||
{
|
||||
using var setup = Client();
|
||||
var referrer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var inviteeA = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var inviteeB = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
// Same row LinkController.CreditReferralAsync writes on the invitee.
|
||||
foreach (var invitee in new[] { inviteeA, inviteeB })
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = invitee.PlayerId,
|
||||
Key = $"referral:credited:{referrer.PlayerId}:{invitee.PlayerId}",
|
||||
Value = DateTime.UtcNow.ToString("O"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = Client(referrer);
|
||||
var first = JsonDocument.Parse(
|
||||
await client.GetStringAsync("/api/incentivizedreferrals/referrals?take=1")).RootElement;
|
||||
var firstPage = Get(first, "Referrals");
|
||||
Assert.Equal(1, firstPage.GetArrayLength());
|
||||
var token = Get(first, "ContinuationToken").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(token));
|
||||
|
||||
var second = JsonDocument.Parse(await client.GetStringAsync(
|
||||
$"/api/incentivizedreferrals/referrals?take=1&continuationToken={token}")).RootElement;
|
||||
var secondPage = Get(second, "Referrals");
|
||||
Assert.Equal(1, secondPage.GetArrayLength());
|
||||
Assert.Equal(JsonValueKind.Null, Get(second, "ContinuationToken").ValueKind);
|
||||
|
||||
var ids = new[] { firstPage[0], secondPage[0] }
|
||||
.Select(e => Get(e, "InviteeAccountId").GetInt64())
|
||||
.OrderBy(x => x)
|
||||
.ToArray();
|
||||
Assert.Equal(new[] { inviteeA.PlayerId, inviteeB.PlayerId }.OrderBy(x => x), ids);
|
||||
// CreatedAt is a non-nullable DateTime on the client: it must be present.
|
||||
Assert.Equal(JsonValueKind.String, Get(firstPage[0], "CreatedAt").ValueKind);
|
||||
}
|
||||
|
||||
// The client formatter registers PascalCase, camelCase and lowercase.
|
||||
private static JsonElement Get(JsonElement e, string pascal) =>
|
||||
e.TryGetProperty(pascal, out var v) ? v : e.GetProperty(char.ToLowerInvariant(pascal[0]) + pascal[1..]);
|
||||
|
||||
private HttpClient Client(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.API.Avatar.V4;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire contract of the 2023-03-21 client's outfit / wardrobe menu, as read
|
||||
/// from RecNet.Runtime ISIL: saved-outfit slots (BEPMJICDNDD via converter
|
||||
/// IMMOJHBENFI), the custom-avatar-item gates the menu queries when it opens,
|
||||
/// the locked-item lookup, the owned list's per-entry ids, and the gift
|
||||
/// generate call the menu's gift flow makes.
|
||||
/// </summary>
|
||||
public sealed class OutfitMenu2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public OutfitMenu2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Creation_allowed_gate_is_a_success_error_envelope()
|
||||
{
|
||||
// MPBLNLMCEDL.GGMDLMLJDDK parses DJMHAFPGLLN {Success, Error}
|
||||
// (LMCCNLLHLCJ.txt:191-254); a bare boolean is what logged
|
||||
// "Failed to query if local account can create Custom Avatar Items".
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var body = await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationAllowedForAccount");
|
||||
Assert.Equal(JsonValueKind.Object, body.ValueKind);
|
||||
Assert.Equal(JsonValueKind.True, body.GetProperty("Success").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, body.GetProperty("Error").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sibling_gates_and_min_price_are_bare_scalars()
|
||||
{
|
||||
using var client = ApiClient();
|
||||
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationEnabled")).ValueKind);
|
||||
Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isRenderingEnabled")).ValueKind);
|
||||
|
||||
// <GetPricingConfig>b__69_0(Int32 minimumPrice) — a bare integer.
|
||||
var min = await GetJsonAsync(client, "/api/customAvatarItems/v1/minPriceForPublicItem");
|
||||
Assert.Equal(JsonValueKind.Number, min.ValueKind);
|
||||
Assert.True(min.GetInt32() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Saved_outfit_round_trips_every_client_key()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var shirt = Guid.NewGuid();
|
||||
// Exactly what the client's writer (IMMOJHBENFI.txt:781-910) emits.
|
||||
var outfit = new Dictionary<string, object?>
|
||||
{
|
||||
["Slot"] = 3,
|
||||
["PreviewImageName"] = "preview-3.jpg",
|
||||
["OutfitSelections"] = "",
|
||||
["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"a\",\"b\"]}",
|
||||
["FaceFeatures"] = "{\"ver\":3}",
|
||||
["SkinColor"] = "skin-guid",
|
||||
["HairColor"] = "hair-guid",
|
||||
["CustomAvatarItems"] = new[] { new { CustomAvatarItemId = shirt, BodyPart = 2 } },
|
||||
};
|
||||
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
|
||||
|
||||
var list = await GetJsonAsync(client, "/api/avatar/v3/saved");
|
||||
var saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
|
||||
Assert.Equal("preview-3.jpg", saved.GetProperty("PreviewImageName").GetString());
|
||||
Assert.Equal("{\"v\":2,\"items\":[\"a\",\"b\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
|
||||
Assert.Equal("{\"ver\":3}", saved.GetProperty("FaceFeatures").GetString());
|
||||
Assert.Equal("skin-guid", saved.GetProperty("SkinColor").GetString());
|
||||
Assert.Equal("hair-guid", saved.GetProperty("HairColor").GetString());
|
||||
var items = saved.GetProperty("CustomAvatarItems");
|
||||
Assert.Equal(1, items.GetArrayLength());
|
||||
Assert.Equal(shirt, items[0].GetProperty("CustomAvatarItemId").GetGuid());
|
||||
Assert.Equal(2, items[0].GetProperty("BodyPart").GetInt32());
|
||||
|
||||
// Overwriting the slot replaces it rather than adding a second row.
|
||||
outfit["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"c\"]}";
|
||||
outfit["CustomAvatarItems"] = Array.Empty<object>();
|
||||
await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
|
||||
list = await GetJsonAsync(client, "/api/avatar/v3/saved");
|
||||
saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
|
||||
Assert.Equal("{\"v\":2,\"items\":[\"c\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
|
||||
Assert.Equal(0, saved.GetProperty("CustomAvatarItems").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gift_generate_accepts_the_clients_form_body()
|
||||
{
|
||||
// Live trace: this exact form body was answered 415 under [FromBody].
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
using var response = await client.PostAsync("/api/avatar/v2/gifts/generate",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["GiftContext"] = "12000",
|
||||
["IsGameGift"] = "True",
|
||||
["Message"] = "Activity completed!",
|
||||
}));
|
||||
Assert.NotEqual(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.True(doc.RootElement.GetProperty("Id").GetInt64() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Locked_items_carry_the_full_owned_item_shape()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
HashSet<string> owned;
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var inv = await db.Avatars.Where(a => a.PlayerId == player.PlayerId)
|
||||
.Select(a => a.InventoryJson).FirstAsync();
|
||||
owned = (JsonSerializer.Deserialize<List<string>>(inv) ?? new())
|
||||
.Select(d => d.Replace("wardrobe-", "").Split(',')[0])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
var notOwned = AvatarItemsController.Catalog.Keys
|
||||
.Where(g => Guid.TryParse(g, out _) && !owned.Contains(g)).Take(3).ToList();
|
||||
Assert.Equal(3, notOwned.Count);
|
||||
|
||||
var query = string.Join("&", notOwned.Select(g => "desc=" + Uri.EscapeDataString(g + ",,,")));
|
||||
var rows = await GetJsonAsync(client, "/api/avatar/v1/lockeditems?" + query);
|
||||
Assert.Equal(3, rows.GetArrayLength());
|
||||
var ids = new List<int>();
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
// Same IFNGBPJGINL keys v4/items carries (CLIANCNHNIE reader).
|
||||
Assert.Equal(JsonValueKind.String, row.GetProperty("TagList").ValueKind);
|
||||
Assert.True(row.GetProperty("IsBaseAvatarItem").ValueKind is JsonValueKind.True or JsonValueKind.False);
|
||||
ids.Add(row.GetProperty("AvatarItemId").GetInt32());
|
||||
}
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owning_a_base_item_and_its_colorways_yields_unique_ids()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
// Two colorways of one base item that the runtime catalog knows.
|
||||
var group = StoreService.ColorVariantCatalog.Values
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.descriptor)
|
||||
&& AvatarItemsController.Catalog.ContainsKey(v.item_guid))
|
||||
.GroupBy(v => v.item_guid, StringComparer.OrdinalIgnoreCase)
|
||||
.First(g => g.Select(v => v.descriptor).Distinct().Count() >= 2);
|
||||
var baseGuid = group.Key;
|
||||
var variants = group.Select(v => v.descriptor).Distinct().Take(2).ToList();
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
|
||||
var inv = JsonSerializer.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
|
||||
inv.Add(baseGuid);
|
||||
inv.AddRange(variants);
|
||||
avatar.InventoryJson = JsonSerializer.Serialize(inv);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = ApiClient(player);
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var sameBase = items.EnumerateArray()
|
||||
.Where(x => x.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]
|
||||
.Equals(baseGuid, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
Assert.Equal(3, sameBase.Count);
|
||||
|
||||
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.DoesNotContain(0, ids);
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task PostJsonAsync(HttpClient client, string path, object body)
|
||||
{
|
||||
using var response = await client.PostAsync(path,
|
||||
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -215,124 +215,6 @@ public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owned_items_carry_the_same_name_tooltip_and_rarity_as_their_shop_tile()
|
||||
{
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||
.SetAllAvatarItemsOwnedEnabledAsync(true);
|
||||
try
|
||||
{
|
||||
using var client = ApiClient(player);
|
||||
var owned = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var shop = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||
|
||||
var tiles = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
foreach (var row in shop.GetProperty("StoreItems").EnumerateArray())
|
||||
{
|
||||
var drop = row.GetProperty("GiftDrop");
|
||||
var desc = drop.GetProperty("AvatarItemDesc").GetString();
|
||||
if (!string.IsNullOrEmpty(desc)) tiles.TryAdd(desc, drop);
|
||||
}
|
||||
|
||||
var matched = 0;
|
||||
foreach (var item in owned.EnumerateArray())
|
||||
{
|
||||
if (!tiles.TryGetValue(item.GetProperty("AvatarItemDesc").GetString()!, out var tile)) continue;
|
||||
matched++;
|
||||
Assert.Equal(tile.GetProperty("FriendlyName").GetString(), item.GetProperty("FriendlyName").GetString());
|
||||
Assert.Equal(tile.GetProperty("Tooltip").GetString(), item.GetProperty("Tooltip").GetString());
|
||||
Assert.Equal(tile.GetProperty("Rarity").GetInt32(), item.GetProperty("Rarity").GetInt32());
|
||||
}
|
||||
Assert.True(matched > 0, "no owned item matched a shop tile");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
await scope.ServiceProvider.GetRequiredService<DorkNet.Server.Services.ServerSettingsService>()
|
||||
.SetAllAvatarItemsOwnedEnabledAsync(false);
|
||||
}
|
||||
}
|
||||
|
||||
[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";
|
||||
|
||||
[Fact]
|
||||
public async Task Default_unlocked_items_are_owned_by_every_player()
|
||||
{
|
||||
// The client replaces its unlocked set with each list it downloads,
|
||||
// so v4/items must carry the free default items or they show a price.
|
||||
using var setup = ApiClient();
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = ApiClient(player);
|
||||
|
||||
var items = await GetJsonAsync(client, "/api/avatar/v4/items");
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
||||
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
|
||||
// A default item the starter inventory also holds appears once.
|
||||
Assert.Equal(descs.Count, descs.Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||
var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList();
|
||||
Assert.Equal(ids.Count, ids.Distinct().Count());
|
||||
|
||||
var locked = await GetJsonAsync(client, $"/api/avatar/v1/lockeditems?avatarItemDescs={DefaultItemGuid},,,");
|
||||
Assert.Equal(0, locked.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_unlocked_endpoint_serves_defaults_before_login()
|
||||
{
|
||||
using var anonymous = ApiClient();
|
||||
var items = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked");
|
||||
var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList();
|
||||
Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Equal(
|
||||
DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.DefaultUnlockedGuids.Count,
|
||||
descs.Count);
|
||||
}
|
||||
|
||||
private HttpClient ApiClient(GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Presence must follow the player through every transition, for the player
|
||||
/// themselves (the heartbeat) and for everyone else (bulk /player and the
|
||||
/// PresenceUpdate push).
|
||||
///
|
||||
/// The heartbeat side is decided by the client's compare
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:18415-18467): a cached instance with
|
||||
/// a negative id — the offline room — is in sync ONLY with roomInstance null,
|
||||
/// and anything else makes the client cancel what it is doing and chase the
|
||||
/// server's room. matchmake/none is how the client tells us it went to that
|
||||
/// offline room, so after it the heartbeat must stay null.
|
||||
/// </summary>
|
||||
public sealed class PresenceTransitionTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public PresenceTransitionTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_to_the_offline_room_makes_the_heartbeat_report_no_room()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
// Enter the dorm for real: matchmake, then report the join.
|
||||
var dormInstance = await MatchmakeDormAsync(client);
|
||||
await ReportJoinAsync(client, dormInstance);
|
||||
var before = await HeartbeatAsync(client);
|
||||
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(before)));
|
||||
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode, $"POST /matchmake/none -> {(int)none.StatusCode}");
|
||||
|
||||
// Twice: the first beat must not re-seed a dorm the second one returns.
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var beat = await HeartbeatAsync(client);
|
||||
var room = RoomInstanceOf(beat);
|
||||
Assert.True(room.ValueKind == JsonValueKind.Null,
|
||||
$"""
|
||||
After matchmake/none the client holds its offline room (instance -1)
|
||||
and is only in sync with a null roomInstance. The heartbeat answered
|
||||
with a room, which makes the client cancel the offline load and
|
||||
chase it:
|
||||
|
||||
{beat.GetRawText()}
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Leaving_while_a_join_is_pending_abandons_the_join()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
// The client gave up on this dorm join (e.g. a Photon timeout) and fell
|
||||
// back to the offline room before reporting a result.
|
||||
await MatchmakeDormAsync(client);
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
|
||||
var beat = await HeartbeatAsync(client);
|
||||
Assert.True(RoomInstanceOf(beat).ValueKind == JsonValueKind.Null,
|
||||
$"heartbeat still echoed the abandoned join target: {beat.GetRawText()}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Matchmaking_again_after_leaving_restores_the_room()
|
||||
{
|
||||
using var client = await SignedInMatchClientAsync();
|
||||
|
||||
using var none = await client.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
Assert.True(RoomInstanceOf(await HeartbeatAsync(client)).ValueKind == JsonValueKind.Null);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(client);
|
||||
await ReportJoinAsync(client, dormInstance);
|
||||
|
||||
var beat = await HeartbeatAsync(client);
|
||||
Assert.Equal(dormInstance, FindInstanceId(RoomInstanceOf(beat)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Friends_see_a_player_in_the_offline_room_as_online_with_no_room()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(subject);
|
||||
await ReportJoinAsync(subject, dormInstance);
|
||||
using var none = await subject.PostAsync("/matchmake/none", Form());
|
||||
Assert.True(none.IsSuccessStatusCode);
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.True(row.GetProperty("isOnline").GetBoolean(),
|
||||
$"a player sitting in the offline room is online: {row.GetRawText()}");
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logging_out_shows_the_player_offline_and_roomless()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
var dormInstance = await MatchmakeDormAsync(subject);
|
||||
await ReportJoinAsync(subject, dormInstance);
|
||||
Assert.True((await BulkPresenceAsync(viewer, subjectId)).GetProperty("isOnline").GetBoolean());
|
||||
|
||||
using var logout = await subject.PostAsync("/player/logout", Form());
|
||||
Assert.True(logout.IsSuccessStatusCode);
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.False(row.GetProperty("isOnline").GetBoolean(), row.GetRawText());
|
||||
Assert.Equal(JsonValueKind.Null, row.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bulk_presence_reports_the_stored_status_visibility()
|
||||
{
|
||||
using var subject = await SignedInMatchClientAsync();
|
||||
using var viewer = await SignedInMatchClientAsync();
|
||||
var subjectId = await PlayerIdOfAsync(subject);
|
||||
|
||||
using var put = await subject.PutAsync("/player/statusvisibility",
|
||||
Form(("statusVisibility", "2")));
|
||||
Assert.True(put.IsSuccessStatusCode, $"PUT statusvisibility -> {(int)put.StatusCode}");
|
||||
|
||||
var row = await BulkPresenceAsync(viewer, subjectId);
|
||||
Assert.Equal(2, row.GetProperty("statusVisibility").GetInt32());
|
||||
|
||||
// And the player's own heartbeat agrees.
|
||||
var beat = await HeartbeatAsync(subject);
|
||||
Assert.Equal(2, beat.GetProperty("statusVisibility").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_push_carries_online_state_independently_of_the_room()
|
||||
{
|
||||
var room = new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = 42,
|
||||
RoomId = 7,
|
||||
Name = "DormRoom",
|
||||
PhotonRoomId = "^dormroom_p1",
|
||||
};
|
||||
|
||||
// Online, no room: the offline-room state. Used to be sent as OFFLINE
|
||||
// because isOnline was derived from the room.
|
||||
var onlineNoRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, null, isOnline: true));
|
||||
Assert.True(onlineNoRoom.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, onlineNoRoom.GetProperty("roomInstance").ValueKind);
|
||||
|
||||
// Offline never leaks a room, even if one is still cached.
|
||||
var offline = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: false));
|
||||
Assert.False(offline.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, offline.GetProperty("roomInstance").ValueKind);
|
||||
|
||||
// Online in a room: redacted join coordinates, visibility passed through,
|
||||
// appVersion a STRING.
|
||||
var inRoom = Msg(NotificationService.BuildPresenceUpdatePayload(5, room, isOnline: true, statusVisibility: 1));
|
||||
Assert.True(inRoom.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(42, inRoom.GetProperty("roomInstance").GetProperty("roomInstanceId").GetInt64());
|
||||
Assert.Equal(string.Empty, inRoom.GetProperty("roomInstance").GetProperty("photonRoomId").GetString());
|
||||
Assert.Equal(1, inRoom.GetProperty("statusVisibility").GetInt32());
|
||||
Assert.Equal(JsonValueKind.String, inRoom.GetProperty("appVersion").ValueKind);
|
||||
|
||||
static JsonElement Msg(string payload)
|
||||
{
|
||||
var root = JsonDocument.Parse(payload).RootElement;
|
||||
// String-keyed channel: the client registers "PresenceUpdate".
|
||||
Assert.Equal("PresenceUpdate", root.GetProperty("Id").GetString());
|
||||
return root.GetProperty("Msg");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_pushes_honour_the_players_status_visibility()
|
||||
{
|
||||
// Live PresenceUpdate frames must hide a player exactly as GET /player
|
||||
// does, or "appear offline" leaks every room change to friends.
|
||||
long[] friends = [10, 11, 12];
|
||||
var favorited = new HashSet<long> { 11 };
|
||||
|
||||
var pub = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Public, favorited);
|
||||
Assert.Equal(friends, pub.VisibleTo);
|
||||
Assert.Empty(pub.HiddenFrom);
|
||||
|
||||
// Every recipient is already a friend, so FriendsOnly hides nobody.
|
||||
var friendsOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FriendsOnly, favorited);
|
||||
Assert.Equal(friends, friendsOnly.VisibleTo);
|
||||
Assert.Empty(friendsOnly.HiddenFrom);
|
||||
|
||||
var favoritesOnly = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.FavoriteFriendsOnly, favorited);
|
||||
Assert.Equal([11L], favoritesOnly.VisibleTo);
|
||||
Assert.Equal([10L, 12L], favoritesOnly.HiddenFrom);
|
||||
|
||||
var offline = PresenceBroadcastService.SplitRecipients(friends, PresenceVisibility.Offline, favorited);
|
||||
Assert.Empty(offline.VisibleTo);
|
||||
Assert.Equal(friends, offline.HiddenFrom);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<HttpClient> SignedInMatchClientAsync()
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://match.{_factory.ApexDomain}");
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<long> PlayerIdOfAsync(HttpClient client)
|
||||
{
|
||||
var beat = await HeartbeatAsync(client);
|
||||
return beat.GetProperty("playerId").GetInt64();
|
||||
}
|
||||
|
||||
private static async Task<string> MatchmakeDormAsync(HttpClient client)
|
||||
{
|
||||
using var resp = await client.PostAsync("/matchmake/dorm", Form(("MaxPersistenceVersion", "45")));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"POST /matchmake/dorm -> {(int)resp.StatusCode}: {body}");
|
||||
var id = FindInstanceId(JsonDocument.Parse(body).RootElement);
|
||||
Assert.False(string.IsNullOrEmpty(id), $"no roomInstanceId in the dorm reply: {body}");
|
||||
return id!;
|
||||
}
|
||||
|
||||
private static async Task ReportJoinAsync(HttpClient client, string instanceId)
|
||||
{
|
||||
using var resp = await client.PostAsync($"/roominstance/{instanceId}/reportjoinresult", Form(("result", "0")));
|
||||
Assert.True(resp.IsSuccessStatusCode, $"reportjoinresult -> {(int)resp.StatusCode}");
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> HeartbeatAsync(HttpClient client)
|
||||
{
|
||||
using var resp = await client.PostAsync("/player/heartbeat", Form(("LoginLock", Guid.NewGuid().ToString())));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"POST /player/heartbeat -> {(int)resp.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> BulkPresenceAsync(HttpClient viewer, long subjectId)
|
||||
{
|
||||
using var resp = await viewer.GetAsync($"/player?{subjectId}");
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.True(resp.IsSuccessStatusCode, $"GET /player -> {(int)resp.StatusCode}: {body}");
|
||||
foreach (var row in JsonDocument.Parse(body).RootElement.EnumerateArray())
|
||||
{
|
||||
if (row.GetProperty("playerId").GetInt64() == subjectId) return row.Clone();
|
||||
}
|
||||
Assert.Fail($"no row for {subjectId}: {body}");
|
||||
return default;
|
||||
}
|
||||
|
||||
private static JsonElement RoomInstanceOf(JsonElement presence)
|
||||
{
|
||||
Assert.True(presence.TryGetProperty("roomInstance", out var room),
|
||||
$"no roomInstance key: {presence.GetRawText()}");
|
||||
return room;
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) =>
|
||||
new(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
|
||||
|
||||
private static string? FindInstanceId(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
|
||||
return property.Value.ToString();
|
||||
if (property.Value.ValueKind == JsonValueKind.Object
|
||||
&& FindInstanceId(property.Value) is { } nested)
|
||||
return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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("value").GetProperty("RoomId").GetInt64();
|
||||
var cloneId = json.RootElement.GetProperty("RoomId").GetInt64();
|
||||
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using DorkNet.Server.Controllers.Match;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-game profile edits for the 2023-03-21 client. Every edit goes through
|
||||
/// OPMAPIOEIFG.OBAGJBBIFGB → PUT "account/me/" + suffix with a single
|
||||
/// urlencoded field whose name differs from the suffix (displayEmoji,
|
||||
/// pronounFlags, identityFlags, imageName). None of these routes existed, so
|
||||
/// every edit failed with "Failed to modify <suffix>", and no account
|
||||
/// DTO emitted the fields, so nothing showed up for anyone. Online-status
|
||||
/// visibility (PUT player/statusvisibility) was persisted but GET /player
|
||||
/// always reported Public and never hid the player.
|
||||
/// </summary>
|
||||
public sealed class ProfileEdit2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ProfileEdit2023Tests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Emoji_edit_persists_and_is_visible_to_other_players()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "🤪");
|
||||
|
||||
var me = await GetJsonAsync(ownerClient, "/account/me");
|
||||
Assert.Equal("🤪", me.GetProperty("displayEmoji").GetString());
|
||||
|
||||
var seen = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}");
|
||||
Assert.Equal("🤪", seen.GetProperty("displayEmoji").GetString());
|
||||
|
||||
// Empty clears it.
|
||||
await PutFormOkAsync(ownerClient, "/account/me/emoji", "displayEmoji", "");
|
||||
me = await GetJsonAsync(ownerClient, "/account/me");
|
||||
Assert.Equal("", me.GetProperty("displayEmoji").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Emoji_edit_rejects_non_whitelisted_and_canonicalizes_variation_selector()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
|
||||
using (var bad = await PutFormAsync(client, "/account/me/emoji", "displayEmoji", "not-an-emoji"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
|
||||
// "✌" without U+FE0F resolves to the list's "✌️".
|
||||
await PutFormOkAsync(client, "/account/me/emoji", "displayEmoji", "✌");
|
||||
var me = await GetJsonAsync(client, "/account/me");
|
||||
Assert.Equal("✌️", me.GetProperty("displayEmoji").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pronouns_identity_flags_and_banner_round_trip_on_every_account_read()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/personalpronouns", "pronounFlags", "5");
|
||||
await PutFormOkAsync(ownerClient, "/account/me/identityflags", "identityFlags", "3");
|
||||
await PutFormOkAsync(ownerClient, "/account/me/bannerimage", "imageName", "banner-test.jpg");
|
||||
|
||||
void AssertProfile(JsonElement a)
|
||||
{
|
||||
Assert.Equal(5, a.GetProperty("personalPronouns").GetInt32());
|
||||
Assert.Equal(3, a.GetProperty("identityFlags").GetInt32());
|
||||
Assert.Equal("banner-test.jpg", a.GetProperty("bannerImage").GetString());
|
||||
}
|
||||
|
||||
AssertProfile(await GetJsonAsync(ownerClient, "/account/me"));
|
||||
AssertProfile(await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}"));
|
||||
|
||||
var bulk = await GetJsonAsync(viewerClient, $"/account/bulk?id={owner.PlayerId}");
|
||||
AssertProfile(bulk.EnumerateArray().Single(a => a.GetProperty("accountId").GetInt64() == owner.PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flag_edits_reject_non_numeric_values()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
|
||||
using (var r = await PutFormAsync(client, "/account/me/personalpronouns", "pronounFlags", "HeHim"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||
using (var r = await PutFormAsync(client, "/account/me/identityflags", "identityFlags", "-1"))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Self_account_reports_username_changes_remaining()
|
||||
{
|
||||
var (owner, _) = await TwoPlayersAsync();
|
||||
using var client = AccountsClient(owner);
|
||||
var me = await GetJsonAsync(client, "/account/me");
|
||||
// Absent/0 makes the settings UI say no username changes are left.
|
||||
Assert.True(me.GetProperty("availableUsernameChanges").GetInt32() > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bio_edit_round_trips_through_public_bio_lookup()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
using var ownerClient = AccountsClient(owner);
|
||||
using var viewerClient = AccountsClient(viewer);
|
||||
|
||||
await PutFormOkAsync(ownerClient, "/account/me/bio", "bio", "hello from the profile");
|
||||
var bio = await GetJsonAsync(viewerClient, $"/account/{owner.PlayerId}/bio");
|
||||
Assert.Equal("hello from the profile", bio.GetProperty("bio").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_visibility_is_reported_to_self_and_hides_appear_offline_player()
|
||||
{
|
||||
var (owner, viewer) = await TwoPlayersAsync();
|
||||
PutOnline(owner.PlayerId);
|
||||
|
||||
using var ownerMatch = MatchClient(owner);
|
||||
using var viewerMatch = MatchClient(viewer);
|
||||
|
||||
// Baseline: Public → viewer sees owner online.
|
||||
var before = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||
Assert.True(before.GetProperty("isOnline").GetBoolean());
|
||||
|
||||
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "3");
|
||||
|
||||
// Self keeps its own choice (the dialog reads the cached self presence).
|
||||
var self = await PresenceOfAsync(ownerMatch, owner.PlayerId);
|
||||
Assert.Equal(3, self.GetProperty("statusVisibility").GetInt32());
|
||||
Assert.True(self.GetProperty("isOnline").GetBoolean());
|
||||
|
||||
// Others see an offline player with no room.
|
||||
var other = await PresenceOfAsync(viewerMatch, owner.PlayerId);
|
||||
Assert.False(other.GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, other.GetProperty("roomInstance").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Friends_only_visibility_shows_friends_and_hides_strangers()
|
||||
{
|
||||
var (owner, friend) = await TwoPlayersAsync();
|
||||
using var setup = AccountsClient();
|
||||
var stranger = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
PutOnline(owner.PlayerId);
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Relationships.Add(new RelationshipEntity
|
||||
{
|
||||
RequesterId = owner.PlayerId,
|
||||
TargetId = friend.PlayerId,
|
||||
Status = RelationshipStatus.Friend,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ownerMatch = MatchClient(owner);
|
||||
await PutFormOkAsync(ownerMatch, "/player/statusvisibility", "statusVisibility", "1");
|
||||
|
||||
using var friendMatch = MatchClient(friend);
|
||||
using var strangerMatch = MatchClient(stranger);
|
||||
Assert.True((await PresenceOfAsync(friendMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||
Assert.False((await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("isOnline").GetBoolean());
|
||||
Assert.Equal(1, (await PresenceOfAsync(strangerMatch, owner.PlayerId)).GetProperty("statusVisibility").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presence_pref_key_matches_status_visibility_writer()
|
||||
{
|
||||
// MatchPlayerController writes the pref; PlayerPresenceController reads it.
|
||||
var field = typeof(MatchPlayerController).GetField(
|
||||
"PrefStatusVisibility",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
Assert.NotNull(field);
|
||||
Assert.Equal(PlayerPresenceController.PrefStatusVisibility, (string)field!.GetRawConstantValue()!);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private void PutOnline(long playerId)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var presence = scope.ServiceProvider.GetRequiredService<PlayerPresenceService>();
|
||||
presence.SetRoom(playerId, new RoomInstanceDto
|
||||
{
|
||||
RoomInstanceId = 900_000 + playerId,
|
||||
RoomId = 1,
|
||||
SubRoomId = 0,
|
||||
PhotonRoomId = $"^profiletest{playerId}",
|
||||
PhotonRegionId = "us",
|
||||
MaxCapacity = 8,
|
||||
});
|
||||
presence.MarkActive(playerId);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PresenceOfAsync(HttpClient client, long playerId)
|
||||
{
|
||||
var list = await GetJsonAsync(client, $"/player?{playerId}");
|
||||
return list.EnumerateArray().Single(p => p.GetProperty("playerId").GetInt64() == playerId);
|
||||
}
|
||||
|
||||
private async Task<(GameClientSession, GameClientSession)> TwoPlayersAsync()
|
||||
{
|
||||
using var setup = AccountsClient();
|
||||
var a = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var b = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
return (a, b);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> PutFormAsync(HttpClient client, string path, string field, string value)
|
||||
=> await client.PutAsync(path, new FormUrlEncodedContent(new Dictionary<string, string> { [field] = value }));
|
||||
|
||||
private static async Task PutFormOkAsync(HttpClient client, string path, string field, string value)
|
||||
{
|
||||
using var response = await PutFormAsync(client, path, field, value);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"PUT {path} → {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
private HttpClient AccountsClient(GameClientSession? session = null) => Client("accounts", session);
|
||||
|
||||
private HttpClient MatchClient(GameClientSession session) => Client("match", session);
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} → {(int)response.StatusCode}: {body}");
|
||||
using var document = JsonDocument.Parse(body);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A Quest client patched without its own Meta app has no platform session,
|
||||
/// so every headset reports the same placeholder platform user id ("1").
|
||||
/// Accounts must stay keyed by device id: two headsets get two accounts, a
|
||||
/// headset gets its own account back on the next boot, and the placeholder is
|
||||
/// never stored or matched by the remembered-logins lookup — otherwise every
|
||||
/// Quest player would be offered every other Quest player's account.
|
||||
/// </summary>
|
||||
public sealed class QuestPlaceholderPlatformIdTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
// RecNet platform value the 2023 Quest client sends on /connect/token.
|
||||
// The fix does not depend on it; any platform with a placeholder id is
|
||||
// handled the same way.
|
||||
private const string QuestPlatform = "1";
|
||||
private const string PlaceholderPlatformId = "1";
|
||||
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public QuestPlaceholderPlatformIdTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true)]
|
||||
[InlineData("", true)]
|
||||
[InlineData(" ", true)]
|
||||
[InlineData("0", true)]
|
||||
[InlineData("1", true)]
|
||||
[InlineData(" 1 ", true)]
|
||||
[InlineData("10", false)]
|
||||
[InlineData("2173678582678296", false)]
|
||||
public void Placeholder_platform_ids_are_recognised(string? platformId, bool expected)
|
||||
=> Assert.Equal(expected, PlayerService.IsPlaceholderPlatformId(platformId));
|
||||
|
||||
[Fact]
|
||||
public async Task Two_headsets_with_the_placeholder_platform_id_get_separate_accounts()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
var headsetA = $"quest-headset-a-{Guid.NewGuid():N}";
|
||||
var headsetB = $"quest-headset-b-{Guid.NewGuid():N}";
|
||||
|
||||
var a = await CreateAccountAsync(client, headsetA);
|
||||
var b = await CreateAccountAsync(client, headsetB);
|
||||
Assert.NotEqual(a, b);
|
||||
|
||||
// Same headset, next boot: its own account comes back.
|
||||
var aAgain = await CreateAccountAsync(client, headsetA);
|
||||
Assert.Equal(a, aAgain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Placeholder_platform_id_never_lists_remembered_logins()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
|
||||
await CreateAccountAsync(client, $"quest-headset-{Guid.NewGuid():N}");
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
$"/cachedlogin/forplatformid/{QuestPlatform}/{PlaceholderPlatformId}");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
using var json = JsonDocument.Parse(body);
|
||||
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
|
||||
Assert.Equal(0, json.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_real_platform_id_still_lists_its_remembered_login()
|
||||
{
|
||||
using var client = Client("auth");
|
||||
var realPlatformId = $"{Random.Shared.NextInt64(10_000_000_000, 99_999_999_999)}";
|
||||
var id = await CreateAccountAsync(client, $"quest-meta-{Guid.NewGuid():N}", realPlatformId);
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
$"/cachedlogin/forplatformid/{QuestPlatform}/{realPlatformId}");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"GET cachedlogin/forplatformid -> {(int)response.StatusCode}: {body}");
|
||||
Assert.Contains(id.ToString(), body);
|
||||
}
|
||||
|
||||
// The 2023 client's account-creation request (LBNJFPOLCDL: grant_type,
|
||||
// client_id, platform, platform_id, device_id, ...). No username/password,
|
||||
// so the server takes the device-id path.
|
||||
private async Task<long> CreateAccountAsync(
|
||||
HttpClient client, string deviceId, string platformId = PlaceholderPlatformId)
|
||||
{
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "create_account",
|
||||
["client_id"] = "recroom",
|
||||
["platform"] = QuestPlatform,
|
||||
["platform_id"] = platformId,
|
||||
["device_id"] = deviceId,
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token")
|
||||
{
|
||||
Content = new FormUrlEncodedContent(form),
|
||||
};
|
||||
request.Headers.Accept.ParseAdd("application/json");
|
||||
request.Headers.UserAgent.ParseAdd("RecRoom/2023.03.17");
|
||||
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode,
|
||||
$"POST /connect/token -> {(int)response.StatusCode}: {body}");
|
||||
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var accessToken = json.RootElement.GetProperty("access_token").GetString()!;
|
||||
|
||||
using var userInfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo");
|
||||
userInfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var userInfoResponse = await client.SendAsync(userInfo);
|
||||
userInfoResponse.EnsureSuccessStatusCode();
|
||||
using var userJson = JsonDocument.Parse(await userInfoResponse.Content.ReadAsStringAsync());
|
||||
return long.Parse(userJson.RootElement.GetProperty("sub").GetString()!);
|
||||
}
|
||||
|
||||
private HttpClient Client(string host)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Writes whose client method returns the untyped promise (<c>LDGADANDBIO</c>)
|
||||
/// but whose reply is still parsed — as <c>DJMHAFPGLLN</c>, the
|
||||
/// <c>{Success, Error}</c> RecNetResult — by <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
|
||||
/// (ExpandRecNetResult, RecNet.Runtime/BFDGEKKNAPB.txt:353).
|
||||
///
|
||||
/// The failure modes are silent server-side: an empty 200 parses to null and
|
||||
/// null-derefs, so the player sees "Failed to …" after the change was applied;
|
||||
/// a body without <c>Success</c> parses as a failure whose <c>Error</c> is null,
|
||||
/// and <c>Promise.Rejected(null)</c> throws "Error message cannot be null".
|
||||
/// So the assertion that matters is the body, read the way the client's
|
||||
/// formatter reads it (LMCCNLLHLCJ.txt: Success/success, Error/error).
|
||||
/// </summary>
|
||||
public sealed class RecNetResultEnvelopeTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public RecNetResultEnvelopeTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Club_member_writes_answer_a_success_result_not_an_empty_body()
|
||||
{
|
||||
using var setup = Client("clubs");
|
||||
var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var joiner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
var clubId = await SeedOpenClubAsync(owner.PlayerId);
|
||||
|
||||
using var joinerClient = Client("clubs", joiner);
|
||||
// requesttojoin is verb 3 (PUT) through IKMMOCKDKAF.JNFEEDLCGHH, form-encoded.
|
||||
using var join = await joinerClient.PutAsync(
|
||||
$"/club/{clubId}/members/requesttojoin", new FormUrlEncodedContent([]));
|
||||
await AssertRecNetSuccessAsync(join, "requesttojoin");
|
||||
|
||||
using var leave = await joinerClient.PostAsync(
|
||||
$"/club/{clubId}/members/leave", new FormUrlEncodedContent([]));
|
||||
await AssertRecNetSuccessAsync(leave, "leave");
|
||||
|
||||
using var clearHome = await joinerClient.DeleteAsync("/club/home/me");
|
||||
await AssertRecNetSuccessAsync(clearHome, "DELETE club/home/me");
|
||||
|
||||
using var ownerClient = Client("clubs", owner);
|
||||
using var delete = await ownerClient.DeleteAsync($"/club/{clubId}");
|
||||
await AssertRecNetSuccessAsync(delete, "DELETE club/{id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_markers_and_privacy_toggle_answer_a_success_result()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = Client("api", player);
|
||||
|
||||
using var commentRead = await client.PutAsync("/comments/read/1/1", content: null);
|
||||
await AssertRecNetSuccessAsync(commentRead, "PUT comments/read");
|
||||
|
||||
using var accounts = Client("accounts", player);
|
||||
using var privacy = await accounts.PutAsync(
|
||||
"/accountprivacysettings/recenthistoryvisibility",
|
||||
new FormUrlEncodedContent([new("isRecentHistoryVisible", "false")]));
|
||||
await AssertRecNetSuccessAsync(privacy, "PUT recenthistoryvisibility");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_warning_carries_an_error_message()
|
||||
{
|
||||
using var setup = Client("api");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var client = Client("api", player);
|
||||
|
||||
using var response = await client.PostAsync("/api/playerwarnings",
|
||||
new FormUrlEncodedContent([new("WarnedPlayerId", player.PlayerId.ToString())]));
|
||||
var root = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(ReadSuccess(root));
|
||||
// Without Error the client's Promise.Rejected(null) throws instead of
|
||||
// surfacing the refusal.
|
||||
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_token_is_a_bare_string_redeemed_once_for_its_own_account()
|
||||
{
|
||||
using var setup = Client("auth");
|
||||
var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||
using var auth = Client("auth", player);
|
||||
|
||||
using var mint = await auth.PostAsync("/account/me/createlogintoken", content: null);
|
||||
var raw = await mint.Content.ReadAsStringAsync();
|
||||
Assert.True(mint.IsSuccessStatusCode, raw);
|
||||
// FGLDKEJLAKB<String> LGKLFFPACGL(): a JSON string, not an object.
|
||||
var token = JsonSerializer.Deserialize<string>(raw);
|
||||
Assert.False(string.IsNullOrEmpty(token));
|
||||
|
||||
using var anon = Client("auth");
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "login_token",
|
||||
["account_id"] = player.PlayerId.ToString(),
|
||||
["login_token"] = token!,
|
||||
};
|
||||
using var redeemed = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||
var redeemedBody = await redeemed.Content.ReadAsStringAsync();
|
||||
Assert.True(redeemed.IsSuccessStatusCode, redeemedBody);
|
||||
Assert.Equal(player.PlayerId, await SubjectOfAsync(redeemedBody));
|
||||
|
||||
using var again = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(form));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_code_poll_waits_for_remoteauth_approval_then_signs_in_the_approver()
|
||||
{
|
||||
using var anon = Client("auth");
|
||||
var approver = await GameClientSessionFactory.CreateAsync(anon, _factory.ApexDomain);
|
||||
|
||||
using var start = await anon.PostAsync("/connect/deviceauthorization", content: null);
|
||||
var started = JsonDocument.Parse(await start.Content.ReadAsStringAsync()).RootElement;
|
||||
var deviceCode = started.GetProperty("device_code").GetString()!;
|
||||
var userCode = started.GetProperty("user_code").GetString()!;
|
||||
|
||||
var poll = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
|
||||
["device_code"] = deviceCode,
|
||||
};
|
||||
|
||||
// Before approval the client must be told to keep polling: a 400
|
||||
// whose error is authorization_pending (JAEKFNKDNCP.txt:97-107).
|
||||
using (var pending = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll)))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.BadRequest, pending.StatusCode);
|
||||
var body = JsonDocument.Parse(await pending.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("authorization_pending", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
using var approverClient = Client("auth", approver);
|
||||
using (var bad = await approverClient.PostAsync("/account/me/remoteauth",
|
||||
new FormUrlEncodedContent([new("code", "NOPE0000")])))
|
||||
{
|
||||
var root = JsonDocument.Parse(await bad.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(ReadSuccess(root));
|
||||
Assert.False(string.IsNullOrEmpty(ReadError(root)));
|
||||
}
|
||||
|
||||
using var approve = await approverClient.PostAsync("/account/me/remoteauth",
|
||||
new FormUrlEncodedContent([new("code", userCode.ToLowerInvariant())]));
|
||||
await AssertRecNetSuccessAsync(approve, "remoteauth");
|
||||
|
||||
using var granted = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||
var grantedBody = await granted.Content.ReadAsStringAsync();
|
||||
Assert.True(granted.IsSuccessStatusCode, grantedBody);
|
||||
Assert.Equal(approver.PlayerId, await SubjectOfAsync(grantedBody));
|
||||
|
||||
// Single use.
|
||||
using var reused = await anon.PostAsync("/connect/token", new FormUrlEncodedContent(poll));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, reused.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gameconfig_carries_the_play_menu_carousel_ordering_in_the_parsed_format()
|
||||
{
|
||||
using var client = Client("api");
|
||||
var rows = JsonDocument.Parse(await client.GetStringAsync("/api/gameconfigs/v1/all")).RootElement;
|
||||
var row = rows.EnumerateArray().FirstOrDefault(r =>
|
||||
string.Equals(Prop(r, "Key"), "PlayMenuCarouselOrdering_1", StringComparison.Ordinal));
|
||||
Assert.NotEqual(JsonValueKind.Undefined, row.ValueKind);
|
||||
|
||||
// PlayMenuCarouselModel.TryGetDisplayPriorityMap: Split(';'), then each
|
||||
// entry Split('=') must give exactly 2 parts with an Int32 priority, or
|
||||
// the entry is logged as "Failed to parse priority mapping".
|
||||
var entries = Prop(row, "Value")!.Split(';');
|
||||
Assert.All(entries, e =>
|
||||
{
|
||||
var parts = e.Split('=');
|
||||
Assert.Equal(2, parts.Length);
|
||||
Assert.True(int.TryParse(parts[1].Trim(), out _), e);
|
||||
});
|
||||
Assert.Contains(entries, e => e.StartsWith("hotrooms=", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_strings_download_answers_an_empty_table()
|
||||
{
|
||||
using var client = Client("strings");
|
||||
using var response = await client.GetAsync("/strings?locale=en-US&scopePrefix=client.&eTag=");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, body);
|
||||
Assert.Equal(JsonValueKind.Object, JsonDocument.Parse(body).RootElement.ValueKind);
|
||||
}
|
||||
|
||||
/// <summary>PUT playerevents/v2/{id}/multiinstance sends the two broadcast
|
||||
/// permission fields as boxed enums, i.e. member names (BNDIAONDFFF.txt:947
|
||||
/// falls back to Object.ToString). Parsing them as ints stored 0 every time.</summary>
|
||||
[Theory]
|
||||
[InlineData("RoomOwners", 256)]
|
||||
[InlineData("All", int.MaxValue)]
|
||||
[InlineData("None", 0)]
|
||||
[InlineData("256", 256)]
|
||||
[InlineData("roomowners, None", 256)]
|
||||
public void Broadcast_permission_names_parse_to_their_values(string raw, int expected) =>
|
||||
Assert.Equal(expected,
|
||||
DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController.ParseBroadcastPermissions(raw));
|
||||
|
||||
[Fact]
|
||||
public void Unknown_broadcast_permission_name_keeps_the_stored_value() =>
|
||||
Assert.Null(DorkNet.Server.Controllers.API.PlayerEvents.PlayerEventsController
|
||||
.ParseBroadcastPermissions("SomethingElse"));
|
||||
|
||||
private async Task<long> SeedOpenClubAsync(long ownerId)
|
||||
{
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var club = new ClubEntity
|
||||
{
|
||||
Name = $"Env{Guid.NewGuid():N}"[..16],
|
||||
CreatorPlayerId = ownerId,
|
||||
Joinability = 0, // Open
|
||||
};
|
||||
db.Clubs.Add(club);
|
||||
await db.SaveChangesAsync();
|
||||
return club.Id;
|
||||
}
|
||||
|
||||
private async Task<long> SubjectOfAsync(string tokenBody)
|
||||
{
|
||||
var access = JsonDocument.Parse(tokenBody).RootElement.GetProperty("access_token").GetString();
|
||||
using var client = Client("auth");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access);
|
||||
var info = JsonDocument.Parse(await client.GetStringAsync("/connect/userinfo")).RootElement;
|
||||
return long.Parse(info.GetProperty("sub").GetString()!);
|
||||
}
|
||||
|
||||
private static async Task AssertRecNetSuccessAsync(HttpResponseMessage response, string what)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"{what} -> {(int)response.StatusCode}: {body}");
|
||||
Assert.False(string.IsNullOrWhiteSpace(body), $"{what} answered an empty body");
|
||||
var root = JsonDocument.Parse(body).RootElement;
|
||||
Assert.True(ReadSuccess(root), $"{what} body has no Success=true: {body}");
|
||||
}
|
||||
|
||||
// The client formatter registers exactly these spellings.
|
||||
private static bool ReadSuccess(JsonElement root) =>
|
||||
(root.TryGetProperty("Success", out var s) || root.TryGetProperty("success", out s))
|
||||
&& s.ValueKind == JsonValueKind.True;
|
||||
|
||||
private static string? ReadError(JsonElement root) =>
|
||||
root.TryGetProperty("Error", out var e) || root.TryGetProperty("error", out e)
|
||||
? e.ValueKind == JsonValueKind.String ? e.GetString() : null
|
||||
: null;
|
||||
|
||||
private static string? Prop(JsonElement row, string name) =>
|
||||
row.TryGetProperty(name, out var v) || row.TryGetProperty(char.ToLowerInvariant(name[0]) + name[1..], out v)
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private HttpClient Client(string host, GameClientSession? session = null)
|
||||
{
|
||||
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
||||
if (session is not null)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,13 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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
|
||||
/// <c>Task<FGCPNAACHIK> GDHIIAHCBMN(Int64, String, CancellationToken)</c>,
|
||||
/// with the route literal on :4802). A bare room with no <c>success</c> is
|
||||
/// rejected with an empty error.
|
||||
/// with the route literal on :4802).
|
||||
///
|
||||
/// So <c>value</c> and the GET response must be shape-identical. When they drift, the server
|
||||
/// So the two responses 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
|
||||
@@ -65,12 +63,8 @@ public sealed class RoomCloneShapeTests : IClassFixture<DorkNetServerFactory>
|
||||
}
|
||||
|
||||
var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
|
||||
var cloneEnvelope = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
|
||||
var cloneBody = 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);
|
||||
@@ -163,7 +157,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("value").GetProperty("RoomId").GetInt64();
|
||||
var cloneId = clone.GetProperty("RoomId").GetInt64();
|
||||
|
||||
await using (var scope = factory.Services.CreateAsyncScope())
|
||||
{
|
||||
@@ -209,24 +203,6 @@ 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)
|
||||
{
|
||||
|
||||
@@ -267,10 +267,8 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
||||
Assert.True(resp.IsSuccessStatusCode, $"clone of {sourceId} -> {(int)resp.StatusCode}: {text}");
|
||||
|
||||
using var json = JsonDocument.Parse(text);
|
||||
// {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");
|
||||
var root = json.RootElement;
|
||||
// Full room-details shape (FGCPNAACHIK), not a status wrapper.
|
||||
foreach (var key in new[] { "RoomId", "SubRooms", "Roles" })
|
||||
Assert.True(root.TryGetProperty(key, out _), $"clone response missing '{key}': {text}");
|
||||
return root.GetProperty("RoomId").GetInt64();
|
||||
|
||||
@@ -523,45 +523,3 @@ POST thread/{0}/snooze social-clubs-chat
|
||||
GET unity_assets/{0}/{1}/{2} inventions-media
|
||||
GET voice/config moderation-safety
|
||||
GET voice/requiresModeration moderation-safety
|
||||
# Requests the plain route-literal sweep missed. Each verified in the ISIL:
|
||||
# - leading-slash literals (RecNet.Runtime): LCOCDKINDMN_NestedType_CIPKPLFLIPC.txt:218,
|
||||
# PBOGNAPEBAI_NestedType_JKNCNOHGPHK.txt:251 (GET under 100 ids, POST at 100+),
|
||||
# NHILNDLLLHH.txt:329/801, OKOOJMAOKFE.txt:561, MNANGNGKKDA_NestedType_JAMKPNKPJPD.txt:179
|
||||
# - Assembly-CSharp CDN fetch: JFDPIJFDHAD.txt:1432 (/sigs/<id>, boot file-signature check)
|
||||
# - DELETE subscription/{0}: IKMMOCKDKAF.txt LPFOPPHGDHG (verb 4)
|
||||
# - account/me/{field}: composed at runtime by OPMAPIOEIFG.OBAGJBBIFGB("account/me/" + field);
|
||||
# verbs from each caller (username/displayname/bio/birthday/emoji/personalpronouns/identityflags/bannerimage PUT=3,
|
||||
# email/phone/confirmphone POST=2).
|
||||
GET api/playerReputation/v2/bulk identity-account
|
||||
POST api/playerReputation/v2/bulk identity-account
|
||||
GET api/players/v2/progression/bulk identity-account
|
||||
POST api/players/v2/progression/bulk identity-account
|
||||
GET strings config-telemetry
|
||||
GET config/{0} config-telemetry
|
||||
GET data/{0} config-telemetry
|
||||
GET sigs/{0} config-telemetry
|
||||
DELETE subscription/{0} commerce-subscription
|
||||
PUT account/me/username identity-account
|
||||
PUT account/me/displayname identity-account
|
||||
PUT account/me/bio identity-account
|
||||
PUT account/me/birthday identity-account
|
||||
POST account/me/email identity-account
|
||||
POST account/me/phone identity-account
|
||||
PUT account/me/emoji identity-account
|
||||
PUT account/me/personalpronouns identity-account
|
||||
PUT account/me/identityflags identity-account
|
||||
PUT account/me/bannerimage identity-account
|
||||
POST account/me/confirmphone identity-account
|
||||
# Routes composed from a base literal plus a format/segment (BPAIBFBKKCN.txt:51-53, HPEBLAHNBLC.txt:38-40,
|
||||
# and the LPPEFJMCJFN URL builder in LINKIECPDML.txt:677-737).
|
||||
GET api/itemWishlists/v1/wishlist/me avatar-inventory
|
||||
GET api/itemWishlists/v1/wishlist/{0} avatar-inventory
|
||||
PUT api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||
DELETE api/itemWishlists/v1/wishlist/me/{0} avatar-inventory
|
||||
GET api/roomEarningsDistributions/v1/earningsDistribution/{0} progression-misc
|
||||
GET api/incentivizedreferrals/referrals progression-misc
|
||||
# Contact lookup (OPMAPIOEIFG.txt:11590-11620) and composed paths (FFFIMAGLKEG.txt:137, NOGJJIIENIL.txt:29).
|
||||
POST account/bulk/email identity-account
|
||||
POST account/bulk/phone identity-account
|
||||
POST api/avatar/v2/gifts/consume avatar-inventory
|
||||
GET video/{0} config-telemetry
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 532 and column 73.
|
@@ -57,7 +57,17 @@ public class AccountApiController(
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Account = AccountDtos.Public(player),
|
||||
Account = new RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,7 +77,22 @@ public class AccountApiController(
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(AccountDtos.Self(player));
|
||||
return Ok(new RecNetSelfAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = string.Empty,
|
||||
Birthday = new DateTime(2000, 1, 1),
|
||||
JuniorState = 0,
|
||||
ParentAccountId = null,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -50,18 +50,7 @@ public class AvatarGiftsController(
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
/// <summary>Body keys are the client's AFGEDDANEKP param literals
|
||||
/// (GiftContext / IsGameGift / AlternateGiftContext / Message). A class
|
||||
/// with settable properties, not a positional record, because
|
||||
/// <see cref="Binding.FormOrJsonModelBinder"/> constructs it empty and
|
||||
/// fills it.</summary>
|
||||
public sealed class GenerateGiftRequest
|
||||
{
|
||||
public int? GiftContext { get; set; }
|
||||
public bool? IsGameGift { get; set; }
|
||||
public int? AlternateGiftContext { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
public sealed record GenerateGiftRequest(int? GiftContext, bool? IsGameGift, int? AlternateGiftContext, string? Message);
|
||||
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — the end-of-
|
||||
/// activity gift box (chest). For a game/quest gift (IsGameGift) the
|
||||
@@ -75,13 +64,8 @@ public class AvatarGiftsController(
|
||||
// POST 404'd, breaking reward chests on that path). Same request body.
|
||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||
[HttpPost("api/avatar/v3/gifts/generate")]
|
||||
// The client adds each field with AFGEDDANEKP (FFFIMAGLKEG.txt:1930-2013,
|
||||
// POST = ordinal 2), which goes out form-urlencoded: a live trace showed
|
||||
// `GiftContext=12000&IsGameGift=True&Message=…` answered 415 while this
|
||||
// was [FromBody]. The form-or-JSON binder accepts either encoding.
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Generate(
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] GenerateGiftRequest? req)
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var isGameGift = req?.IsGameGift ?? false;
|
||||
|
||||
@@ -4,21 +4,16 @@ using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Controllers.API.Avatar.V2;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Avatar.V3;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Client wire
|
||||
/// type BEPMJICDNDD, whose converter IMMOJHBENFI registers exactly
|
||||
/// <c>Slot, PreviewImageName, OutfitSelections, OutfitSelectionsV2,
|
||||
/// FaceFeatures, SkinColor, HairColor, CustomAvatarItems</c>
|
||||
/// (RecNet.Runtime/IMMOJHBENFI.txt:587-766 reader, :781-910 writer).
|
||||
/// CustomAvatarItems is a list of BGNNOMBFMLH
|
||||
/// <c>{CustomAvatarItemId, BodyPart}</c>, the same element the avatar
|
||||
/// settings (<c>api/avatar/v2</c>) use.
|
||||
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
|
||||
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
|
||||
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
|
||||
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
|
||||
///
|
||||
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
|
||||
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
|
||||
@@ -43,27 +38,12 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
|
||||
/// Verb ordinal 2 = POST with a raw JSON body of one serialised
|
||||
/// BEPMJICDNDD (RecNet.Runtime/FFFIMAGLKEG.txt:4071-4095, NCILAADFJMM).
|
||||
///
|
||||
/// Parsed by hand rather than <c>[FromBody] SavedOutfitDto</c>: the old
|
||||
/// DTO silently dropped OutfitSelectionsV2 and CustomAvatarItems, and the
|
||||
/// 2023 client wears OutfitSelectionsV2 whenever it is non-empty
|
||||
/// (BEPMJICDNDD.NLGCNDMLACA, BEPMJICDNDD.txt:237-266, falls back to
|
||||
/// OutfitSelections only when V2 is empty). Loading a saved slot
|
||||
/// therefore restored a stale V1 selection (or nothing) and lost every
|
||||
/// custom shirt in it.</summary>
|
||||
/// Body is the SavedOutfit shape directly.</summary>
|
||||
[HttpPost("api/avatar/v3/saved/set")]
|
||||
[HttpPost("api/avatar/v2/saved/set")]
|
||||
public async Task<IActionResult> Set()
|
||||
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
|
||||
{
|
||||
string raw;
|
||||
using (var reader = new StreamReader(Request.Body))
|
||||
raw = await reader.ReadToEndAsync();
|
||||
|
||||
var body = ParseOutfit(raw);
|
||||
if (body is null) return BadRequest("missing body");
|
||||
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
|
||||
if (avatar is null)
|
||||
@@ -79,7 +59,6 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
|
||||
list.Add(body);
|
||||
}
|
||||
list.Sort((a, b) => a.Slot.CompareTo(b.Slot));
|
||||
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
@@ -102,91 +81,12 @@ public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Read one outfit object, matching keys case-insensitively
|
||||
/// (the client reader accepts Pascal/camel/lower spellings, so any of
|
||||
/// them is a valid write). String fields tolerate null; a nested
|
||||
/// object/array is kept as its raw JSON text, mirroring how
|
||||
/// <c>avatar/v2/set</c> treats the same fields. Returns null for a body
|
||||
/// that is not a JSON object.</summary>
|
||||
internal static SavedOutfitDto? ParseOutfit(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
var dto = new SavedOutfitDto();
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
switch (prop.Name.ToLowerInvariant())
|
||||
{
|
||||
case "slot":
|
||||
if (prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var slot)) dto.Slot = slot;
|
||||
else if (prop.Value.ValueKind == JsonValueKind.String && int.TryParse(prop.Value.GetString(), out slot)) dto.Slot = slot;
|
||||
break;
|
||||
case "previewimagename": dto.PreviewImageName = AsString(prop.Value); break;
|
||||
case "outfitselections": dto.OutfitSelections = AsString(prop.Value); break;
|
||||
case "outfitselectionsv2": dto.OutfitSelectionsV2 = AsString(prop.Value); break;
|
||||
case "facefeatures": dto.FaceFeatures = AsString(prop.Value); break;
|
||||
case "skincolor": dto.SkinColor = AsString(prop.Value); break;
|
||||
case "haircolor": dto.HairColor = AsString(prop.Value); break;
|
||||
case "customavataritems": dto.CustomAvatarItems = AsCustomItems(prop.Value); break;
|
||||
}
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string AsString(JsonElement v) => v.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => v.GetString() ?? string.Empty,
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => string.Empty,
|
||||
_ => v.GetRawText(),
|
||||
};
|
||||
|
||||
/// <summary>BGNNOMBFMLH list. Elements without a parseable guid are
|
||||
/// dropped instead of failing the whole save.</summary>
|
||||
private static List<CustomAvatarItemRefDto> AsCustomItems(JsonElement v)
|
||||
{
|
||||
var result = new List<CustomAvatarItemRefDto>();
|
||||
if (v.ValueKind != JsonValueKind.Array) return result;
|
||||
foreach (var el in v.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
Guid? id = null;
|
||||
byte bodyPart = 0;
|
||||
foreach (var p in el.EnumerateObject())
|
||||
{
|
||||
if (p.Name.Equals("CustomAvatarItemId", StringComparison.OrdinalIgnoreCase)
|
||||
&& p.Value.ValueKind == JsonValueKind.String
|
||||
&& Guid.TryParse(p.Value.GetString(), out var g))
|
||||
id = g;
|
||||
else if (p.Name.Equals("BodyPart", StringComparison.OrdinalIgnoreCase)
|
||||
&& p.Value.ValueKind == JsonValueKind.Number
|
||||
&& p.Value.TryGetByte(out var b))
|
||||
bodyPart = b;
|
||||
}
|
||||
if (id is { } guid) result.Add(new CustomAvatarItemRefDto { CustomAvatarItemId = guid, BodyPart = bodyPart });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<SavedOutfitDto> ParseList(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||
try
|
||||
{
|
||||
var list = JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||
// Rows written before CustomAvatarItems existed carry no key;
|
||||
// keep the wire array non-null.
|
||||
foreach (var o in list) o.CustomAvatarItems ??= new();
|
||||
return list;
|
||||
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
@@ -197,14 +97,7 @@ public sealed class SavedOutfitDto
|
||||
[JsonPropertyName("Slot")] public int Slot { get; set; }
|
||||
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
|
||||
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
|
||||
/// <summary>The selection the 2023 client actually wears when non-empty
|
||||
/// (BEPMJICDNDD.NLGCNDMLACA). Rows saved before this field existed
|
||||
/// deserialize to "" and the client falls back to OutfitSelections.</summary>
|
||||
[JsonPropertyName("OutfitSelectionsV2")] public string OutfitSelectionsV2 { get; set; } = string.Empty;
|
||||
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
|
||||
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
|
||||
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
|
||||
/// <summary>Custom shirts worn in this slot. Always an array on the wire
|
||||
/// (the client's ctor allocates an empty list, BEPMJICDNDD.txt:321-360).</summary>
|
||||
[JsonPropertyName("CustomAvatarItems")] public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -59,15 +59,7 @@ namespace DorkNet.Server.Controllers.API.Avatar.V4;
|
||||
/// makes every store item appear owned. We instead intersect the
|
||||
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
|
||||
/// catalog so the watch sees only items the player actually owns.
|
||||
///
|
||||
/// DEFAULT-UNLOCKED items: the 2023 client REPLACES its unlocked set with
|
||||
/// each list it downloads (OutfitManager.GFEIDFKKNMO clears field +0x70,
|
||||
/// then refills it; IsAvatarItemUnlocked is a HashSet lookup on that
|
||||
/// field). It never merges in its own baked default list, so the server
|
||||
/// must include the free starter items (avatar_item_lookup.json
|
||||
/// <c>default_unlocked_variants</c>) in every response. Without them a
|
||||
/// default item such as Beard Close showed a price in the outfit menu.
|
||||
/// Anonymous calls (<c>defaultunlocked</c> before login) get the defaults.
|
||||
/// Anonymous calls return [].
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class AvatarItemsController(
|
||||
@@ -115,10 +107,10 @@ public class AvatarItemsController(
|
||||
// reverts to the real per-player wardrobe below. Bounded by the same
|
||||
// maxItems ceiling so a future watch build can't be over-fed.
|
||||
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
|
||||
return await WithStoreInfoAsync(BuildEntireCatalogOwned(maxItems));
|
||||
return Ok(BuildEntireCatalogOwned(maxItems));
|
||||
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
|
||||
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
var avatar = await db.Avatars
|
||||
.Where(a => a.PlayerId == me)
|
||||
@@ -134,7 +126,7 @@ public class AvatarItemsController(
|
||||
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (owned.Count == 0 && ownedHairDyes.Count == 0) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
|
||||
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List<UnlockedAvatarItemDto>());
|
||||
|
||||
var catalog = _catalog.Value;
|
||||
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
|
||||
@@ -150,10 +142,7 @@ public class AvatarItemsController(
|
||||
.ThenByDescending(x => x.OriginalIndex)
|
||||
.Select(x => x.Entry);
|
||||
|
||||
// Room is kept for the default-unlocked items so a large wardrobe
|
||||
// can't push them out (they'd show as buyable again).
|
||||
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count - catalog.DefaultUnlocked.Count);
|
||||
var emittedBase = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
|
||||
var perSlot = new Dictionary<int, int>();
|
||||
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
|
||||
foreach (var entry in ordered)
|
||||
@@ -167,15 +156,12 @@ 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);
|
||||
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue;
|
||||
|
||||
perSlot[slot] = count + 1;
|
||||
if (string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase))
|
||||
emittedBase.Add(entry.BaseGuid);
|
||||
// Preserve the bought color: a bare entry (FullDesc == BaseGuid)
|
||||
// emits the catalog DTO as-is ("{guid},,,"); a colored entry
|
||||
// overrides AvatarItemDesc with the stored swatch/mask desc so
|
||||
@@ -187,12 +173,7 @@ public class AvatarItemsController(
|
||||
{
|
||||
AvatarItemType = item.AvatarItemType,
|
||||
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
|
||||
// Hash the FULL desc, as the toggle-on path does: a
|
||||
// player can own the base item and several colorways of
|
||||
// it at once (entries dedupe by FullDesc), and reusing the
|
||||
// base id made them collide in the client's id-keyed
|
||||
// wardrobe.
|
||||
AvatarItemId = StableAvatarItemIdFull(ToWatchDesc(entry.FullDesc)),
|
||||
AvatarItemId = item.AvatarItemId,
|
||||
PlatformMask = item.PlatformMask,
|
||||
FriendlyName = item.FriendlyName,
|
||||
Tooltip = item.Tooltip,
|
||||
@@ -203,12 +184,6 @@ public class AvatarItemsController(
|
||||
if (result.Count >= maxItems) break;
|
||||
}
|
||||
|
||||
foreach (var guid in catalog.DefaultUnlocked)
|
||||
{
|
||||
if (result.Count >= maxItems) break;
|
||||
if (emittedBase.Add(guid)) result.Add(catalog.Items[guid]);
|
||||
}
|
||||
|
||||
foreach (var slug in ownedHairDyes)
|
||||
{
|
||||
if (result.Count >= maxItems) break;
|
||||
@@ -227,42 +202,7 @@ public class AvatarItemsController(
|
||||
IsBaseAvatarItem = false,
|
||||
});
|
||||
}
|
||||
return await WithStoreInfoAsync(result);
|
||||
}
|
||||
|
||||
/// <summary>Give each owned row the same FriendlyName, Tooltip and
|
||||
/// Rarity its shop tile shows. The client keeps this list's metadata
|
||||
/// per item, and the catalog dump only carries asset names (e.g.
|
||||
/// "(Shirt_Winter)" for colour variants) with no rarity or tooltip, so
|
||||
/// the owned list disagreed with the shop for most items. Rows with no
|
||||
/// shop tile keep the catalog name. Catalog DTOs are shared, so matched
|
||||
/// rows are copied, never mutated.</summary>
|
||||
private async Task<List<UnlockedAvatarItemDto>> WithStoreInfoAsync(List<UnlockedAvatarItemDto> rows)
|
||||
{
|
||||
var info = await StoreService.LoadAvatarItemStoreInfoAsync(db);
|
||||
if (info.Count == 0) return rows;
|
||||
return rows.Select(r => info.TryGetValue(r.AvatarItemDesc, out var s)
|
||||
? new UnlockedAvatarItemDto
|
||||
{
|
||||
AvatarItemType = r.AvatarItemType,
|
||||
AvatarItemDesc = r.AvatarItemDesc,
|
||||
AvatarItemId = r.AvatarItemId,
|
||||
PlatformMask = r.PlatformMask,
|
||||
FriendlyName = string.IsNullOrWhiteSpace(s.FriendlyName) ? r.FriendlyName : s.FriendlyName,
|
||||
Tooltip = s.Tooltip,
|
||||
Rarity = s.Rarity,
|
||||
TagList = r.TagList,
|
||||
IsBaseAvatarItem = r.IsBaseAvatarItem,
|
||||
}
|
||||
: r).ToList();
|
||||
}
|
||||
|
||||
/// <summary>The free items every player owns
|
||||
/// (<c>default_unlocked_variants</c>), capped at <paramref name="maxItems"/>.</summary>
|
||||
private static List<UnlockedAvatarItemDto> BuildDefaultUnlocked(int maxItems)
|
||||
{
|
||||
var catalog = _catalog.Value;
|
||||
return catalog.DefaultUnlocked.Take(maxItems).Select(g => catalog.Items[g]).ToList();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>The full owned set when the "everyone owns all avatar
|
||||
@@ -278,8 +218,6 @@ 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
|
||||
@@ -329,33 +267,14 @@ 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 items = _catalog.Value.Items;
|
||||
var rows = BaseAvatarItemIds.Keys
|
||||
.Where(items.ContainsKey)
|
||||
.Select(g => items[g])
|
||||
var rows = _catalog.Value.Items.Values
|
||||
.OrderBy(i => i.AvatarItemType)
|
||||
.ThenBy(i => i.FriendlyName)
|
||||
.Take(100)
|
||||
.ToList();
|
||||
return Ok(rows);
|
||||
}
|
||||
@@ -371,10 +290,6 @@ public class AvatarItemsController(
|
||||
/// data against the same safe list.</summary>
|
||||
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
|
||||
|
||||
/// <summary>Base GUIDs of the free items every player owns
|
||||
/// (<c>default_unlocked_variants</c> in avatar_item_lookup.json).</summary>
|
||||
public static IReadOnlyList<string> DefaultUnlockedGuids => _catalog.Value.DefaultUnlocked;
|
||||
|
||||
/// <summary>Master catalog of all renderable items, keyed by
|
||||
/// GUID. Used by callers that need item metadata (slot type,
|
||||
/// name, hair-color flags) outside of the per-player ownership
|
||||
@@ -412,24 +327,23 @@ public class AvatarItemsController(
|
||||
foreach (var guid in ParseInventory(invJson))
|
||||
owned.Add(guid);
|
||||
}
|
||||
// Free default items are owned by everyone, logged in or not.
|
||||
owned.UnionWith(_catalog.Value.DefaultUnlocked);
|
||||
|
||||
// The client parses this as List<IFNGBPJGINL> — the SAME element as
|
||||
// v4/items (FFFIMAGLKEG.txt:3222-3465, NBLIDNCEELM), read by
|
||||
// CLIANCNHNIE which registers TagList / AvatarItemId /
|
||||
// IsBaseAvatarItem too (CLIANCNHNIE.txt). The old 5-key subset left
|
||||
// every locked row at AvatarItemId 0 with a null TagList, so ship
|
||||
// the full catalog DTO.
|
||||
var catalog = _catalog.Value.Items;
|
||||
var rows = requested
|
||||
.Where(guid => !owned.Contains(guid))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
|
||||
.Where(item => item is not null)
|
||||
.Select(item => item!)
|
||||
.ToList();
|
||||
return Ok(await WithStoreInfoAsync(rows));
|
||||
.Select(item => new
|
||||
{
|
||||
item!.AvatarItemType,
|
||||
item.AvatarItemDesc,
|
||||
item.FriendlyName,
|
||||
item.Tooltip,
|
||||
item.Rarity,
|
||||
})
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
|
||||
@@ -567,8 +481,7 @@ public class AvatarItemsController(
|
||||
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
|
||||
IReadOnlySet<string> SafeGuids,
|
||||
IReadOnlyDictionary<int, List<string>> BySlot,
|
||||
IReadOnlyDictionary<string, int> SlotByGuid,
|
||||
IReadOnlyList<string> DefaultUnlocked);
|
||||
IReadOnlyDictionary<string, int> SlotByGuid);
|
||||
|
||||
private static CatalogData LoadCatalog()
|
||||
{
|
||||
@@ -595,8 +508,7 @@ public class AvatarItemsController(
|
||||
new Dictionary<string, UnlockedAvatarItemDto>(),
|
||||
new HashSet<string>(),
|
||||
new Dictionary<int, List<string>>(),
|
||||
new Dictionary<string, int>(),
|
||||
Array.Empty<string>());
|
||||
new Dictionary<string, int>());
|
||||
|
||||
using var fs = System.IO.File.OpenRead(path);
|
||||
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
|
||||
@@ -629,13 +541,13 @@ public class AvatarItemsController(
|
||||
// GUID as a color GUID and log "Can not find hair dye color".
|
||||
AvatarItemType = AvatarItemType.Outfit,
|
||||
AvatarItemDesc = row.Guid + ",,,",
|
||||
AvatarItemId = BaseAvatarItemIds.TryGetValue(row.Guid, out var baseId) ? baseId : StableAvatarItemId(row.Guid),
|
||||
AvatarItemId = StableAvatarItemId(row.Guid),
|
||||
PlatformMask = unchecked((int)0xFFFFFFFF),
|
||||
FriendlyName = pretty,
|
||||
Tooltip = string.Empty,
|
||||
Rarity = 0,
|
||||
TagList = string.Empty,
|
||||
IsBaseAvatarItem = IsBaseAvatarItemGuid(row.Guid),
|
||||
IsBaseAvatarItem = false,
|
||||
};
|
||||
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
||||
bySlot[row.OutfitType] = lst = new List<string>();
|
||||
@@ -643,23 +555,15 @@ public class AvatarItemsController(
|
||||
slotByGuid[row.Guid] = row.OutfitType;
|
||||
}
|
||||
|
||||
// Default-unlocked entries are all plain base items (no swatch, mask
|
||||
// or decal) in the March dump; only keep ones the catalog can render.
|
||||
var defaults = dump.DefaultUnlockedVariants
|
||||
.Where(v => string.IsNullOrEmpty(v.Swatch) && string.IsNullOrEmpty(v.Mask) && string.IsNullOrEmpty(v.Decal))
|
||||
.Select(v => v.Item)
|
||||
.Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g) && !IsBaseAvatarItemGuid(g))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new CatalogData(items, safe, bySlot, slotByGuid, defaults);
|
||||
return new CatalogData(items, safe, bySlot, slotByGuid);
|
||||
}
|
||||
|
||||
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned
|
||||
/// avatar item, derived from its base GUID (FNV-1a). The 2023 client
|
||||
/// keys the wardrobe by <c>AvatarItemId</c>, so it must be unique per
|
||||
/// item and stable across restarts. Colored variants must NOT use this — a player can own the base and
|
||||
/// several colorways together; see <see cref="StableAvatarItemIdFull"/>.</summary>
|
||||
/// item and stable across restarts. Colored variants reuse their base
|
||||
/// item's id (a player owns the base OR a variant, never both in the
|
||||
/// list), so no collision.</summary>
|
||||
public static int StableAvatarItemId(string? avatarItemDescOrGuid)
|
||||
{
|
||||
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
|
||||
@@ -714,19 +618,6 @@ public class AvatarItemsController(
|
||||
public List<string> SwatchGuids { get; set; } = new();
|
||||
[JsonPropertyName("decal_guids")]
|
||||
public List<string> DecalGuids { get; set; } = new();
|
||||
|
||||
// The client config's defaultUnlockedVariants: free items every
|
||||
// player owns.
|
||||
[JsonPropertyName("default_unlocked_variants")]
|
||||
public List<LookupVariant> DefaultUnlockedVariants { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class LookupVariant
|
||||
{
|
||||
[JsonPropertyName("item")] public string Item { get; set; } = string.Empty;
|
||||
[JsonPropertyName("mask")] public string Mask { get; set; } = string.Empty;
|
||||
[JsonPropertyName("swatch")] public string Swatch { get; set; } = string.Empty;
|
||||
[JsonPropertyName("decal")] public string Decal { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class LookupItem
|
||||
|
||||
@@ -268,11 +268,8 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase
|
||||
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
|
||||
///
|
||||
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
|
||||
/// (OPELMBNJHNO.txt:1375) returns the untyped <c>LDGADANDBIO</c> promise, but
|
||||
/// it is built from the body parsed as a RecNetResult: the continuation
|
||||
/// <c><UpdateLatestReadComment>b__9_1(DJMHAFPGLLN)</c>
|
||||
/// (OPELMBNJHNO_NestedType___c.txt:333) null-derefs an empty body and
|
||||
/// rejects on <c>Success</c>=false — see <see cref="RecNetResults"/>. Verb is <b>PUT</b>
|
||||
/// (OPELMBNJHNO.txt:1375) returns the status-only <c>LDGADANDBIO</c>, not a
|
||||
/// DTO — the body is never parsed, only the status code. Verb is <b>PUT</b>
|
||||
/// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments
|
||||
/// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so
|
||||
/// the request carries no body at all.
|
||||
@@ -283,7 +280,7 @@ public class CommentsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
await SetLastReadAsync(Me, roomId, commentId);
|
||||
await db.SaveChangesAsync();
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>Deletes a comment.
|
||||
|
||||
@@ -129,19 +129,14 @@ public class ConfigController(ConfigService configService) : ControllerBase
|
||||
/// Returns a broad default set so the picker is populated.</summary>
|
||||
[HttpGet("/emojiConfig/whitelistedEmojis")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult WhitelistedEmojis() => Ok(WhitelistedEmojiList);
|
||||
|
||||
/// <summary>The emoji allow-list served above. Also the validation set
|
||||
/// for <c>PUT account/me/emoji</c>, so a player can only pick an emoji
|
||||
/// the client's own picker offered.</summary>
|
||||
public static readonly IReadOnlyList<string> WhitelistedEmojiList = new[]
|
||||
public IActionResult WhitelistedEmojis() => Ok(new[]
|
||||
{
|
||||
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
|
||||
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
|
||||
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
|
||||
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
|
||||
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
|
||||
};
|
||||
});
|
||||
|
||||
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
|
||||
/// 2020.12 client deserializes this endpoint as a raw Boolean.
|
||||
|
||||
@@ -371,22 +371,11 @@ public class CustomAvatarItemsController(
|
||||
var allowed = player is not null
|
||||
&& !player.IsJunior
|
||||
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
|
||||
// NOT a bare boolean, unlike the two sibling gates below. The issuing
|
||||
// method GGMDLMLJDDK (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route on
|
||||
// :6205) parses the body as DJMHAFPGLLN — {Boolean @0x10, String} —
|
||||
// (generic token 0x188477F70 at :6216; Func<DJMHAFPGLLN,…> at :6223)
|
||||
// and its success continuation HONEEPBHNLB reads the bool at +0x10
|
||||
// (:9155-9216).
|
||||
// DJMHAFPGLLN's converter LMCCNLLHLCJ registers exactly the keys
|
||||
// "Success" and "Error" (LMCCNLLHLCJ.txt:191-254). A bare `true`
|
||||
// failed that parse, so the client logged "Failed to query if local
|
||||
// account can create Custom Avatar Items: An error occurred" and
|
||||
// treated the account as unable to create shirts.
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["Success"] = allowed,
|
||||
["Error"] = allowed ? null : "Custom avatar item creation is not available for this account.",
|
||||
});
|
||||
// Bare JSON boolean, like the two sibling gates below: the client's
|
||||
// issuing method is FGLDKEJLAKB<System.Boolean> GGMDLMLJDDK()
|
||||
// (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an
|
||||
// object body threw and the can-create gate blocked shirt creation.
|
||||
return Content(allowed ? "true" : "false", "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("isCreationEnabled")]
|
||||
@@ -397,17 +386,22 @@ public class CustomAvatarItemsController(
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
||||
|
||||
/// <summary>Bare JSON integer. The client's IHLMDLKOLNB pricing config is
|
||||
/// built CLIENT-side: HCBOHHEACPK sends through FDKKOPAPDGF<Int32>
|
||||
/// (RecNet.Runtime/MPBLNLMCEDL.txt:6922 — the same generic token
|
||||
/// 0x18847B0B8 as the bare-Int32 itemOwnershipLimit call at :2981) and
|
||||
/// continues with <c><GetPricingConfig>b__69_0(Int32 minimumPrice)</c>
|
||||
/// (MPBLNLMCEDL_NestedType___c.txt:286). An object body failed the Int32
|
||||
/// parse, so the designer's public-price floor never loaded.</summary>
|
||||
/// <summary>The client DTO (IHLMDLKOLNB) is an object with exactly one
|
||||
/// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere
|
||||
/// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are
|
||||
/// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT
|
||||
/// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft
|
||||
/// ignores the ones that don't match, and a total miss falls back to the
|
||||
/// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing.</summary>
|
||||
[HttpGet("minPriceForPublicItem")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult MinPriceForPublicItem()
|
||||
=> Content(MinPublicItemPrice.ToString(System.Globalization.CultureInfo.InvariantCulture), "application/json");
|
||||
public IActionResult MinPriceForPublicItem() => Ok(new
|
||||
{
|
||||
MinPrice = MinPublicItemPrice,
|
||||
Price = MinPublicItemPrice,
|
||||
MinimumPrice = MinPublicItemPrice,
|
||||
MinPriceForPublicItem = MinPublicItemPrice,
|
||||
});
|
||||
|
||||
/// <summary>GET api/customAvatarItems/v1/design — hand back the caller's
|
||||
/// saved in-progress shirt design. Verb ordinal 0 = GET
|
||||
|
||||
@@ -56,12 +56,9 @@ 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,
|
||||
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(row.Gift));
|
||||
new { FromPlayerId = sender, CurrencyType = currencyType, Currency = currency, Xp = xp, Message = message });
|
||||
}
|
||||
|
||||
return Ok(new { Success = true, Sent = created.Count });
|
||||
|
||||
@@ -1295,14 +1295,39 @@ public class ImagesController(
|
||||
.Select(s => long.TryParse(s, out var v) ? v : 0L)
|
||||
.Where(v => v > 0);
|
||||
|
||||
/// <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 Task NotifyAccountImageChangedAsync(PlayerEntity player)
|
||||
{
|
||||
var account = new DorkNet.Models.Auth.RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = player.DisplayName ?? player.Username,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = player.IsJunior,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
};
|
||||
var selfAccount = new DorkNet.Models.Auth.RecNetSelfAccount
|
||||
{
|
||||
AccountId = account.AccountId,
|
||||
RawUsername = account.RawUsername,
|
||||
Username = account.Username,
|
||||
DisplayName = account.DisplayName,
|
||||
ProfileImage = account.ProfileImage,
|
||||
TreatAsJunior = account.TreatAsJunior,
|
||||
HasBirthday = account.HasBirthday,
|
||||
Platforms = account.Platforms,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = player.Phone ?? string.Empty,
|
||||
Birthday = player.Birthday,
|
||||
JuniorState = player.IsJunior ? 1 : 0,
|
||||
ParentAccountId = null,
|
||||
};
|
||||
return Task.WhenAll(
|
||||
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
|
||||
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
|
||||
}
|
||||
|
||||
private static string NormaliseTaggedIds(string? csv, long exceptId)
|
||||
{
|
||||
|
||||
-52
@@ -34,58 +34,6 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>The caller's referred players, paged.
|
||||
///
|
||||
/// <c>FGLDKEJLAKB<GPJIMAHMAIO> EGEEFJHAGAM(Int32? take, String continuationToken)</c>
|
||||
/// (RecNet.Runtime/LINKIECPDML.txt:337) builds this through the URL builder
|
||||
/// LPPEFJMCJFN ("api/incentivizedreferrals/" + "referrals", :677-701, with
|
||||
/// <c>take</c> and <c>continuationToken</c> query params at :716/:737) — which
|
||||
/// is why the plain route-literal sweep never saw it — and issues a GET
|
||||
/// (:164-170). Reply formatter KOEEACLMAFE.txt:203-238:
|
||||
/// <c>{Referrals: [...], ContinuationToken: string}</c>; each entry
|
||||
/// (ADAFNGNADFH.txt:279-346) is <c>{InviteeAccountId: Int32, CreatedAt:
|
||||
/// DateTime, VerifiedAt: DateTime?}</c>.
|
||||
///
|
||||
/// A referral is recorded when a brand-new player redeems the caller's
|
||||
/// referral code (LinkController.CreditReferralAsync), and that redemption
|
||||
/// is what counts it, so it is verified at the moment it is created. The
|
||||
/// continuation token is the next offset; null once the list is exhausted.</summary>
|
||||
[HttpGet("referrals")]
|
||||
public async Task<IActionResult> Referrals([FromQuery] int? take, [FromQuery] string? continuationToken)
|
||||
{
|
||||
var size = Math.Clamp(take ?? 20, 1, 100);
|
||||
var offset = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
|
||||
var prefix = $"referral:credited:{Me}:";
|
||||
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(prefix))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.Skip(offset)
|
||||
.Take(size + 1)
|
||||
.Select(s => new { s.PlayerId, s.Value })
|
||||
.ToListAsync();
|
||||
|
||||
var page = rows.Take(size).Select(r =>
|
||||
{
|
||||
var at = DateTime.TryParse(r.Value, null,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind, out var credited)
|
||||
? credited
|
||||
: DateTime.UnixEpoch;
|
||||
return new
|
||||
{
|
||||
InviteeAccountId = (int)r.PlayerId,
|
||||
CreatedAt = at,
|
||||
VerifiedAt = (DateTime?)at,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Referrals = page,
|
||||
ContinuationToken = rows.Count > size ? (offset + size).ToString() : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Client contract: the claim response deserializes as a top-level
|
||||
// List<RewardSelection> where each entry is
|
||||
// { rewardSelectionId: long, rewardType: string } (RecNet.Runtime
|
||||
|
||||
@@ -1095,9 +1095,11 @@ public class InventionsController(
|
||||
});
|
||||
inv.CheerCount += 1;
|
||||
await db.SaveChangesAsync();
|
||||
// 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.
|
||||
|
||||
if (inv.CreatorPlayerId != pid)
|
||||
await notifications.NotifyAsync(inv.CreatorPlayerId,
|
||||
PushNotificationId.InventionModerationStateChanged,
|
||||
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
|
||||
}
|
||||
else if (!req.Cheer && existing is not null)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
PushNotificationId.ConsumableMappingRemoved, ConsumableMappingWire(row));
|
||||
row.Quantity > 0
|
||||
? PushNotificationId.ConsumableMappingAdded
|
||||
: PushNotificationId.ConsumableMappingRemoved,
|
||||
new { row.Id, row.ItemSlug, row.Quantity });
|
||||
return Ok(new { row.Id, Count = row.Quantity });
|
||||
}
|
||||
|
||||
@@ -332,7 +332,8 @@ public class InventoryController(
|
||||
if (source.Quantity == 0) db.PlayerInventory.Remove(source);
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.NotifyAsync(recipient,
|
||||
PushNotificationId.ConsumableMappingAdded, ConsumableMappingWire(target));
|
||||
PushNotificationId.ConsumableMappingAdded,
|
||||
new { target.Id, target.ItemSlug, target.Quantity });
|
||||
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
|
||||
}
|
||||
|
||||
@@ -448,26 +449,6 @@ 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)
|
||||
|
||||
@@ -208,10 +208,8 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
||||
/// <c>DELETE api/keepsakes/{guid}</c> - <c>NCCLEJPIABA.JBKENCNIEPA(System.Guid)</c> builds
|
||||
/// <c>String.Format("{0}/{1}", "api/keepsakes", guid)</c> and passes verb 4 (DELETE) to the
|
||||
/// BNDIAONDFFF ctor (NCCLEJPIABA.txt:1544, 1687-1698 - <c>Move rdx, 4</c>). Its return type is
|
||||
/// the non-generic <c>LDGADANDBIO</c> promise, but that promise comes from
|
||||
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult) — the body IS parsed, as a
|
||||
/// <c>{success,error}</c> RecNetResult, and an empty 200 null-derefs into a failed delete.
|
||||
/// See <see cref="RecNetResults"/>. Client-side the tool refuses unless the caller is at least a room co-owner
|
||||
/// the non-generic <c>LDGADANDBIO</c> promise, so no response body is deserialised; a bare 200
|
||||
/// is enough. Client-side the tool refuses unless the caller is at least a room co-owner
|
||||
/// ("Must be at least a room co-owner to modify keepsakes.",
|
||||
/// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side.
|
||||
/// </summary>
|
||||
@@ -237,7 +235,7 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
||||
db.Keepsakes.RemoveRange(orphanedCollections
|
||||
.Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId));
|
||||
await db.SaveChangesAsync();
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -152,6 +152,8 @@ 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 = "" });
|
||||
@@ -247,14 +249,21 @@ public class InGameModerationController(
|
||||
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
|
||||
caller, target, reason, p.BannedUntil, displayReason);
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents;
|
||||
public class PlayerEventsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
PlayerPresenceService presence) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
@@ -818,7 +817,7 @@ public class PlayerEventsController(
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
await ApplyBodyToExtrasAsync(extras);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -902,7 +901,10 @@ public class PlayerEventsController(
|
||||
|
||||
// Tell the creator someone RSVP'd. They can ignore but the
|
||||
// watch will refresh their event detail card.
|
||||
await NotifyResponseChangedAsync(ev, row);
|
||||
if (ev.CreatorPlayerId != Me)
|
||||
await notifications.NotifyAsync(ev.CreatorPlayerId,
|
||||
PushNotificationId.PlayerEventResponseChanged,
|
||||
new { ev.Id, From = Me, body.Response });
|
||||
// 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.
|
||||
@@ -945,15 +947,10 @@ public class PlayerEventsController(
|
||||
var existing = await db.PlayerEventResponses
|
||||
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response };
|
||||
db.PlayerEventResponses.Add(existing);
|
||||
}
|
||||
db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response });
|
||||
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*/);
|
||||
}
|
||||
|
||||
@@ -971,18 +968,9 @@ public class PlayerEventsController(
|
||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||
|
||||
var deleted = await db.PlayerEventResponses
|
||||
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*/);
|
||||
}
|
||||
|
||||
@@ -1005,7 +993,6 @@ 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();
|
||||
@@ -1013,7 +1000,6 @@ 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));
|
||||
}
|
||||
|
||||
@@ -1035,7 +1021,6 @@ public class PlayerEventsController(
|
||||
evt.Title = Clamp(value.Trim(), 128);
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1049,7 +1034,6 @@ public class PlayerEventsController(
|
||||
evt.Description = Clamp(value.Trim(), 2000);
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1072,7 +1056,6 @@ 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));
|
||||
}
|
||||
|
||||
@@ -1089,7 +1072,6 @@ 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));
|
||||
}
|
||||
|
||||
@@ -1103,7 +1085,6 @@ 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));
|
||||
}
|
||||
|
||||
@@ -1118,7 +1099,6 @@ 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));
|
||||
}
|
||||
|
||||
@@ -1140,7 +1120,6 @@ 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));
|
||||
}
|
||||
|
||||
@@ -1166,20 +1145,12 @@ public class PlayerEventsController(
|
||||
extras.SupportMultiInstanceRoomChat =
|
||||
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
|
||||
"SupportMultiInstanceRoomChat") ?? false;
|
||||
// These two are boxed enums (typeof(AKFCIMLMAKA) at
|
||||
// CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:117-165), and a boxed non-string
|
||||
// field goes through Object.ToString (BNDIAONDFFF.txt:947, ISIL 107-110),
|
||||
// so they arrive as member NAMES. int.TryParse failed on every one and
|
||||
// the settings were always stored as 0.
|
||||
extras.DefaultBroadcastPermissions = ParseBroadcastPermissions(
|
||||
await ReadStringFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions"))
|
||||
?? extras.DefaultBroadcastPermissions;
|
||||
extras.CanRequestBroadcastPermissions = ParseBroadcastPermissions(
|
||||
await ReadStringFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions"))
|
||||
?? extras.CanRequestBroadcastPermissions;
|
||||
extras.DefaultBroadcastPermissions =
|
||||
await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0;
|
||||
extras.CanRequestBroadcastPermissions =
|
||||
await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0;
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1210,7 +1181,6 @@ public class PlayerEventsController(
|
||||
}
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
@@ -1220,7 +1190,6 @@ 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.
|
||||
@@ -1230,7 +1199,6 @@ public class PlayerEventsController(
|
||||
await db.PlayerSettings
|
||||
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
|
||||
.ExecuteDeleteAsync();
|
||||
await NotifyEventDeletedAsync(eventId, notify);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -1269,7 +1237,6 @@ 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);
|
||||
@@ -1278,15 +1245,17 @@ public class PlayerEventsController(
|
||||
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
|
||||
continue;
|
||||
}
|
||||
// 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);
|
||||
db.Messages.Add(new MessageEntity
|
||||
{
|
||||
SenderPlayerId = sender,
|
||||
RecipientPlayerId = rid,
|
||||
Body = inviteText,
|
||||
});
|
||||
await notifications.NotifyAsync(rid,
|
||||
PushNotificationId.PlayerEventCreated,
|
||||
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
||||
}
|
||||
|
||||
@@ -1313,56 +1282,21 @@ public class PlayerEventsController(
|
||||
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
|
||||
// Attendees' event cards key off BroadcastingRoomInstanceId, so send
|
||||
// them the updated event.
|
||||
await NotifyEventUpdatedAsync(evt);
|
||||
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge
|
||||
// them to re-fetch instead of sending a chat message.
|
||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <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
|
||||
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
|
||||
{
|
||||
var recipients = await db.PlayerEventResponses
|
||||
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
|
||||
.Select(r => r.PlayerId)
|
||||
.Distinct()
|
||||
.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) });
|
||||
.ToListAsync();
|
||||
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
|
||||
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
|
||||
}
|
||||
|
||||
// ── Report ───────────────────────────────────────────────────────────
|
||||
@@ -1503,33 +1437,6 @@ public class PlayerEventsController(
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Broadcast-permission mode as a number. Members per the
|
||||
/// 2023.06.21 dump (enum KJJFEBEMPKM, mapped by
|
||||
/// EventsConfig.BroadcastPermissionsModeFriendlyNameMapping): None = 0,
|
||||
/// RoomOwners = 256, All = Int32.MaxValue. Accepts the number itself (JSON
|
||||
/// bodies send numbers), a member name, or a comma-joined flags string.
|
||||
/// Null when the value is absent or unrecognised, so the stored setting is
|
||||
/// kept rather than silently zeroed.</summary>
|
||||
public static int? ParseBroadcastPermissions(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) return n;
|
||||
var total = 0;
|
||||
foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
int? value = part.ToLowerInvariant() switch
|
||||
{
|
||||
"none" => 0,
|
||||
"roomowners" => 256,
|
||||
"all" => int.MaxValue,
|
||||
_ => null,
|
||||
};
|
||||
if (value is null) return null;
|
||||
total |= value.Value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
|
||||
@@ -48,18 +48,14 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
.Where(p => p.Id == me)
|
||||
.Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam))
|
||||
.FirstOrDefaultAsync();
|
||||
// Both calls are read as DJMHAFPGLLN = {Success, Error} (the formatter,
|
||||
// RecNet.Runtime/LMCCNLLHLCJ.txt:191-218, knows no "Message" key). A
|
||||
// failure without Error reached Promise.Rejected(null) and threw
|
||||
// "Error message cannot be null" instead of showing the reason.
|
||||
if (moderator != true)
|
||||
return Ok(new { Success = false, Error = "not_authorized", Message = "not_authorized" });
|
||||
return Ok(new { Success = false, Message = "not_authorized" });
|
||||
|
||||
var target = warnedPlayerId
|
||||
?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0);
|
||||
if (target <= 0) return Ok(new { Success = false, Error = "missing_player", Message = "missing_player" });
|
||||
if (target <= 0) return Ok(new { Success = false, Message = "missing_player" });
|
||||
if (!await db.Players.AnyAsync(p => p.Id == target))
|
||||
return Ok(new { Success = false, Error = "player_not_found", Message = "player_not_found" });
|
||||
return Ok(new { Success = false, Message = "player_not_found" });
|
||||
|
||||
// Same "warning:{id}|message|category|createdAt|acknowledged" layout the
|
||||
// GET list handler parses in ToWarningWire.
|
||||
@@ -79,7 +75,7 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
"false"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = id });
|
||||
return Ok(new { Success = true, Message = string.Empty, WarningId = id });
|
||||
}
|
||||
|
||||
/// <summary>The 2023-03-21 client's acknowledge call takes NO arguments —
|
||||
@@ -108,19 +104,19 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
|
||||
.OrderByDescending(s => s.Id)
|
||||
.ToListAsync();
|
||||
row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged);
|
||||
if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty });
|
||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
else
|
||||
{
|
||||
row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
|
||||
if (row is null) return Ok(new { Success = true, Error = string.Empty, Message = string.Empty });
|
||||
if (row is null) return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
var parts = row.Value.Split('|');
|
||||
row.Value = string.Join('|', parts.Take(3).Append("true"));
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Error = string.Empty, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
|
||||
return Ok(new { Success = true, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
|
||||
}
|
||||
|
||||
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
|
||||
|
||||
@@ -48,11 +48,7 @@ namespace DorkNet.Server.Controllers.API.Relationships.V2;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class RelationshipsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
ServerSettingsService settings) : ControllerBase
|
||||
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase
|
||||
{
|
||||
// RelationshipType — int values match Relationship.RelationshipType
|
||||
// in the patched dump.cs.
|
||||
@@ -230,9 +226,7 @@ public class RelationshipsController(
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
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);
|
||||
await notifications.FriendRequestReceived(id, me);
|
||||
return Ok(BuildRelationship(row, me));
|
||||
}
|
||||
|
||||
@@ -257,8 +251,7 @@ public class RelationshipsController(
|
||||
rel.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await PushPairAsync(me, id);
|
||||
await systemNotifications.SendAsync(id, SystemNotificationService.MessageType.FriendRequestAccepted, fromPlayerId: me);
|
||||
await notifications.FriendRequestAccepted(id, me);
|
||||
return Ok(BuildRelationship(rel, me));
|
||||
}
|
||||
|
||||
@@ -292,7 +285,7 @@ public class RelationshipsController(
|
||||
|
||||
db.Relationships.RemoveRange(removable);
|
||||
await db.SaveChangesAsync();
|
||||
await PushPairAsync(me, id);
|
||||
await notifications.FriendRemoved(id, me);
|
||||
|
||||
var remaining = pair.Except(removable).ToList();
|
||||
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
|
||||
@@ -340,8 +333,6 @@ public class RelationshipsController(
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -420,10 +411,6 @@ public class RelationshipsController(
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -524,36 +511,6 @@ public class RelationshipsController(
|
||||
|
||||
// ── 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
|
||||
|
||||
@@ -284,11 +284,7 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
||||
offer.IsDeleted = true;
|
||||
offer.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
// MIHKAJFMIPE.OHCONDGELJB hands this body to BFDGEKKNAPB.PEFKBAEAOPD
|
||||
// (ExpandRecNetResult): it is a {success,error} RecNetResult, not the
|
||||
// offer. The offer wire has no Success key, so it parsed as a failure
|
||||
// with a null Error and the delete toast threw instead of closing.
|
||||
return RecNetResults.Ok();
|
||||
return Ok(ToOfferWire(offer, currency));
|
||||
}
|
||||
|
||||
/// <summary>Both verbs are mapped on purpose: the client picks GET or POST at
|
||||
|
||||
@@ -1375,13 +1375,13 @@ public class RoomsModerationController(
|
||||
{
|
||||
var newName = await ReadCloneNameAsync();
|
||||
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (source is null) return CloneResult(null, "That room doesn't exist.");
|
||||
if (source is null) return NotFound();
|
||||
// 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 is refused.
|
||||
// so without the IsAGRoom exception every base-room create 403s.
|
||||
if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me)
|
||||
return CloneResult(null, "You can't clone this room!");
|
||||
return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
newName = $"{source.Name}Copy";
|
||||
@@ -1517,22 +1517,17 @@ public class RoomsModerationController(
|
||||
|
||||
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
|
||||
|
||||
// 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));
|
||||
// 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));
|
||||
}
|
||||
/// <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.
|
||||
///
|
||||
|
||||
@@ -156,19 +156,9 @@ 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
|
||||
{
|
||||
BalanceAddType = 0,
|
||||
Delta = (int)-item.Price,
|
||||
Balance = newBalance,
|
||||
Platform = 0,
|
||||
req.CurrencyType,
|
||||
});
|
||||
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug });
|
||||
|
||||
if (isGift)
|
||||
{
|
||||
@@ -176,13 +166,24 @@ 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,
|
||||
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// For gift purchases the buyer doesn't get a box — their flow
|
||||
|
||||
@@ -177,10 +177,7 @@ public class SubscriptionsController(
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
// The client reads this body as DJMHAFPGLLN, the {success,error}
|
||||
// RecNetResult (FGLDKEJLAKB<DJMHAFPGLLN> at IKMMOCKDKAF.txt LPFOPPHGDHG,
|
||||
// instr 098) — answer in that shape rather than an empty 200.
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -61,7 +61,7 @@ public class AccountsController(
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
return Ok(AccountDtos.Self(player));
|
||||
return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -154,7 +154,7 @@ public class AccountsController(
|
||||
playerId, username, summary);
|
||||
}
|
||||
|
||||
await notifications.LogoutAsync(playerId, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||
await notifications.KickPlayerAsync(playerId, "This account has been deleted.");
|
||||
presence.Clear(playerId);
|
||||
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
@@ -194,7 +194,7 @@ public class AccountsController(
|
||||
{
|
||||
var player = await playerService.GetByIdAsync(accountId);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(AccountDtos.Public(player));
|
||||
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
}
|
||||
|
||||
// Accounts.GetAccountByUsername
|
||||
@@ -203,7 +203,7 @@ public class AccountsController(
|
||||
{
|
||||
var player = await playerService.GetByUsernameAsync(username);
|
||||
if (player is null) return NotFound();
|
||||
return Ok(AccountDtos.Public(player));
|
||||
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
|
||||
}
|
||||
|
||||
// Accounts.CreateAccount — actual URL observed in server logs:
|
||||
@@ -263,7 +263,7 @@ public class AccountsController(
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Account = AccountDtos.Public(player),
|
||||
Account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ public class AccountsController(
|
||||
var clamped = Math.Clamp(take, 1, 50);
|
||||
var matches = await playerService.SearchAsync(term.Trim(), clamped);
|
||||
return Ok(matches
|
||||
.Select(p => AccountDtos.Public(p))
|
||||
.Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
@@ -302,12 +302,6 @@ public class AccountsController(
|
||||
[HttpPost("/account/v1/emails")]
|
||||
[HttpPost("/account/v1/phones")]
|
||||
[HttpPost("/account/v1/byplatformid")]
|
||||
// 2023 contact lookup: OPMAPIOEIFG.HHAAAPKCLMN (RecNet.Runtime/OPMAPIOEIFG.txt:11864)
|
||||
// POSTs "account/bulk/" + "email" or "phone" (:11590-11620, verb 2) with the
|
||||
// values as repeated form fields of the same name, and reads the same account
|
||||
// list (List<BLOJAFNJPJI>) as account/bulk. Both suffixes 404'd.
|
||||
[HttpPost("/account/bulk/email")]
|
||||
[HttpPost("/account/bulk/phone")]
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
// The watch calls this route family with payloads keyed by
|
||||
@@ -407,7 +401,7 @@ public class AccountsController(
|
||||
{
|
||||
var p = await playerService.GetByIdAsync(id);
|
||||
if (p is not null)
|
||||
accounts.Add(AccountDtos.Public(p));
|
||||
accounts.Add(BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName));
|
||||
}
|
||||
return Ok(accounts);
|
||||
}
|
||||
@@ -439,11 +433,8 @@ public class AccountsController(
|
||||
}
|
||||
|
||||
// Phone-confirm is the one mutation we don't persist — there's no
|
||||
// SMS provider in the loop. Acknowledge silently. The 2023 client
|
||||
// composes the route as "account/me/" + "confirmphone" with POST
|
||||
// (OPMAPIOEIFG.OBAGJBBIFGB) and reads the RecNetResult body.
|
||||
// SMS provider in the loop. Acknowledge silently.
|
||||
[HttpPost("/account/v1/confirmphone")]
|
||||
[HttpPost("/account/me/confirmphone")]
|
||||
public IActionResult ConfirmPhone() =>
|
||||
Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
|
||||
@@ -638,126 +629,6 @@ public class AccountsController(
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
// ── 2023 profile edits ────────────────────────────────────────────
|
||||
// All four go through OPMAPIOEIFG.OBAGJBBIFGB, which builds
|
||||
// "account/me/" + suffix (RecNet.Runtime OPMAPIOEIFG.txt:8579) and
|
||||
// reports "Failed to modify <suffix>" on any non-2xx (:8609). Every call
|
||||
// site passes verb 3 = PUT. The form field name is NOT the suffix — each
|
||||
// comes from the lambda that fills the request:
|
||||
// emoji → "displayEmoji" (OPMAPIOEIFG_NestedType_KPGNAEPGLAC.txt:57)
|
||||
// personalpronouns → "pronounFlags" (…_LCCEHLBIOPK.txt:67, boxed Int32)
|
||||
// identityflags → "identityFlags" (…_HHDFMJPLFHG.txt:67, boxed Int32)
|
||||
// bannerimage → "imageName" (…_NJKHNGEPODH.txt:57)
|
||||
// The response body is never parsed (LDGADANDBIO handle), so the new
|
||||
// value reaches the editor's UI and other players only through the
|
||||
// AccountUpdate / SelfAccountUpdate push in NotificationService.
|
||||
// None of these routes existed before, so every edit 404'd.
|
||||
|
||||
/// <summary>PUT <c>/account/me/emoji</c> — form <c>displayEmoji</c>.
|
||||
/// Empty clears the emoji; anything else must be on the
|
||||
/// <c>emojiConfig/whitelistedEmojis</c> list the picker was filled
|
||||
/// from, and is stored in that list's canonical spelling.</summary>
|
||||
[HttpPut("/account/me/emoji")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeEmoji([FromForm(Name = "displayEmoji")] string? displayEmoji)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
|
||||
var submitted = displayEmoji?.Trim() ?? string.Empty;
|
||||
string stored;
|
||||
if (submitted.Length == 0)
|
||||
{
|
||||
stored = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
var canonical = ResolveWhitelistedEmoji(submitted);
|
||||
if (canonical is null)
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "emoji_not_allowed" });
|
||||
stored = canonical;
|
||||
}
|
||||
|
||||
player.DisplayEmoji = stored;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>Whitelist match that ignores U+FE0F variation selectors, so
|
||||
/// "❤" and "❤️" resolve to the same entry. Returns the list's spelling
|
||||
/// (the client highlights the current pick by string equality against
|
||||
/// the list it fetched).</summary>
|
||||
internal static string? ResolveWhitelistedEmoji(string value)
|
||||
{
|
||||
static string Strip(string s) => s.Replace("️", string.Empty, StringComparison.Ordinal);
|
||||
var needle = Strip(value);
|
||||
foreach (var e in API.Config.V1.ConfigController.WhitelistedEmojiList)
|
||||
if (string.Equals(Strip(e), needle, StringComparison.Ordinal)) return e;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>/account/me/personalpronouns</c> — form
|
||||
/// <c>pronounFlags</c> (pronoun flags enum as Int32).</summary>
|
||||
[HttpPut("/account/me/personalpronouns")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangePersonalPronouns([FromForm(Name = "pronounFlags")] string? pronounFlags)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
if (!TryParseFlags(pronounFlags, out var flags))
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "invalid_pronouns" });
|
||||
|
||||
player.PersonalPronouns = flags;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>/account/me/identityflags</c> — form
|
||||
/// <c>identityFlags</c> (identity flags enum as Int32).</summary>
|
||||
[HttpPut("/account/me/identityflags")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeIdentityFlags([FromForm(Name = "identityFlags")] string? identityFlags)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
if (!TryParseFlags(identityFlags, out var flags))
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "invalid_identity_flags" });
|
||||
|
||||
player.IdentityFlags = flags;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
/// <summary>Flags arrive as the boxed enum's Int32 ToString(). Negative
|
||||
/// values are not valid bitmasks for either enum.</summary>
|
||||
private static bool TryParseFlags(string? raw, out int flags)
|
||||
=> int.TryParse(raw?.Trim(), System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out flags)
|
||||
&& flags >= 0;
|
||||
|
||||
/// <summary>PUT <c>/account/me/bannerimage</c> — form <c>imageName</c>,
|
||||
/// the storage name of an image the player already uploaded (the
|
||||
/// portfolio-banner picker offers the player's own photos). Empty clears
|
||||
/// the banner.</summary>
|
||||
[HttpPut("/account/me/bannerimage")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> ChangeBannerImage([FromForm(Name = "imageName")] string? imageName)
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
var name = imageName?.Trim() ?? string.Empty;
|
||||
if (name.Length > 256)
|
||||
return BadRequest(new RecNetResult { Success = false, Error = "invalid_image" });
|
||||
|
||||
player.BannerImageName = name.Length == 0 ? null : name;
|
||||
await db.SaveChangesAsync();
|
||||
await notifications.AccountChanged(player);
|
||||
return Ok(new RecNetResult { Success = true, Error = string.Empty });
|
||||
}
|
||||
|
||||
// ParentalControl.Deserialize at RVA 0x14543A0 — required keys:
|
||||
// accountId (int, GetKey)
|
||||
// disallowInAppPurchases (bool, GetKey)
|
||||
@@ -809,56 +680,63 @@ public class AccountsController(
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(10);
|
||||
await SetPlayerSettingAsync(player.Id, RemoteLoginTokenKey, $"{token}|{expiresAt:O}");
|
||||
// FGLDKEJLAKB<System.String> LGKLFFPACGL() (RecNet.Runtime/
|
||||
// LBNJFPOLCDL.txt:8382) deserialises the reply as a BARE JSON string.
|
||||
// The old {Success, Token, ...} object hit the string reader's '"'
|
||||
// check and every "open on the web" link failed with "Failed to
|
||||
// CreateLoginToken". The token is redeemed by connect/token
|
||||
// grant_type=login_token (AuthController.Token).
|
||||
return Content(System.Text.Json.JsonSerializer.Serialize(token), "application/json");
|
||||
await SetPlayerSettingAsync(player.Id, "remote_login_token", $"{token}|{expiresAt:O}");
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
Token = token,
|
||||
LoginToken = token,
|
||||
ExpiresAt = expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>PlayerSettings key of the one-shot token minted above.
|
||||
/// Value is <c>{token}|{expiresAtUtc:O}</c>.</summary>
|
||||
public const string RemoteLoginTokenKey = "remote_login_token";
|
||||
|
||||
/// <summary>In-game approval of another device's login code.
|
||||
///
|
||||
/// <c>LDGADANDBIO BJMMDKNPCLD(String)</c> (RecNet.Runtime/LBNJFPOLCDL.txt:7896)
|
||||
/// POSTs the single form field <c>code</c> and reads the reply through
|
||||
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> as a <c>{success,error}</c> RecNetResult
|
||||
/// (failure toast "Failed to authorize login"). The code is the
|
||||
/// <c>user_code</c> that <c>connect/deviceauthorization</c> handed the other
|
||||
/// device, which meanwhile polls <c>connect/token</c> with
|
||||
/// <c>grant_type=urn:ietf:params:oauth:grant-type:device_code</c>
|
||||
/// (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451). Approving binds the
|
||||
/// pending authorization to the CALLER, and that device's next poll is
|
||||
/// issued the caller's tokens (<see cref="DeviceAuthorizations"/>).
|
||||
///
|
||||
/// This used to look the code up among createlogintoken tokens — which a
|
||||
/// user_code never matches, so every approval 401'd — and answered with a
|
||||
/// token pair, which is not a RecNetResult either.</summary>
|
||||
[HttpPost("/account/me/remoteauth")]
|
||||
[HttpGet("/account/me/remoteauth")]
|
||||
public async Task<IActionResult> RemoteAuth()
|
||||
{
|
||||
var approver = await GetCurrentPlayerAsync();
|
||||
if (approver is null) return Unauthorized();
|
||||
|
||||
var code = Request.Query["code"].FirstOrDefault()
|
||||
?? Request.Query["user_code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(code) && Request.HasFormContentType)
|
||||
var token = Request.Query["token"].FirstOrDefault()
|
||||
?? Request.Query["loginToken"].FirstOrDefault()
|
||||
?? Request.Query["code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(token) && Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
code = form["code"].FirstOrDefault() ?? form["user_code"].FirstOrDefault();
|
||||
token = form["token"].FirstOrDefault()
|
||||
?? form["loginToken"].FirstOrDefault()
|
||||
?? form["code"].FirstOrDefault();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return RecNetResults.Fail("missing_code");
|
||||
|
||||
return await DeviceAuthorizations.ApproveAsync(db, code, approver.Id, DateTime.UtcNow)
|
||||
? RecNetResults.Ok()
|
||||
: RecNetResults.Fail("invalid_or_expired_code");
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
var player = await GetCurrentPlayerAsync();
|
||||
if (player is null) return Unauthorized();
|
||||
var pair = authService.GenerateTokenPair(player.Id);
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = pair.AccessToken,
|
||||
RefreshToken = pair.RefreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
var prefix = token.Trim() + "|";
|
||||
var row = await db.PlayerSettings
|
||||
.Where(s => s.Key == "remote_login_token" && s.Value.StartsWith(prefix))
|
||||
.OrderByDescending(s => s.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (row is null) return Unauthorized();
|
||||
|
||||
var rawExpiry = row.Value[prefix.Length..];
|
||||
if (!DateTime.TryParse(rawExpiry, out var expiresAt) || expiresAt < DateTime.UtcNow)
|
||||
return Unauthorized();
|
||||
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
var (access, refresh) = authService.GenerateTokenPair(row.PlayerId);
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
AccessToken = access,
|
||||
RefreshToken = refresh,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/account/recoverpassword")]
|
||||
@@ -1075,15 +953,8 @@ public class AccountsController(
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var visible = await ReadRecentHistoryVisibleBodyAsync();
|
||||
await SetPlayerSettingAsync(me, RecentHistoryVisibleKey, visible ? "true" : "false");
|
||||
// OPMAPIOEIFG.PNEDPCJCKHI passes this body to BFDGEKKNAPB.PEFKBAEAOPD
|
||||
// (ExpandRecNetResult) — it is read as {Success, Error}. Without a
|
||||
// Success key the toggle parsed as a failure with a null Error, which
|
||||
// throws "Error message cannot be null" instead of saving quietly.
|
||||
// The settings keys stay for any reader of the old shape.
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Error = string.Empty,
|
||||
AccountId = (int)me,
|
||||
IsRecentHistoryVisible = visible,
|
||||
});
|
||||
@@ -1124,4 +995,35 @@ public class AccountsController(
|
||||
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
|
||||
// unknown accounts URLs now 404 so we notice them in logs and wire
|
||||
// them properly rather than silently masking missing endpoints.
|
||||
|
||||
private static RecNetAccount BuildAccount(long id, string username, string? displayName, string? profileImage = null)
|
||||
=> new()
|
||||
{
|
||||
AccountId = (int)id,
|
||||
RawUsername = username,
|
||||
Username = username,
|
||||
DisplayName = displayName ?? username,
|
||||
ProfileImage = profileImage ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
};
|
||||
|
||||
private static RecNetSelfAccount BuildSelfAccount(long id, string username, string? displayName, string? profileImage = null)
|
||||
=> new()
|
||||
{
|
||||
AccountId = (int)id,
|
||||
RawUsername = username,
|
||||
Username = username,
|
||||
DisplayName = displayName ?? username,
|
||||
ProfileImage = profileImage ?? string.Empty,
|
||||
TreatAsJunior = false,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
Email = string.Empty,
|
||||
Phone = string.Empty,
|
||||
Birthday = new DateTime(2000, 1, 1),
|
||||
JuniorState = 0,
|
||||
ParentAccountId = null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ namespace DorkNet.Server.Controllers.Admin;
|
||||
public class AdminController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
LevelService level,
|
||||
CommunityBoardService communityBoard,
|
||||
RoomBlobNormalizerService roomBlobNormalizer,
|
||||
@@ -161,9 +160,7 @@ public class AdminController(
|
||||
await LogAsync("ban_player", "player", id, body.Reason ?? "");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// 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);
|
||||
await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned.");
|
||||
playerPresence.Clear(id);
|
||||
|
||||
return Ok(new { player.Id, player.BannedUntil });
|
||||
@@ -246,7 +243,7 @@ public class AdminController(
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
await notifications.LogoutAsync(id, NotificationService.LogoutReason.ForciblyLoggedOut);
|
||||
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
|
||||
playerPresence.Clear(id);
|
||||
|
||||
return Ok(new
|
||||
@@ -1573,13 +1570,10 @@ public class AdminController(
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <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>
|
||||
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
@@ -1593,7 +1587,7 @@ public class AdminController(
|
||||
.ToListAsync();
|
||||
|
||||
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
|
||||
.BuildRoomServerDetails(room, scenes, roles: roles);
|
||||
.BuildRoomDetails(room, scenes, roles: roles);
|
||||
|
||||
var recipients = onlinePresence.OnlinePlayerIds()
|
||||
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
|
||||
@@ -1745,7 +1739,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, gameSessionId: instanceId);
|
||||
await notifications.KickPlayerAsync(pid, reason);
|
||||
playerPresence.Clear(pid);
|
||||
kicked.Add(pid);
|
||||
}
|
||||
@@ -2261,22 +2255,6 @@ public class AdminController(
|
||||
return Ok(settings);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/settings/circuits-limits</c> — the
|
||||
/// CircuitsV2 creation limits served as the <c>UGC.CircuitsV2.Limit.*</c>
|
||||
/// gameconfig keys, including the per-frame RPC and byte budgets.</summary>
|
||||
[HttpGet("settings/circuits-limits")]
|
||||
public async Task<ActionResult> GetCircuitsV2Limits()
|
||||
=> Ok(await serverSettings.GetCircuitsV2LimitsAsync());
|
||||
|
||||
[HttpPost("settings/circuits-limits")]
|
||||
public async Task<ActionResult> SetCircuitsV2Limits([FromBody] CircuitsV2LimitSettings body)
|
||||
{
|
||||
var settings = await serverSettings.SetCircuitsV2LimitsAsync(body);
|
||||
await LogAsync("gameconfigs_updated", "system", 0, "circuitsv2-limits");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(settings);
|
||||
}
|
||||
|
||||
// ── Signup codes ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
|
||||
@@ -2402,9 +2380,7 @@ public class AdminController(
|
||||
var reason = string.IsNullOrWhiteSpace(body?.Reason)
|
||||
? "Kicked by an admin."
|
||||
: body!.Reason!.Trim();
|
||||
// The room instance lets the client announce the kick to that room.
|
||||
await notifications.KickPlayerAsync(id, reason,
|
||||
gameSessionId: playerPresence.GetRoom(id)?.RoomInstanceId ?? 0);
|
||||
await notifications.KickPlayerAsync(id, reason);
|
||||
playerPresence.Clear(id);
|
||||
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
|
||||
await db.SaveChangesAsync();
|
||||
@@ -3336,11 +3312,10 @@ public class AdminController(
|
||||
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
|
||||
await LogAsync("set_password", "player", id, "");
|
||||
await db.SaveChangesAsync();
|
||||
// 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);
|
||||
// Push a logout so any active sessions on the old password get
|
||||
// dropped — the watch re-authenticates with the new password.
|
||||
await notifications.NotifyAsync(id, PushNotificationId.Logout,
|
||||
new { Reason = "AdminPasswordReset" });
|
||||
return Ok(new { id });
|
||||
}
|
||||
|
||||
@@ -3509,26 +3484,22 @@ public class AdminController(
|
||||
|
||||
playerPresence.SetRoom(id, roomInstance);
|
||||
|
||||
// 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.
|
||||
// Two pushes back-to-back: GameSession update changes the
|
||||
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance
|
||||
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom
|
||||
// refreshes the watch's RoomDetails cache so the room-info
|
||||
// panel doesn't show stale data after the warp.
|
||||
await notifications.NotifyAsync(id,
|
||||
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
|
||||
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,
|
||||
DorkNet.Server.Controllers.API.Rooms.V2.RoomsController.BuildRoomServerDetails(
|
||||
room, forceJoinScenes, roles: forceJoinRoles));
|
||||
new
|
||||
{
|
||||
Room = new { Id = room.Id, Name = room.Name },
|
||||
RoomId = room.Id,
|
||||
SubRoomId = subRoomId,
|
||||
Reason = "AdminForceJoin",
|
||||
});
|
||||
|
||||
await LogAsync("force_join", "player", id,
|
||||
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
|
||||
@@ -3545,46 +3516,20 @@ public class AdminController(
|
||||
|
||||
// ── System ───────────────────────────────────────────────────────────
|
||||
|
||||
public sealed record SetMotdRequest(string? Message, int? StartsInMinutes);
|
||||
public sealed record SetMotdRequest(string Message);
|
||||
|
||||
/// <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>
|
||||
/// <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>
|
||||
[HttpPost("broadcast")]
|
||||
public async Task<ActionResult> Broadcast([FromBody] SetMotdRequest body)
|
||||
{
|
||||
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);
|
||||
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message");
|
||||
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
|
||||
new { Message = body.Message });
|
||||
await LogAsync("broadcast", "system", 0, body.Message);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { recipients = recipients.Count });
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// ── Store catalog management ─────────────────────────────────────────
|
||||
|
||||
@@ -101,9 +101,7 @@ public class AuthController(
|
||||
[FromForm] int platform = 0,
|
||||
[FromForm] string? platformId = null,
|
||||
[FromForm(Name = "platform_id")] string? platform_id_snake = null,
|
||||
[FromForm] long? account_id = null,
|
||||
[FromForm] string? device_code = null,
|
||||
[FromForm] string? login_token = null)
|
||||
[FromForm] long? account_id = null)
|
||||
{
|
||||
// The client mixes camelCase and snake_case parameter naming
|
||||
// depending on grant type — accept both and merge.
|
||||
@@ -141,55 +139,6 @@ public class AuthController(
|
||||
return TokenOk(acc1, refr1);
|
||||
}
|
||||
|
||||
// Device-authorization poll (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451).
|
||||
// The client keeps polling on HTTP 400 with error authorization_pending
|
||||
// or slow_down (LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116); any other
|
||||
// error ends the attempt. This grant used to fall through to the
|
||||
// device-id fallback, which signed the polling device straight in as
|
||||
// whatever account its device id mapped to — no approval involved.
|
||||
if (string.Equals(grant_type, DeviceCodeGrantType, StringComparison.Ordinal))
|
||||
{
|
||||
var (status, approverId) = await DeviceAuthorizations.RedeemAsync(db, device_code, DateTime.UtcNow);
|
||||
switch (status)
|
||||
{
|
||||
case DeviceAuthorizations.RedeemStatus.Pending:
|
||||
return BadRequest(new { error = "authorization_pending" });
|
||||
case DeviceAuthorizations.RedeemStatus.Expired:
|
||||
return BadRequest(new { error = "expired_token" });
|
||||
case DeviceAuthorizations.RedeemStatus.Invalid:
|
||||
return BadRequest(new { error = "invalid_grant" });
|
||||
}
|
||||
var approver = await playerService.GetByIdAsync(approverId);
|
||||
if (approver is null) return BadRequest(new { error = "invalid_grant" });
|
||||
await TryAwardLoginXpAsync(approver.Id);
|
||||
var (accD, refrD) = authService.GenerateTokenPair(approver.Id);
|
||||
return TokenOk(accD, refrD);
|
||||
}
|
||||
|
||||
// One-shot token from account/me/createlogintoken, redeemed with the
|
||||
// account it was minted for (LBNJFPOLCDL.txt:7593-7606: grant_type,
|
||||
// account_id, login_token). Also used to fall through to the device-id
|
||||
// fallback, ignoring both account_id and the token.
|
||||
if (string.Equals(grant_type, "login_token", StringComparison.Ordinal))
|
||||
{
|
||||
if (account_id is not long tokenAccountId || string.IsNullOrWhiteSpace(login_token))
|
||||
return BadRequest(new { error = "invalid_request", error_description = "invalid login_token" });
|
||||
var prefix = login_token.Trim() + "|";
|
||||
var row = await db.PlayerSettings.FirstOrDefaultAsync(s =>
|
||||
s.PlayerId == tokenAccountId
|
||||
&& s.Key == Accounts.AccountsController.RemoteLoginTokenKey
|
||||
&& s.Value.StartsWith(prefix));
|
||||
if (row is null
|
||||
|| !DateTime.TryParse(row.Value[prefix.Length..], null,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind, out var tokenExpiry)
|
||||
|| tokenExpiry < DateTime.UtcNow)
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "invalid login_token" });
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
var (accL, refrL) = authService.GenerateTokenPair(tokenAccountId);
|
||||
return TokenOk(accL, refrL);
|
||||
}
|
||||
|
||||
if (string.Equals(grant_type, "refresh_token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refresh_token))
|
||||
@@ -415,20 +364,26 @@ public class AuthController(
|
||||
[HttpGet("/connect/deviceauthorization")]
|
||||
public async Task<IActionResult> DeviceAuthorization()
|
||||
{
|
||||
var created = await DeviceAuthorizations.CreateAsync(db, DateTime.UtcNow);
|
||||
var deviceCode = Guid.NewGuid().ToString("N");
|
||||
var userCode = deviceCode[..8].ToUpperInvariant();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = RelationshipQueries.SystemAccountId,
|
||||
Key = $"deviceauthorization:{deviceCode}",
|
||||
Value = $"{userCode}|{DateTime.UtcNow.AddMinutes(15):O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
device_code = created.DeviceCode,
|
||||
user_code = created.UserCode,
|
||||
device_code = deviceCode,
|
||||
user_code = userCode,
|
||||
verification_uri = $"https://{domain.Sub("auth")}/device",
|
||||
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={created.UserCode}",
|
||||
expires_in = created.ExpiresInSeconds,
|
||||
verification_uri_complete = $"https://{domain.Sub("auth")}/device?user_code={userCode}",
|
||||
expires_in = 900,
|
||||
interval = 5,
|
||||
});
|
||||
}
|
||||
|
||||
public const string DeviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
private static CachedLogin ToCachedLogin(PlayerEntity p, int platform, string platformId) => new()
|
||||
{
|
||||
Platform = platform,
|
||||
|
||||
@@ -460,20 +460,6 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,13 +91,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
/// 2023 client sends the target club in a form-urlencoded
|
||||
/// <c>clubId</c> field (boxed <c>Nullable<Int64></c> pushed through
|
||||
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> at <c>IKMMOCKDKAF.txt:16923-16927</c>)
|
||||
/// — the issuing method is <c>LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>)</c>.
|
||||
/// Despite the untyped promise the body IS parsed: both branches hand
|
||||
/// the response to <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (:16858, :16943), the
|
||||
/// <c>{success,error}</c> RecNetResult reader, so an empty 200 surfaced as
|
||||
/// a failure (<see cref="RecNetResults"/>). Verb ordinal 3 (PUT) is the
|
||||
/// HasValue branch of the same method at :16911; the null branch is the
|
||||
/// DELETE below (:16838).</summary>
|
||||
/// and parses NO response DTO — the issuing method is
|
||||
/// <c>LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>)</c>, the status-only
|
||||
/// promise type, so an empty 200 is the contract. Verb ordinal 3 (PUT)
|
||||
/// is the HasValue branch of the same method at :16911; the null branch
|
||||
/// is the DELETE below (:16838).</summary>
|
||||
[HttpPut("/club/home/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> HomeMeSet()
|
||||
@@ -118,11 +116,11 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
clubId = fromQuery;
|
||||
if (clubId <= 0) return BadRequest(new { error = "missing_club_id" });
|
||||
if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound();
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>/club/home/me</c> — "Remove Home Clubhouse Room".
|
||||
/// No request body; RecNetResult reply (<c>IKMMOCKDKAF.txt:16830-16858</c>: route
|
||||
/// No body, no response DTO (<c>IKMMOCKDKAF.txt:16830-16841</c>: route
|
||||
/// literal, host 13, verb ordinal 4, error string "Failed to Remove Home
|
||||
/// Clubhouse Room"). Idempotent so a double-tap is harmless.</summary>
|
||||
[HttpDelete("/club/home/me")]
|
||||
@@ -130,7 +128,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
public async Task<IActionResult> HomeMeClear()
|
||||
{
|
||||
await clubs.ClearHomeClubAsync(Me);
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("/club/mine/member")]
|
||||
@@ -349,7 +347,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
{
|
||||
var pid = Me;
|
||||
await clubs.MarkAnnouncementReadAsync(pid, announcementId);
|
||||
return RecNetResults.Ok();
|
||||
return Ok(new { Read = true });
|
||||
}
|
||||
|
||||
// ── Single-club reads ─────────────────────────────────────────────
|
||||
@@ -371,11 +369,8 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
/// the club NAME used only for the client-side confirmation copy and is
|
||||
/// never transmitted; the request is a bare DELETE with no body
|
||||
/// (<c>IKMMOCKDKAF.txt:17107-17120</c>, verb ordinal 4, host 13, error
|
||||
/// string "Failed to Delete Club"). The promise is built by
|
||||
/// <c>BFDGEKKNAPB.PEFKBAEAOPD</c> (ExpandRecNetResult, :17137), so the
|
||||
/// body is a <c>{success,error}</c> RecNetResult — an empty 200 made the
|
||||
/// client report "Failed to Delete Club" after the club was gone. See
|
||||
/// <see cref="RecNetResults"/>.
|
||||
/// string "Failed to Delete Club"). <c>LDGADANDBIO</c> is the status-only
|
||||
/// promise so an empty 200 is the whole contract.
|
||||
///
|
||||
/// Owner-only, and a soft delete — see
|
||||
/// <see cref="ClubService.DisbandAsync"/>.</summary>
|
||||
@@ -387,7 +382,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
{
|
||||
var disbanded = await clubs.DisbandAsync(clubId, Me);
|
||||
if (disbanded is null) return NotFound();
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return Forbid(); }
|
||||
}
|
||||
@@ -1149,15 +1144,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
/// is reachable — see <see cref="ToWireMemberRequest"/>.</summary>
|
||||
private static int RequestStatusFor(ClubMembershipEntity row) => 1;
|
||||
|
||||
// ── Member mutations ─────────────────────────────────────────────
|
||||
//
|
||||
// Every one of these goes through the client's shared club-write helper
|
||||
// IKMMOCKDKAF.JNFEEDLCGHH ("Failed to " + action), which hands the
|
||||
// response to BFDGEKKNAPB.PEFKBAEAOPD (IKMMOCKDKAF.txt:26696) — the
|
||||
// RecNetResult reader. They are not status-only: an empty 200 parses to
|
||||
// null and the client shows "Failed to request to join club" (etc.) after
|
||||
// the server has applied the change. Success bodies are
|
||||
// RecNetResults.Ok().
|
||||
// ── Member mutations (void / IPromise) ───────────────────────────
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/requesttojoin</c> — caller
|
||||
/// asks to join. For Open clubs we promote straight to Member; for
|
||||
@@ -1182,7 +1169,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
};
|
||||
if (perms == -1) return Forbid();
|
||||
await clubs.UpsertMembershipAsync(clubId, Me, perms);
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/directJoin</c> — same as
|
||||
@@ -1196,7 +1183,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var club = await clubs.GetByIdAsync(clubId);
|
||||
if (club is null) return NotFound();
|
||||
await clubs.UpsertMembershipAsync(clubId, Me, 0);
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/leave</c> — caller leaves
|
||||
@@ -1211,7 +1198,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (club.CreatorPlayerId == Me)
|
||||
return BadRequest(new { error = "owner_must_transfer_first" });
|
||||
await clubs.RemoveMembershipAsync(clubId, Me);
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/invite</c> — caller invites
|
||||
@@ -1233,7 +1220,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, 128); // Pending
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/invitemembers</c> — bulk
|
||||
@@ -1254,7 +1241,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
|
||||
foreach (var pid in ids)
|
||||
await clubs.UpsertMembershipAsync(clubId, pid, 128);
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/acceptinvite</c> — caller
|
||||
@@ -1267,7 +1254,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (row is null) return NotFound();
|
||||
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_invite" });
|
||||
await clubs.UpsertMembershipAsync(clubId, Me, 0); // Member
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/declineinvite</c> — caller
|
||||
@@ -1277,7 +1264,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
public async Task<IActionResult> MemberDeclineInvite(long clubId)
|
||||
{
|
||||
await clubs.RemoveMembershipAsync(clubId, Me);
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
public sealed class MemberTargetRequest
|
||||
@@ -1301,7 +1288,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (row is null) return NotFound();
|
||||
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_request" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, 0);
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
public sealed class BulkTargetRequest
|
||||
@@ -1322,7 +1309,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (row is null || (row.Permissions & 128) == 0) continue;
|
||||
await clubs.UpsertMembershipAsync(clubId, pid, 0);
|
||||
}
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/denyrequest")]
|
||||
@@ -1333,7 +1320,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||
await clubs.RemoveMembershipAsync(clubId, target);
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/denyrequests")]
|
||||
@@ -1344,7 +1331,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
|
||||
foreach (var pid in ids)
|
||||
await clubs.RemoveMembershipAsync(clubId, pid);
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/remove")]
|
||||
@@ -1359,7 +1346,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (target == club.CreatorPlayerId)
|
||||
return BadRequest(new { error = "cannot_remove_owner" });
|
||||
await clubs.RemoveMembershipAsync(clubId, target);
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/ban</c> — kick + prevent
|
||||
@@ -1377,7 +1364,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (target == club.CreatorPlayerId)
|
||||
return BadRequest(new { error = "cannot_ban_owner" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, 256); // Ban marker
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("/club/{clubId:long}/members/unban")]
|
||||
@@ -1388,7 +1375,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var target = req.PlayerId ?? req.AccountId ?? 0;
|
||||
if (target == 0) return BadRequest(new { error = "missing_player_id" });
|
||||
await clubs.RemoveMembershipAsync(clubId, target);
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/members/changetype</c> — promote /
|
||||
@@ -1409,7 +1396,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
if (target == club.CreatorPlayerId)
|
||||
return BadRequest(new { error = "cannot_change_owner_role" });
|
||||
await clubs.UpsertMembershipAsync(clubId, target, ClubService.PermsFromMembershipType(newType));
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// ── members/bulk + clubreporting ─────────────────────────────────
|
||||
@@ -1526,7 +1513,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
public async Task<IActionResult> AnnouncementDelete(long clubId, long announcementId)
|
||||
{
|
||||
if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid();
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>/announcements/club/{clubId}/{announcementId}</c> — EDIT
|
||||
@@ -1542,7 +1529,7 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
var (title, body, imageName) = await ReadAnnouncementFieldsAsync();
|
||||
if (!await clubs.UpdateAnnouncementAsync(clubId, announcementId, Me, title, body, imageName))
|
||||
return Forbid();
|
||||
return RecNetResults.Ok();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/announcements/club/{clubId}</c> — CREATE an
|
||||
|
||||
@@ -39,10 +39,8 @@ public class GoToController(
|
||||
RoomService rooms,
|
||||
DorkNet.Server.Data.DorkNetDbContext db,
|
||||
NotificationService notifications,
|
||||
SystemNotificationService systemNotifications,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
ServerSettingsService serverSettings,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
ILogger<GoToController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("/goto/room/{roomName}")]
|
||||
@@ -878,15 +876,11 @@ 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);
|
||||
// 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);
|
||||
// Notify the OWNER that someone tried to follow them in.
|
||||
// Helps surface the rejection so the owner knows there's
|
||||
// an interested party — they can choose to send an invite.
|
||||
await notifications.PrivateInstanceJoinRejected(
|
||||
ownerPlayerId: playerId, attemptedByPlayerId: callerId ?? 0);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 26, // RoomInstanceIsPrivate
|
||||
@@ -1300,14 +1294,15 @@ public class GoToController(
|
||||
|
||||
private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room)
|
||||
{
|
||||
// Honors the global-friends toggle (every other player is a "friend"
|
||||
// when it is on). Goes through the shared fan-out so the frame
|
||||
// carries the player's real status visibility and the same
|
||||
// online/room state every other presence transition reports.
|
||||
// Honors the global-friends toggle: with it on, every other player is
|
||||
// a "friend" and gets the presence ping (so the whole small server
|
||||
// sees each other's room moves).
|
||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, playerId);
|
||||
logger.LogInformation(
|
||||
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName}",
|
||||
playerId, room.RoomId, room.Name);
|
||||
await presenceFanout.PublishCurrentAsync(playerId);
|
||||
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)",
|
||||
playerId, room.RoomId, room.Name, friendIds.Count);
|
||||
if (friendIds.Count == 0) return;
|
||||
await notifications.PlayerEnteredRoom(friendIds, playerId, room);
|
||||
}
|
||||
|
||||
private async Task<MatchmakingResponseDto> BuildResponseAsync(string roomName)
|
||||
@@ -1673,35 +1668,3 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ public class MatchController(
|
||||
PlayerPresenceService presence,
|
||||
PrivateInstanceService privateInstances,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
RoomService rooms,
|
||||
DorkNetDbContext db,
|
||||
ILogger<MatchController> logger) : ControllerBase
|
||||
@@ -68,12 +67,6 @@ public class MatchController(
|
||||
completed.Value.TargetRoom.Name,
|
||||
completed.Value.TargetRoom.RoomInstanceId,
|
||||
completed.Value.DeferPresenceCommit);
|
||||
// A deferred join (matchmake/dorm, private-instance hop)
|
||||
// commits HERE, not at matchmake time, so this is the only
|
||||
// point friends can learn about the move. Immediate commits
|
||||
// were already announced by /goto.
|
||||
if (completed.Value.DeferPresenceCommit)
|
||||
await presenceFanout.PublishCurrentAsync(pid);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -95,48 +88,36 @@ public class MatchController(
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>matchmake/none</c> — "leave room". The 2023 client's
|
||||
/// only caller is TryGoToOfflineRoom
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:12677-13279): it has already
|
||||
/// switched its own cached presence to the local offline instance
|
||||
/// (RoomInstanceId -1) before posting this, and it discards the reply
|
||||
/// (<c><TryGoToOfflineRoom>b__119_0</c> returns a constant).
|
||||
///
|
||||
/// So the player is now online in no room, and that is what presence must
|
||||
/// say. This used to re-commit the previous room (or the dorm) instead:
|
||||
/// the next heartbeat then answered with a room while the client held the
|
||||
/// offline instance, the client logged "presence heartbeat response
|
||||
/// indicates local presence is out-of-sync", cancelled its offline load
|
||||
/// ("OnPlayerPresenceUpdate is cancelling room load for -1") and chased the
|
||||
/// server's room — and friends kept seeing the room the player had left.
|
||||
///
|
||||
/// A join still pending is abandoned too: the client only goes offline
|
||||
/// after giving up on it (fallback chain after a Photon timeout), and
|
||||
/// echoing its target from the heartbeat would pull the player back.</summary>
|
||||
[HttpPost("/matchmake/none")]
|
||||
public async Task<IActionResult> MatchmakeNone()
|
||||
{
|
||||
var pid = this.CurrentPlayerId() ?? 0;
|
||||
if (pid == 0) return Unauthorized();
|
||||
|
||||
var previous = presence.GetRoom(pid);
|
||||
var pending = joinTimeouts.GetPending(pid);
|
||||
joinTimeouts.Cancel(pid, "matchmake-none");
|
||||
presence.SetNoRoom(pid);
|
||||
if (pending is { DeferPresenceCommit: true })
|
||||
{
|
||||
presence.MarkActive(pid);
|
||||
logger.LogInformation(
|
||||
"[matchmake-none] player={Player} has deferred pending room={Room} instance={Instance}; not committing presence",
|
||||
pid,
|
||||
pending.Value.TargetRoom.RoomId,
|
||||
pending.Value.TargetRoom.RoomInstanceId);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 0,
|
||||
RoomInstance = pending.Value.TargetRoom,
|
||||
});
|
||||
}
|
||||
|
||||
var current = presence.GetRoom(pid) ?? await BuildDormRoomInstanceAsync(pid);
|
||||
presence.SetRoom(pid, current);
|
||||
presence.MarkActive(pid);
|
||||
presence.EndBootDormGrace(pid);
|
||||
logger.LogInformation(
|
||||
"[matchmake-none] player={Player} left room={Room} instance={Instance} abandonedPending={Pending}; online with no room",
|
||||
pid, previous?.RoomId, previous?.RoomInstanceId, pending?.TargetRoom.RoomInstanceId);
|
||||
|
||||
await presenceFanout.PublishCurrentAsync(pid);
|
||||
|
||||
// The client ignores this body; keep a well-formed instance in it so
|
||||
// the shared MatchmakingResponse reader never sees a null object.
|
||||
logger.LogInformation("[matchmake-none] player={Player}", pid);
|
||||
return Ok(new MatchmakingResponseDto
|
||||
{
|
||||
ErrorCode = 0,
|
||||
RoomInstance = previous ?? pending?.TargetRoom ?? await BuildDormRoomInstanceAsync(pid),
|
||||
RoomInstance = current,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ public class MatchPlayerController(
|
||||
RoomService rooms,
|
||||
PrivateInstanceService privateInstances,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
IConfiguration config,
|
||||
DorkNetDbContext db,
|
||||
ILogger<MatchPlayerController> log) : ControllerBase
|
||||
@@ -69,15 +68,17 @@ public class MatchPlayerController(
|
||||
if (replaced)
|
||||
{
|
||||
log.LogWarning(
|
||||
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; logging old session(s) out",
|
||||
"[{Scope}] concurrent session for player={PlayerId}; takeOver={TakeOver}; kicking old session(s) via ModerationKick push",
|
||||
logScope, playerId, takeOverExclusiveSession);
|
||||
// The new session hasn't opened its SignalR socket yet
|
||||
// (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);
|
||||
// (negotiate fires AFTER /player/login on the watch's
|
||||
// boot sequence — verified in the live trace), so the
|
||||
// ModerationKick push hits only the OLDER session's
|
||||
// existing connections. They'll show the "you've been
|
||||
// kicked" dialog and gracefully disconnect via the
|
||||
// watch's LocalModerationKickSelf path.
|
||||
await notifications.KickStaleSession(playerId,
|
||||
"Your account was signed in on another device.");
|
||||
}
|
||||
}
|
||||
return Ok(Array.Empty<object>());
|
||||
@@ -86,20 +87,13 @@ public class MatchPlayerController(
|
||||
/// <summary>
|
||||
/// POST /player/logout — fires when the client gives up on a session
|
||||
/// (e.g. Photon connection couldn't be established, client aborts).
|
||||
/// Clear the cached presence so the next /goto starts fresh, and tell
|
||||
/// friends right away — otherwise they keep seeing the player in their
|
||||
/// last room until a bulk poll happens to notice.
|
||||
/// Clear the cached presence so the next /goto starts fresh.
|
||||
/// </summary>
|
||||
[HttpPost("/player/logout")]
|
||||
public async Task<IActionResult> PlayerLogout()
|
||||
public IActionResult PlayerLogout()
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (playerId != 0)
|
||||
{
|
||||
joinTimeouts.Cancel(playerId, "logout");
|
||||
presence.Clear(playerId);
|
||||
await presenceFanout.PublishOfflineAsync(playerId);
|
||||
}
|
||||
if (playerId != 0) presence.Clear(playerId);
|
||||
return Ok(new { success = true, error = "" });
|
||||
}
|
||||
|
||||
@@ -184,15 +178,7 @@ public class MatchPlayerController(
|
||||
public async Task<ActionResult<PresenceDto>> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value)
|
||||
{
|
||||
var playerId = TryGetCurrentPlayerId();
|
||||
if (value is int v)
|
||||
{
|
||||
await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
|
||||
// Friends only. The client does not apply this response to its own
|
||||
// cached presence, and a PresenceUpdate about the local player runs
|
||||
// the same apply-and-rejoin path as an out-of-sync heartbeat
|
||||
// (Matchmaking.EGPABEDPEJO -> FLAOOBNJAAD, Matchmaking.txt:6917), so no self push.
|
||||
await presenceFanout.PublishCurrentAsync(playerId);
|
||||
}
|
||||
if (value is int v) await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
|
||||
var room = await GetPresenceRoomForResponseAsync(playerId);
|
||||
return Ok(new PresenceDto
|
||||
{
|
||||
@@ -226,7 +212,7 @@ public class MatchPlayerController(
|
||||
// back in the response and was then dropped, so the setting reverted on the
|
||||
// next heartbeat and never survived a restart. They live in PlayerSettings
|
||||
// (no schema change needed) and are read back wherever presence is built.
|
||||
private const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
|
||||
private const string PrefStatusVisibility = "pref:statusVisibility";
|
||||
private const string PrefVrMovementMode = "pref:vrMovementMode";
|
||||
private const string PrefAvoidJuniors = "pref:avoidJuniors";
|
||||
|
||||
@@ -409,21 +395,6 @@ public class MatchPlayerController(
|
||||
|
||||
if (room is null)
|
||||
{
|
||||
// The player went to the offline room (matchmake/none). Their
|
||||
// client now holds RoomInstanceId -1, which the heartbeat compare
|
||||
// treats as "no room" (HCHDEHIGEBE.GIICPDHJOOH, sign bit of the id;
|
||||
// RecNet.Runtime/RecNet/Matchmaking.txt:18415-18437) and is only in
|
||||
// sync with a null roomInstance. The dorm fallback below would
|
||||
// read as out-of-sync and drag the player out of the offline room.
|
||||
if (presence.IsInNoRoom(playerId))
|
||||
{
|
||||
presence.TouchNoRoom(playerId);
|
||||
log.LogInformation(
|
||||
"[player-heartbeat] player={PlayerId} online in no room (left via matchmake/none); returning null roomInstance",
|
||||
playerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (presence.IsBootDormGraceActive(playerId))
|
||||
{
|
||||
log.LogInformation(
|
||||
@@ -455,7 +426,6 @@ public class MatchPlayerController(
|
||||
playerId, fallback.RoomId, fallback.DataBlob);
|
||||
presence.SetRoom(playerId, fallback);
|
||||
presence.MarkActive(playerId);
|
||||
await presenceFanout.PublishCurrentAsync(playerId);
|
||||
return fallback;
|
||||
}
|
||||
log.LogInformation("[player-heartbeat] player={PlayerId} room=null", playerId);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.Match;
|
||||
@@ -38,19 +36,10 @@ namespace DorkNet.Server.Controllers.Match;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PlayerPresenceController(
|
||||
PlayerPresenceService presence,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
JoinTimeoutService joinTimeouts,
|
||||
DorkNetDbContext db,
|
||||
ServerSettingsService settings) : ControllerBase
|
||||
PlayerPresenceService presence) : ControllerBase
|
||||
{
|
||||
/// <summary>PlayerSettings key written by <c>PUT /player/statusvisibility</c>
|
||||
/// (MatchPlayerController.PrefStatusVisibility — same literal; the
|
||||
/// round-trip is pinned by a regression test).</summary>
|
||||
public const string PrefStatusVisibility = PresenceBroadcastService.StatusVisibilityPrefKey;
|
||||
|
||||
[HttpGet("/player")]
|
||||
public async Task<ActionResult<List<PlayerPresenceDto>>> GetPresences()
|
||||
public ActionResult<List<PlayerPresenceDto>> GetPresences()
|
||||
{
|
||||
// The watch sends two URL flavours for this endpoint:
|
||||
// 1. Bulk: <c>/player?{id1}&{id2}&{id3}</c> — bare keys,
|
||||
@@ -92,39 +81,31 @@ public class PlayerPresenceController(
|
||||
// mid-coroutine. Only redact for OTHER players where the
|
||||
// privacy concern actually applies.
|
||||
var callerId = this.CurrentPlayerId();
|
||||
var callerRoom = callerId is long cid ? SelfRoom(cid) : null;
|
||||
var visibility = await LoadVisibilityAsync(ids);
|
||||
var hiddenFromCaller = await ResolveHiddenAsync(callerId, ids, visibility);
|
||||
var callerRoom = callerId is long cid ? presence.GetRoom(cid) : null;
|
||||
var result = ids.Select(id =>
|
||||
{
|
||||
var room = presence.GetRoom(id);
|
||||
// Other players need both a room presence and an active
|
||||
// live signal. Use the recent-heartbeat marker as the
|
||||
// truth source: SignalR can drop while the player is still
|
||||
// heartbeating, or linger after the game is gone. Presence
|
||||
// keys intentionally live for minutes to survive long room
|
||||
// loads, so using them alone leaves friends stuck online
|
||||
// after a graceful disconnect.
|
||||
var isSelf = callerId == id;
|
||||
// The caller's own row must say exactly what their heartbeat
|
||||
// says (pending-join target, or null in the offline room): the
|
||||
// client compares its cached self presence field by field
|
||||
// (HCHDEHIGEBE.DCIJHIPJHOB) and treats any difference as
|
||||
// out-of-sync.
|
||||
var room = isSelf ? callerRoom : presence.GetRoom(id);
|
||||
// Online = a live signal (recent heartbeat, or a SignalR
|
||||
// connection), NOT "has a room". Presence keys live for minutes
|
||||
// to survive long room loads, so a room alone would leave friends
|
||||
// stuck online after the game is gone; and a player in the offline
|
||||
// room after matchmake/none has no room but is very much online
|
||||
// (the 2023 DTO carries isOnline on its own; see
|
||||
// NotificationService.BuildPresenceUpdatePayload).
|
||||
// A player who chose "appear offline" (or friends/favorites
|
||||
// only, when the caller doesn't qualify) is reported exactly
|
||||
// like a genuinely offline player: no room, isOnline=false.
|
||||
var isOnline = (isSelf || presenceFanout.IsOnline(id)) && !hiddenFromCaller.Contains(id);
|
||||
if (!isOnline) room = null;
|
||||
var active = isSelf || presence.IsRecentlyActive(id);
|
||||
var isOnline = room is not null && active;
|
||||
return new PlayerPresenceDto
|
||||
{
|
||||
PlayerId = (int)id,
|
||||
// The player's stored online-status visibility. Self MUST
|
||||
// see its own stored value: the profile dialog's current
|
||||
// selection is read from the cached self presence, and a
|
||||
// hard-coded 0 here snapped it back to Public after every
|
||||
// presence refresh.
|
||||
StatusVisibility = visibility.GetValueOrDefault(id, PresenceVisibility.Public),
|
||||
// StatusVisibility is the player's *preference* for who may
|
||||
// see them online. The enum is Everyone=0 / FriendsOnly=1 /
|
||||
// Nobody=2 — so the previous value 1 (mislabeled "Visible")
|
||||
// actually meant FriendsOnly, and anyone who wasn't your
|
||||
// friend saw you as OFFLINE. On a private server everyone
|
||||
// should see everyone, so report Everyone (0). IsOnline=false
|
||||
// still gates the "Online" badge for offline players.
|
||||
StatusVisibility = 0, // Everyone
|
||||
DeviceClass = 0, // Desktop / unknown — not VR
|
||||
VrMovementMode = 0,
|
||||
// Full instance details are safe, and necessary, when the
|
||||
@@ -143,58 +124,6 @@ public class PlayerPresenceController(
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>The caller's own room as their heartbeat reports it.</summary>
|
||||
private RoomInstanceDto? SelfRoom(long playerId)
|
||||
{
|
||||
if (joinTimeouts.GetPending(playerId) is { DeferPresenceCommit: true } pending)
|
||||
return pending.TargetRoom;
|
||||
if (presence.IsInNoRoom(playerId)) return null;
|
||||
return presence.GetRoom(playerId);
|
||||
}
|
||||
|
||||
private Task<Dictionary<long, int>> LoadVisibilityAsync(HashSet<long> ids) =>
|
||||
PresenceBroadcastService.GetStatusVisibilitiesAsync(db, ids);
|
||||
|
||||
/// <summary>Ids whose online status the caller may not see. The caller
|
||||
/// always sees itself; anonymous callers only see Public players.</summary>
|
||||
private async Task<HashSet<long>> ResolveHiddenAsync(
|
||||
long? callerId, HashSet<long> ids, Dictionary<long, int> visibility)
|
||||
{
|
||||
var hidden = new HashSet<long>();
|
||||
var restricted = visibility
|
||||
.Where(kv => kv.Value != PresenceVisibility.Public && kv.Key != callerId)
|
||||
.ToList();
|
||||
if (restricted.Count == 0) return hidden;
|
||||
|
||||
if (callerId is not long me)
|
||||
{
|
||||
foreach (var kv in restricted) hidden.Add(kv.Key);
|
||||
return hidden;
|
||||
}
|
||||
|
||||
HashSet<long>? friends = null;
|
||||
HashSet<long>? favoritedMe = null;
|
||||
foreach (var (target, vis) in restricted)
|
||||
{
|
||||
if (vis == PresenceVisibility.Offline) { hidden.Add(target); continue; }
|
||||
|
||||
friends ??= (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, me)).ToHashSet();
|
||||
if (!friends.Contains(target)) { hidden.Add(target); continue; }
|
||||
|
||||
if (vis == PresenceVisibility.FavoriteFriendsOnly)
|
||||
{
|
||||
// Favorited lives on the favoriter's own row
|
||||
// (RequesterId = favoriter), as RelationshipsController.BuildMerged reads it.
|
||||
favoritedMe ??= (await db.Relationships
|
||||
.Where(r => r.TargetId == me && r.Favorited && ids.Contains(r.RequesterId))
|
||||
.Select(r => r.RequesterId)
|
||||
.ToListAsync()).ToHashSet();
|
||||
if (!favoritedMe.Contains(target)) hidden.Add(target);
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
private static bool ShouldExposeFullRoom(
|
||||
long? callerId,
|
||||
RoomInstanceDto? callerRoom,
|
||||
@@ -222,8 +151,8 @@ public class PlayerPresenceDto
|
||||
[JsonPropertyName("playerId")]
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
/// <summary>Online-status visibility enum (int, 2023 client):
|
||||
/// 0 = Public, 1 = FriendsOnly, 2 = FavoriteFriendsOnly, 3 = Offline.</summary>
|
||||
/// <summary>PlayerStatusVisibility enum (int):
|
||||
/// 0 = Hidden / Offline-appearing, 1 = Visible, 2 = FriendsOnly.</summary>
|
||||
[JsonPropertyName("statusVisibility")]
|
||||
public int StatusVisibility { get; set; }
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
using DorkNet.Models.Auth;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Bodies for the 2023 client's <c>RecNetResult</c> writes.
|
||||
///
|
||||
/// A lot of the client's fire-and-forget writes look status-only from their
|
||||
/// signature (they return <c>LDGADANDBIO</c>, the untyped promise), but they
|
||||
/// are not: they go through <c>BFDGEKKNAPB.PEFKBAEAOPD</c>
|
||||
/// (<c>RecNet.Runtime/BFDGEKKNAPB.txt:353</c>, "ExpandRecNetResult"), which
|
||||
/// deserialises the body as <c>DJMHAFPGLLN</c> = <c>{Success, Error}</c>
|
||||
/// (formatter <c>RecNet.Runtime/LMCCNLLHLCJ.txt:191-218</c> registers
|
||||
/// <c>Success</c>/<c>success</c> and <c>Error</c>/<c>error</c>) and then runs
|
||||
/// the <c><ExpandRecNetResult>b__2_0</c> lambda
|
||||
/// (<c>RecNet.Runtime/BFDGEKKNAPB_NestedType___c.txt:61</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item>body null (an empty 200 or <c>null</c> — <c>COLNKALCBOK.txt</c>
|
||||
/// JIKCMNMKNPH returns default for both) → NullReferenceException, the write
|
||||
/// shows "Failed to …" although the server applied it;</item>
|
||||
/// <item><c>Success</c> missing/false → <c>Promise.Rejected(Error)</c>; with no
|
||||
/// <c>Error</c> key that throws <c>ArgumentException: Error message cannot be
|
||||
/// null</c> (seen in Player.log as an unobserved task exception).</item>
|
||||
/// </list>
|
||||
/// So every such endpoint must answer <c>{"success":true,"error":""}</c> on
|
||||
/// success, and carry a non-null <c>error</c> on a 200 failure.
|
||||
/// </summary>
|
||||
public static class RecNetResults
|
||||
{
|
||||
public static IActionResult Ok() =>
|
||||
new OkObjectResult(new RecNetResult { Success = true, Error = string.Empty });
|
||||
|
||||
public static IActionResult Fail(string error) =>
|
||||
new OkObjectResult(new RecNetResult { Success = false, Error = error });
|
||||
}
|
||||
@@ -65,12 +65,6 @@ public class StorageController(
|
||||
|
||||
[HttpPost("/upload", Order = -2000)]
|
||||
[Consumes("multipart/form-data")]
|
||||
// Kestrel's default 30,000,000-byte body cap would 413 the room save of
|
||||
// a big circuits room now that the served CircuitsV2 chip limit is 30000
|
||||
// (docs/recroom-2023-circuitsv2-limits.md). 100 MB matches the admin
|
||||
// import endpoints and Cloudflare's own per-request ceiling.
|
||||
[RequestSizeLimit(100_000_000)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 100_000_000)]
|
||||
public async Task<IActionResult> Upload()
|
||||
{
|
||||
// Form parsing — ASP.NET Core deserialises the multipart body
|
||||
|
||||
@@ -15,21 +15,4 @@ public class StringsController : ControllerBase
|
||||
[HttpGet("/strings/v1/{locale}")]
|
||||
public ActionResult<Dictionary<string, string>> GetLocale(string locale)
|
||||
=> Ok(new Dictionary<string, string>());
|
||||
|
||||
/// <summary>The 2023 client's server-string download (RecNet.Runtime/NHILNDLLLHH.txt).
|
||||
/// Two GETs share the path <c>/strings</c> on the strings host:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>?locale=&scopePrefix=client.&eTag=</c> (FMLBOAOBKPM, :531) →
|
||||
/// <c>Dictionary<string, Dictionary<long,string>></c>, scope → id → text;</item>
|
||||
/// <item><c>?locale=&scope=client.{name}&eTag=</c> (BNEELKAKCEN, :3) →
|
||||
/// <c>Dictionary<long,string></c>.</item>
|
||||
/// </list>
|
||||
/// The localisation service stores the first answer
|
||||
/// (RecRoom.Localization.Runtime/IBAJDNIMADI.txt BICKHBAJPIG) and its scope lookup
|
||||
/// (NHLKEJLGGIE) null-derefs when the download never succeeded but returns an empty
|
||||
/// table for a scope the server did not send. DorkNet keeps no server-side string
|
||||
/// overrides, so the truthful answer is an empty JSON object for either query —
|
||||
/// the lookups then fall back to the bundled text. The path used to 404.</summary>
|
||||
[HttpGet("/strings")]
|
||||
public IActionResult GetServerStrings() => Content("{}", "application/json");
|
||||
}
|
||||
|
||||
@@ -116,29 +116,6 @@ public class PlayerEntity
|
||||
[MaxLength(32)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
// ── 2023 profile fields ──────────────────────────────────────────────
|
||||
// Written by PUT account/me/{emoji,bannerimage,personalpronouns,
|
||||
// identityflags} and read back on every account DTO (account/me,
|
||||
// account/{id}, account/bulk, search, AccountUpdate push) under the
|
||||
// keys displayEmoji / bannerImage / personalPronouns / identityFlags.
|
||||
|
||||
/// <summary>Emoji shown beside the display name ("" = none).</summary>
|
||||
[MaxLength(32)]
|
||||
public string DisplayEmoji { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Profile banner image name (a storage image the player
|
||||
/// already uploaded, e.g. one of their photos). Null = no banner.</summary>
|
||||
[MaxLength(256)]
|
||||
public string? BannerImageName { get; set; }
|
||||
|
||||
/// <summary>Pronoun bitmask (client flags enum: SheHer=1, HeHim=2,
|
||||
/// TheyThem=4, ZeHir=8, ZeZir=16, XeXem=32).</summary>
|
||||
public int PersonalPronouns { get; set; } = 0;
|
||||
|
||||
/// <summary>Identity-flag bitmask (client flags enum: LGBTQIA=1,
|
||||
/// Transgender=2, Bisexual=4, …).</summary>
|
||||
public int IdentityFlags { get; set; } = 0;
|
||||
|
||||
// Navigation
|
||||
public AvatarEntity? Avatar { get; set; }
|
||||
public List<PlayerSettingEntity> Settings { get; set; } = [];
|
||||
|
||||
@@ -108,14 +108,5 @@ public class ServerSettingsEntity
|
||||
/// admins curate the base-room list from the SPA without a redeploy.</summary>
|
||||
public string BaseRoomNamesJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Admin-managed CircuitsV2 creation limits, served to the
|
||||
/// client as the <c>UGC.CircuitsV2.Limit.*</c> gameconfig keys. The
|
||||
/// network entry carries the per-frame RPC and byte budgets a circuit
|
||||
/// may spend; DorkNet ships them effectively unlimited, where retail
|
||||
/// shipped 5 RPCs/frame and 1024 bytes/60 frames. Stored as JSON so
|
||||
/// new limit keys don't need a table migration.
|
||||
/// See <see cref="Services.CircuitsV2LimitSettings"/>.</summary>
|
||||
public string CircuitsV2LimitsJson { get; set; } = string.Empty;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`,
|
||||
|
||||
| Verb | URL | Status | Handler |
|
||||
|---|---|---|---|
|
||||
| GET | `api/gameconfigs/v1/all` | REAL | `GameConfigurationsController.GetAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Serves the admin-managed key/value set from `ServerSettingsService.GetGameConfigurationsAsync`: Rec Center doors, the typed keys confirmed in the 2020.12 decomp, the `UGC.CircuitsV2.Limit.*` family (see `docs/recroom-2023-circuitsv2-limits.md`), and `PlayMenuCarouselOrdering_1` (the 2023 play-menu carousel priority map; without it the client logs "Could not find GameConfig for PlayMenuCarouselOrdering_1" and keeps prefab order). |
|
||||
| GET | `api/gameconfigs/v1/all` | STUB | `GameConfigurationsController.GameConfigsAll` (also shadowed by `AllEndpointsController.GameConfigs` — the explicit `gameconfigs/v1/all` route wins). Returns `[]`. |
|
||||
|
||||
### `api/groups/`
|
||||
|
||||
@@ -391,18 +391,6 @@ These all live on `match.rec.net` (locally: `match.localhost`).
|
||||
| POST | `matchmake/none` | REAL | `Match.MatchController.MatchmakeNone` — returns the caller's current room (or dorm) without changing it |
|
||||
| POST | `player/heartbeat` | REAL | `Match.MatchPlayerController.Heartbeat` — refreshes the caller's `PlayerPresenceService` entry; also has a duplicate in `ApiNamespaceStubsController.Heartbeat` for the api-host case |
|
||||
|
||||
2023 additions on the auth / accounts / strings hosts:
|
||||
|
||||
| Verb | URL | Status | Handler |
|
||||
|---|---|---|---|
|
||||
| POST | `connect/token` `grant_type=urn:ietf:params:oauth:grant-type:device_code` | REAL | `Auth.AuthController.Token` via `DeviceAuthorizations.RedeemAsync` — 400 `authorization_pending` until approved, then the approver's token pair; single use |
|
||||
| POST | `connect/token` `grant_type=login_token` | REAL | `Auth.AuthController.Token` — redeems an `account/me/createlogintoken` token for its own `account_id`; single use |
|
||||
| POST | `account/me/createlogintoken` | REAL | `Accounts.AccountsController.CreateLoginToken` — bare JSON string (the client reads `FGLDKEJLAKB<String>`) |
|
||||
| POST | `account/me/remoteauth` | REAL | `Accounts.AccountsController.RemoteAuth` — approves a device `user_code` for the caller; `{success,error}` RecNetResult |
|
||||
| POST | `account/bulk/email`, `account/bulk/phone` | REAL | `Accounts.AccountsController.Bulk` — contact lookup, repeated `email` / `phone` form fields, account list |
|
||||
| GET | `strings?locale&scopePrefix|scope&eTag` | REAL | `Strings.StringsController.GetServerStrings` — empty table (no server-side string overrides) instead of a 404 |
|
||||
| GET | `api/incentivizedreferrals/referrals?take&continuationToken` | REAL | `IncentivizedReferralsController.Referrals` — `{Referrals:[{InviteeAccountId,CreatedAt,VerifiedAt}],ContinuationToken}` |
|
||||
|
||||
### CDN host (`cdn.rec.net` / `storage` / `data`)
|
||||
|
||||
Served by `Controllers/Cdn/CdnController` (catch-all that wins routing on every host).
|
||||
|
||||
@@ -27,23 +27,23 @@ namespace DorkNet.Server.Hubs;
|
||||
/// Pre-PR-2 used a local <c>static ConcurrentDictionary</c> which only
|
||||
/// reflected this replica's connections.
|
||||
///
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class NotifyHub(
|
||||
OnlinePresenceService presence,
|
||||
PlayerPresenceService playerRooms,
|
||||
NotificationService notifications,
|
||||
PresenceBroadcastService presenceFanout,
|
||||
DorkNetDbContext db,
|
||||
ServerSettingsService serverSettings,
|
||||
ILogger<NotifyHub> logger) : Hub
|
||||
{
|
||||
/// <summary>SignalR group name for a given player id. Use this
|
||||
@@ -66,25 +66,28 @@ 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 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.)
|
||||
// joined the group yet) and push a targeted ModerationKick
|
||||
// to those ids. The watch's OnModerationKick handler at
|
||||
// PlayerReporting.txt:1857 shows the standard moderation
|
||||
// dialog and routes through LocalModerationKickSelf, the
|
||||
// same graceful disconnect path the original Rec Room used
|
||||
// for real moderator kicks.
|
||||
//
|
||||
// Targeting specific connection ids (not the group) means
|
||||
// the new connection — about to join the group on the next
|
||||
// line — never receives it, even with Redis backplane
|
||||
// line — never receives the kick, even with Redis backplane
|
||||
// routing across replicas.
|
||||
var existing = presence.GetConnections(id);
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); logging out {OldCount} existing connection(s)",
|
||||
"[hub] second-session connect for player={PlayerId} (conn={NewConn}); kicking {OldCount} existing connection(s)",
|
||||
id, Context.ConnectionId, existing.Count);
|
||||
try
|
||||
{
|
||||
await notifications.LogoutConnectionsAsync(existing);
|
||||
await notifications.KickConnectionsAsync(
|
||||
existing,
|
||||
"Your account was signed in on another device.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -105,11 +108,18 @@ public class NotifyHub(
|
||||
// friend's list for the rest of the session even though they were
|
||||
// still playing.
|
||||
//
|
||||
// isOnline is sent explicitly now, so this no longer needs a
|
||||
// known room: a player who connects before matchmaking, or who is
|
||||
// sitting in the offline room, is online with roomInstance null.
|
||||
if (cameOnline)
|
||||
await presenceFanout.PublishCurrentAsync(id);
|
||||
// Only push when a room is already known. The presence payload
|
||||
// derives isOnline from roomInstance being non-null, so a push with
|
||||
// no room reads as OFFLINE — worse than staying quiet. On a fresh
|
||||
// boot there is no room yet and the /goto that follows sends the
|
||||
// real presence anyway; this branch is for reconnects, where the
|
||||
// cached room is exactly what friends need to see again.
|
||||
if (cameOnline && playerRooms.GetRoom(id) is { } room)
|
||||
{
|
||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, id);
|
||||
if (friendIds.Count > 0)
|
||||
await notifications.PlayerCameOnline(friendIds, id, room);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -129,17 +139,9 @@ public class NotifyHub(
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupForPlayer(id));
|
||||
if (wentOffline)
|
||||
{
|
||||
// The socket can drop under a live session (the server or a
|
||||
// proxy closes it; the client logs "RecNet HubConnection error:
|
||||
// ... TCP Stream closed unexpectedly by the remote server" and
|
||||
// reconnects by itself). While the player is still
|
||||
// heartbeating, wait one activity window before calling them
|
||||
// offline; a real exit either logs out (immediate offline push)
|
||||
// or stops heartbeating and is caught by the deferred check.
|
||||
if (playerRooms.IsRecentlyActive(id))
|
||||
presenceFanout.ScheduleOfflineCheck(id);
|
||||
else
|
||||
await presenceFanout.PublishOfflineAsync(id);
|
||||
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, id);
|
||||
if (friendIds.Count > 0)
|
||||
await notifications.PlayerWentOffline(friendIds, id);
|
||||
}
|
||||
logger.LogInformation(
|
||||
"[hub] disconnected player={PlayerId} conn={ConnectionId} wentOffline={WentOffline} error={Error}",
|
||||
@@ -154,111 +156,36 @@ public class NotifyHub(
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
public Task SubscribeToPlayers(JsonElement payload)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[hub] SubscribeTo player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||
"[hub] SubscribeToPlayers player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Counterpart of <see cref="SubscribeTo"/>; logged only.</summary>
|
||||
public Task UnsubscribeFrom(JsonElement payload)
|
||||
public Task UnsubscribeFromPlayers(JsonElement payload)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[hub] UnsubscribeFrom player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||
"[hub] UnsubscribeFromPlayers player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
public Task Subscribe(JsonElement payload)
|
||||
{
|
||||
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;
|
||||
logger.LogInformation(
|
||||
"[hub] Subscribe player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Unsubscribe(JsonElement payload)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"[hub] Unsubscribe player={PlayerId} conn={ConnectionId} payload={Payload}",
|
||||
TryGetPlayerId(), Context.ConnectionId, RawPayload(payload));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private long? TryGetPlayerId()
|
||||
|
||||
@@ -1,3658 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
[DbContext(typeof(DorkNetDbContext))]
|
||||
[Migration("20260802190504_CircuitsV2Limits")]
|
||||
partial class CircuitsV2Limits
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AdminPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AdminPlayerId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("TargetType", "TargetId");
|
||||
|
||||
b.ToTable("AdminActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomAvatarItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquippedItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FaceFeatures")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("HairColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InventoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelections")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelectionsV2")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SavedOutfitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SkinColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Avatars");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReadAt");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.ToTable("BugReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBuiltIn")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CharadesWordLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ModerationState")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey", "SentAt");
|
||||
|
||||
b.ToTable("ChatMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LastReadMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SnoozeUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ThreadKey", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreadMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CheeredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPhotoId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TargetPhotoId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("TargetRoomId");
|
||||
|
||||
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Cheers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "Slot")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAdditionalImages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AuthorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "CreatedAt");
|
||||
|
||||
b.ToTable("ClubAnnouncements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AnnouncementId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("AnnouncementId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAnnouncementReads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CategoryTagId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryTagId");
|
||||
|
||||
b.HasIndex("ClubId", "CategoryTagId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubCategoryAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("ClubCategoryTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ClubChatEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ClubType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ClubhouseRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRRO")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Joinability")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Clubs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ClubId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ApproveMember")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BanUnban")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CreateEvent")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditDetails")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditPermissionSettings")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MembershipType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PostAnnouncement")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "MembershipType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SubscribedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId");
|
||||
|
||||
b.HasIndex("PlayerId", "ClubId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AssignedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CohortKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Variant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CohortKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CohortAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Json")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CommunityBoardRows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxRedemptions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RedemptionCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RewardAmount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardCurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RewardItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Coupons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CouponId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CouponId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CouponRedemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CurrencyType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssetName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("BaseAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PurchaseCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsFeatured");
|
||||
|
||||
b.HasIndex("IsPublic");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CustomAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomAvatarItemId");
|
||||
|
||||
b.HasIndex("PlayerId", "CustomAvatarItemId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItemOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
|
||||
{
|
||||
b.Property<long>("PlayerId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("PlayerId");
|
||||
|
||||
b.ToTable("DormStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("GrantedItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Offer1ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer2ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer3ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SelectedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SelectedGiftDropId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "SelectedAt");
|
||||
|
||||
b.ToTable("GameRewardSelections");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActivityLevelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlayerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Region")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Region");
|
||||
|
||||
b.ToTable("GameSessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AvatarItemDescOrHairDyeDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("AvatarItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConsumableItemDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Consumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ConsumedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Currency")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EquipmentModificationGuid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquipmentPrefabName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftRarity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsGifted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsValid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageMaterial")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageVariant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SourceStoreItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsCurrentPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Xp")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "Consumed");
|
||||
|
||||
b.ToTable("GiftPackages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("CreationRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CreatorPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentVersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("FirstPublishedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GeneralPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAgInvention")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("NumPlayersHaveUsedInRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Permission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SpawnCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CheerCount");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.ToTable("Inventions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("InstantiationCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("InventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LightsCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InventionId", "VersionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InventionVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("BannedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("BannedByAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Cidr")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Cidr");
|
||||
|
||||
b.ToTable("IpBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ItemKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemKey", "ItemType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ItemWishlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EarnedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "EventKey");
|
||||
|
||||
b.ToTable("Keepsakes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
|
||||
{
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Channel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LowerIsBetter")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ValueFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("RoomId", "Channel");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("LeaderboardChannelMeta");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StatChannel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Value")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "StatChannel")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId", "StatChannel", "Value");
|
||||
|
||||
b.ToTable("LeaderboardStats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Context")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlatformMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("LoadingScreenTips");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "SentAt");
|
||||
|
||||
b.HasIndex("SenderPlayerId", "SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAll")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAnnouncements")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowEventInvite")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowFriendRequest")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowMessage")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NotificationPrefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ClearedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ObjectiveProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PendingDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TaggedPlayerIdsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploaderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ViewCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("UploaderPlayerId");
|
||||
|
||||
b.ToTable("Photos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlatformIgnores");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeviceId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PlayerDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Elo")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GameMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Losses")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MatchesPlayed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Wins")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GameMode", "Elo");
|
||||
|
||||
b.HasIndex("PlayerId", "GameMode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerElo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CanReceiveInvites")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedFromDeviceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsCommunityTeam")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeveloper")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsJunior")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LastPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastPlatformId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProfileImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Reputation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("XP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Capacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("StartsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("StartsAt");
|
||||
|
||||
b.ToTable("PlayerEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("EventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Response")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("EventId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerEventResponses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemSlug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerInventory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCurated")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsCurated");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Playlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Cheered")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlaylistId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlaylistInteractions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlaylistId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("PlaylistId", "OrderIndex");
|
||||
|
||||
b.ToTable("PlaylistRooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DataBlob")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("OwnerPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRegion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhotonRoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId");
|
||||
|
||||
b.ToTable("PrivateInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
|
||||
{
|
||||
b.Property<long>("PrivateInstanceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("InvitedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LatestInviteMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PrivateInstanceId", "PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PrivateInstanceInvitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PushTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Ignored")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Muted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RequesterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RequesterId", "TargetId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ResolutionNote")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ResolverAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetEventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.HasIndex("ResolvedAt");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.ToTable("Reports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BannedPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "BannedPlayerId");
|
||||
|
||||
b.ToTable("RoomBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomBookmarks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("CurrencyId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomConsumables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ConcurrencyCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomConsumableId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomConsumableId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomConsumableId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomConsumableOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomCurrencyId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomCurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DailyLimit")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomCurrencies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Amount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.ToTable("RoomCurrencyPurchaseOffers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Bytes")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ReferencedFilenamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploadedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomDataBlobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowNewUsers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CloningAllowed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomRoomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DisableMicAutoMute")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("HiddenFromBrowse")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("HotScore")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAGRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDormRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsStudioRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LoadScreensJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LocationReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayerCalculationMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<string>("PromoExternalContentJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PromoImagesJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RoomWarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioSessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsMobile")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsVRLow")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("HotScore");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Rooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(174)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.ToTable("RoomKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PaidPrice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("PurchasedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomKeyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomKeyId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomKeyPurchases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Accepted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("GrantedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("GrantedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "Role")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CanMatchmakeInto")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DataModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSandbox")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomSceneLocationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StudioAssetBundleNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("StudioSubRoomDataSaveId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioUnityAssetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.HasIndex("RoomId", "OrderIndex")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomScenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("FirstVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "LastVisitAt");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomVisits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChestsOpened")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CompletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CustomGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("HealthPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsInAir")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ShieldPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WalkGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompletedAt");
|
||||
|
||||
b.ToTable("RoyaleMatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("MatchId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("MatchId", "PlayerId");
|
||||
|
||||
b.ToTable("RoyaleMatchPlayers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CurrentLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NextLevelAcornReward")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("NextLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RankIdx")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RankName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalXP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoyalePlayerProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllAvatarItemsOwned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BaseRoomNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CharadesSlotBindingsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CircuitsV2LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiscoveredGameConfigsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("GlobalFriendsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlayMenuTagsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("ProfanityFilterDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecCenterDoorsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("RoomBlobVersionClampDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SignupsDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WeeklyChallengeRewardJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("WeeklyChallengesCompletedRequired")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("WeeklyChallengesJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Descriptor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("RedeemedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SignupCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("AvailableUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsLimitedTime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Storefront")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Storefront");
|
||||
|
||||
b.HasIndex("IsActive", "Category");
|
||||
|
||||
b.ToTable("StoreItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SubscriberPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Pk")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssignedPlayerIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AssignedPlayerNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraBugUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinNumAssignedPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<uint?>("TestPassId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Pk");
|
||||
|
||||
b.HasIndex("Id")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("TestPassId");
|
||||
|
||||
b.ToTable("TestCases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
|
||||
{
|
||||
b.Property<uint>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("EndDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("WasManuallyClosed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TestPasses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("UgcPurchasables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithOne("Avatar")
|
||||
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithMany("Settings")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("RequesterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Requester");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Navigation("Avatar");
|
||||
|
||||
b.Navigation("Relationships");
|
||||
|
||||
b.Navigation("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CircuitsV2Limits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CircuitsV2LimitsJson",
|
||||
table: "ServerSettings",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CircuitsV2LimitsJson",
|
||||
table: "ServerSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
-3673
@@ -1,3673 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
[DbContext(typeof(DorkNetDbContext))]
|
||||
[Migration("20261015110121_PlayerProfile2023Fields")]
|
||||
partial class PlayerProfile2023Fields
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AdminPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AdminPlayerId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("TargetType", "TargetId");
|
||||
|
||||
b.ToTable("AdminActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomAvatarItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquippedItemsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FaceFeatures")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("HairColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InventoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelections")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OutfitSelectionsV2")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SavedOutfitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SkinColor")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Avatars");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientVersion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReadAt");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.ToTable("BugReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBuiltIn")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CharadesWordLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ModerationState")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey", "SentAt");
|
||||
|
||||
b.ToTable("ChatMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ThreadKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LastReadMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SnoozeUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreadKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ThreadKey", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ChatThreadMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CheeredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPhotoId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TargetPhotoId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("TargetRoomId");
|
||||
|
||||
b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Cheers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "Slot")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAdditionalImages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AuthorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "CreatedAt");
|
||||
|
||||
b.ToTable("ClubAnnouncements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("AnnouncementId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("AnnouncementId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubAnnouncementReads");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CategoryTagId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryTagId");
|
||||
|
||||
b.HasIndex("ClubId", "CategoryTagId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubCategoryAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("ClubCategoryTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ClubChatEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ClubType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ClubhouseRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRRO")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Joinability")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Clubs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("ClubId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ApproveMember")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BanUnban")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CreateEvent")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditDetails")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditPermissionSettings")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MembershipType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PostAnnouncement")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId", "MembershipType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubRolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ClubId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SubscribedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClubId");
|
||||
|
||||
b.HasIndex("PlayerId", "ClubId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ClubSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AssignedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CohortKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Variant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CohortKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CohortAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Json")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CommunityBoardRows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxRedemptions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RedemptionCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RewardAmount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardCurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RewardItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Coupons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CouponId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CouponId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CouponRedemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "CurrencyType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssetName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("BaseAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PurchaseCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsFeatured");
|
||||
|
||||
b.HasIndex("IsPublic");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CustomAvatarItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomAvatarItemId");
|
||||
|
||||
b.HasIndex("PlayerId", "CustomAvatarItemId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CustomAvatarItemOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b =>
|
||||
{
|
||||
b.Property<long>("PlayerId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("PlayerId");
|
||||
|
||||
b.ToTable("DormStates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("GrantedItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Offer1ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer2ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Offer3ItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("SelectedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SelectedGiftDropId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "SelectedAt");
|
||||
|
||||
b.ToTable("GameRewardSelections");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActivityLevelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlayerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Region")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Region");
|
||||
|
||||
b.ToTable("GameSessions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AvatarItemDescOrHairDyeDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("AvatarItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ConsumableItemDesc")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Consumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ConsumedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Currency")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EquipmentModificationGuid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EquipmentPrefabName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FromPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftContext")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GiftRarity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsGifted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsValid")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageMaterial")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PackageVariant")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SourceStoreItemId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsCurrentPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Xp")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "Consumed");
|
||||
|
||||
b.ToTable("GiftPackages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("CreationRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CreatorPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentVersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("FirstPublishedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("GeneralPermission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAgInvention")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("NumPlayersHaveUsedInRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Permission")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SpawnCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CheerCount");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.ToTable("Inventions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("InstantiationCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("InventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LightsCost")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VersionNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InventionId", "VersionNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InventionVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("BannedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("BannedByAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Cidr")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Cidr");
|
||||
|
||||
b.ToTable("IpBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ItemKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemKey", "ItemType")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ItemWishlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EarnedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "EventKey");
|
||||
|
||||
b.ToTable("Keepsakes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b =>
|
||||
{
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Channel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LowerIsBetter")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ValueFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("RoomId", "Channel");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("LeaderboardChannelMeta");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StatChannel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Value")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "StatChannel")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId", "StatChannel", "Value");
|
||||
|
||||
b.ToTable("LeaderboardStats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Context")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlatformMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("LoadingScreenTips");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ReadAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RecipientPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SenderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("SentAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipientPlayerId", "SentAt");
|
||||
|
||||
b.HasIndex("SenderPlayerId", "SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAll")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAnnouncements")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowEventInvite")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowFriendRequest")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowMessage")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NotificationPrefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("ClearedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ObjectiveProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PendingDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TaggedPlayerIdsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploaderPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ViewCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("UploaderPlayerId");
|
||||
|
||||
b.ToTable("Photos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlatformUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform", "PlatformUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlatformIgnores");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("FirstSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeviceId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PlayerDevices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Elo")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("GameMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Losses")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MatchesPlayed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Wins")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GameMode", "Elo");
|
||||
|
||||
b.HasIndex("PlayerId", "GameMode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerElo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BannerImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CanReceiveInvites")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedFromDeviceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayEmoji")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdentityFlags")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsCommunityTeam")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeveloper")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsJunior")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastIp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LastPlatform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LastPlatformId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PersonalPronouns")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProfileImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Reputation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("XP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Capacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("EndsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("StartsAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("StartsAt");
|
||||
|
||||
b.ToTable("PlayerEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("EventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Response")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("EventId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerEventResponses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("AcquiredAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ItemSlug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "ItemSlug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerInventory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlayerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CustomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsCurated")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("IsCurated");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Playlists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Cheered")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlaylistId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlaylistInteractions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlaylistId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlaylistId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("PlaylistId", "OrderIndex");
|
||||
|
||||
b.ToTable("PlaylistRooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DataBlob")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("OwnerPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PhotonRegion")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhotonRoomId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoomCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "SubRoomId");
|
||||
|
||||
b.ToTable("PrivateInstances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b =>
|
||||
{
|
||||
b.Property<long>("PrivateInstanceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("InvitedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("LatestInviteMessageId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PrivateInstanceId", "PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("PrivateInstanceInvitees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token");
|
||||
|
||||
b.HasIndex("PlayerId", "Platform")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PushTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Favorited")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Ignored")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Muted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RequesterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RequesterId", "TargetId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameSessionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ReporterPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ResolutionNote")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ResolverAdminId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetEventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetInventionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReporterPlayerId");
|
||||
|
||||
b.HasIndex("ResolvedAt");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.ToTable("Reports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BanType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("BannedPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("Until")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BannedPlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "BannedPlayerId");
|
||||
|
||||
b.ToTable("RoomBans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomBookmarks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("CurrencyId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomConsumables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ConcurrencyCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomConsumableId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomConsumableId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomConsumableId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomConsumableOwnership");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Balance")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.HasIndex("PlayerId", "RoomCurrencyId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomCurrencyBalances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("DailyLimit")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomCurrencies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Amount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomCurrencyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomCurrencyId");
|
||||
|
||||
b.ToTable("RoomCurrencyPurchaseOffers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Bytes")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ReferencedFilenamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("SubRoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UploadedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlobName")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("RoomDataBlobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Accessibility")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowNewUsers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowsJuniors")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CheerCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CloningAllowed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CurrentDataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomRoomWarning")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DisableMicAutoMute")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("HiddenFromBrowse")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("HotScore")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAGRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDormRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsRoomLinkedToRecRoomStudio")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsStudioRoom")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LoadScreensJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LocationReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxCapacity")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayerCalculationMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<string>("PromoExternalContentJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PromoImagesJson")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RoomWarningMask")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("State")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioSessionId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SupportsLevelVoting")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsMobile")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsScreens")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsTeleportVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsVRLow")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SupportsWalkVR")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitorCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("HotScore");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Rooms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(174)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ReplicationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.ToTable("RoomKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PaidPrice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("PurchasedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomKeyId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomKeyId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomKeyPurchases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Accepted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("GrantedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("GrantedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId", "Role")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CanMatchmakeInto")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DataBlobName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DataModifiedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSandbox")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int>("OrderIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomSceneLocationId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StudioAssetBundleNamesCsv")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("StudioSubRoomDataSaveId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StudioUnityAssetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoomId", "Name");
|
||||
|
||||
b.HasIndex("RoomId", "OrderIndex")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomScenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("FirstVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastVisitAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VisitCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("RoomId", "LastVisitAt");
|
||||
|
||||
b.HasIndex("RoomId", "PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoomVisits");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChestsOpened")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CompletedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CustomGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("HealthPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsInAir")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ShieldPotionsConsumed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WalkGame")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompletedAt");
|
||||
|
||||
b.ToTable("RoyaleMatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("MatchId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumEliminations")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondsAlive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.HasIndex("MatchId", "PlayerId");
|
||||
|
||||
b.ToTable("RoyaleMatchPlayers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CurrentLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NextLevelAcornReward")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("NextLevelXPThreshold")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("PlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("RankIdx")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RankName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalXP")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RoyalePlayerProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllAvatarItemsOwned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BaseRoomNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CharadesSlotBindingsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CircuitsV2LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiscoveredGameConfigsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("GlobalFriendsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PlayMenuTagsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("ProfanityFilterDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecCenterDoorsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("RoomBlobVersionClampDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SignupsDisabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WeeklyChallengeRewardJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("WeeklyChallengesCompletedRequired")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("WeeklyChallengesJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Descriptor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("RedeemedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("RedeemedByPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SignupCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("AvailableUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsLimitedTime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Storefront")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Storefront");
|
||||
|
||||
b.HasIndex("IsActive", "Category");
|
||||
|
||||
b.ToTable("StoreItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SubscriberPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("TargetPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId");
|
||||
|
||||
b.HasIndex("TargetPlayerId");
|
||||
|
||||
b.HasIndex("SubscriberPlayerId", "TargetPlayerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Pk")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AssignedPlayerIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AssignedPlayerNamesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraBugUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("JiraUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinNumAssignedPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RoomName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<uint?>("TestPassId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Pk");
|
||||
|
||||
b.HasIndex("Id")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("TestPassId");
|
||||
|
||||
b.ToTable("TestCases");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b =>
|
||||
{
|
||||
b.Property<uint>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("EndDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TagsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("WasManuallyClosed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TestPasses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatorPlayerId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CurrencyType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsFeatured")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ItemType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Price")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RoomId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatorPlayerId");
|
||||
|
||||
b.HasIndex("PublicId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RoomId");
|
||||
|
||||
b.ToTable("UgcPurchasables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithOne("Avatar")
|
||||
.HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player")
|
||||
.WithMany("Settings")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b =>
|
||||
{
|
||||
b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("RequesterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Requester");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b =>
|
||||
{
|
||||
b.Navigation("Avatar");
|
||||
|
||||
b.Navigation("Relationships");
|
||||
|
||||
b.Navigation("Settings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DorkNet.Server.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PlayerProfile2023Fields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BannerImageName",
|
||||
table: "Players",
|
||||
type: "TEXT",
|
||||
maxLength: 256,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DisplayEmoji",
|
||||
table: "Players",
|
||||
type: "TEXT",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "IdentityFlags",
|
||||
table: "Players",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PersonalPronouns",
|
||||
table: "Players",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BannerImageName",
|
||||
table: "Players");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DisplayEmoji",
|
||||
table: "Players");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IdentityFlags",
|
||||
table: "Players");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PersonalPronouns",
|
||||
table: "Players");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1819,10 +1819,6 @@ namespace DorkNet.Server.Migrations
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BannerImageName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
@@ -1846,11 +1842,6 @@ namespace DorkNet.Server.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayEmoji")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
@@ -1860,9 +1851,6 @@ namespace DorkNet.Server.Migrations
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("IdentityFlags")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -1904,9 +1892,6 @@ namespace DorkNet.Server.Migrations
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PersonalPronouns")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -3249,10 +3234,6 @@ namespace DorkNet.Server.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CircuitsV2LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiscoveredGameConfigsJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
using DorkNet.Models.Auth;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single projection from <see cref="PlayerEntity"/> to the account wire
|
||||
/// DTOs. Every account read (account/me, account/{id}, account/bulk,
|
||||
/// account/search, account/create) and the AccountUpdate /
|
||||
/// SelfAccountUpdate pushes go through here so a profile field written by
|
||||
/// one endpoint is visible on all of them — previously each call site built
|
||||
/// its own DTO and the 2023 profile fields were never emitted anywhere.
|
||||
/// </summary>
|
||||
public static class AccountDtos
|
||||
{
|
||||
public static RecNetAccount Public(PlayerEntity p) => Fill(new RecNetAccount(), p);
|
||||
|
||||
public static RecNetSelfAccount Self(PlayerEntity p)
|
||||
{
|
||||
var a = Fill(new RecNetSelfAccount(), p);
|
||||
a.Email = p.Email ?? string.Empty;
|
||||
a.Phone = p.Phone ?? string.Empty;
|
||||
a.Birthday = p.Birthday ?? new DateTime(2000, 1, 1);
|
||||
a.JuniorState = p.IsJunior ? 1 : 0;
|
||||
a.ParentAccountId = null;
|
||||
return a;
|
||||
}
|
||||
|
||||
private static T Fill<T>(T a, PlayerEntity p) where T : RecNetAccount
|
||||
{
|
||||
a.AccountId = (int)p.Id;
|
||||
a.RawUsername = p.Username;
|
||||
a.Username = p.Username;
|
||||
a.DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? p.Username : p.DisplayName;
|
||||
a.ProfileImage = p.ProfileImageName ?? string.Empty;
|
||||
a.TreatAsJunior = p.IsJunior;
|
||||
a.HasBirthday = true;
|
||||
a.Platforms = 1;
|
||||
a.CreatedAt = p.CreatedAt;
|
||||
a.DisplayEmoji = p.DisplayEmoji ?? string.Empty;
|
||||
a.BannerImage = p.BannerImageName ?? string.Empty;
|
||||
a.PersonalPronouns = p.PersonalPronouns;
|
||||
a.IdentityFlags = p.IdentityFlags;
|
||||
return a;
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// OAuth device-authorization grant as the 2023 client drives it.
|
||||
///
|
||||
/// 1. The device that wants to sign in calls <c>connect/deviceauthorization</c>
|
||||
/// and shows the player the <c>user_code</c> (reader
|
||||
/// <c>RecNet.Runtime/AMPFGJPKHOD.txt:399-598</c>: device_code, user_code,
|
||||
/// verification_uri(_complete), expires_in, interval).
|
||||
/// 2. It polls <c>connect/token</c> with
|
||||
/// <c>grant_type=urn:ietf:params:oauth:grant-type:device_code</c> and
|
||||
/// <c>device_code</c> (<c>LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451</c>).
|
||||
/// A 400 whose <c>error</c> is <c>authorization_pending</c> or
|
||||
/// <c>slow_down</c> means "keep polling"
|
||||
/// (<c>LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116</c>); anything else
|
||||
/// ends the attempt.
|
||||
/// 3. A signed-in client approves the code with
|
||||
/// <c>account/me/remoteauth</c> (form field <c>code</c>).
|
||||
///
|
||||
/// State lives in one PlayerSettings row per authorization, owned by the system
|
||||
/// account: key <c>deviceauthorization:{device_code}</c>, value
|
||||
/// <c>{user_code}|{expiresAtUtc:O}</c>, plus <c>|{approverAccountId}</c> once
|
||||
/// approved. Redeeming deletes the row, so a device code is single-use.
|
||||
/// </summary>
|
||||
public static class DeviceAuthorizations
|
||||
{
|
||||
public const string KeyPrefix = "deviceauthorization:";
|
||||
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15);
|
||||
|
||||
public enum RedeemStatus { Invalid, Expired, Pending, Approved }
|
||||
|
||||
public sealed record Created(string DeviceCode, string UserCode, int ExpiresInSeconds);
|
||||
|
||||
public static async Task<Created> CreateAsync(DorkNetDbContext db, DateTime nowUtc)
|
||||
{
|
||||
var deviceCode = Guid.NewGuid().ToString("N");
|
||||
var userCode = deviceCode[..8].ToUpperInvariant();
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = RelationshipQueries.SystemAccountId,
|
||||
Key = KeyPrefix + deviceCode,
|
||||
Value = $"{userCode}|{(nowUtc + Lifetime):O}",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return new Created(deviceCode, userCode, (int)Lifetime.TotalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>Bind the pending authorization whose user code is
|
||||
/// <paramref name="userCode"/> to <paramref name="approverId"/>. False when
|
||||
/// no live, unapproved authorization carries that code.</summary>
|
||||
public static async Task<bool> ApproveAsync(
|
||||
DorkNetDbContext db, string userCode, long approverId, DateTime nowUtc)
|
||||
{
|
||||
var code = NormaliseUserCode(userCode);
|
||||
if (code.Length == 0) return false;
|
||||
|
||||
var prefix = code + "|";
|
||||
var rows = await db.PlayerSettings
|
||||
.Where(s => s.Key.StartsWith(KeyPrefix) && s.Value.StartsWith(prefix))
|
||||
.ToListAsync();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var parts = row.Value.Split('|');
|
||||
if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc)
|
||||
continue;
|
||||
if (parts.Length >= 3 && parts[2].Length > 0) continue; // already approved
|
||||
row.Value = $"{parts[0]}|{parts[1]}|{approverId}";
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>One poll from the device. Approved authorizations are consumed
|
||||
/// and yield the approving account.</summary>
|
||||
public static async Task<(RedeemStatus Status, long AccountId)> RedeemAsync(
|
||||
DorkNetDbContext db, string? deviceCode, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceCode)) return (RedeemStatus.Invalid, 0);
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.Key == KeyPrefix + deviceCode.Trim());
|
||||
if (row is null) return (RedeemStatus.Invalid, 0);
|
||||
|
||||
var parts = row.Value.Split('|');
|
||||
if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc)
|
||||
{
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
return (RedeemStatus.Expired, 0);
|
||||
}
|
||||
|
||||
if (parts.Length < 3 || !long.TryParse(parts[2], out var approver))
|
||||
return (RedeemStatus.Pending, 0);
|
||||
|
||||
db.PlayerSettings.Remove(row);
|
||||
await db.SaveChangesAsync();
|
||||
return (RedeemStatus.Approved, approver);
|
||||
}
|
||||
|
||||
private static string NormaliseUserCode(string? raw) =>
|
||||
new string((raw ?? string.Empty).Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant();
|
||||
|
||||
private static bool TryParseExpiry(string raw, out DateTime expiresUtc) =>
|
||||
DateTime.TryParse(raw, null, System.Globalization.DateTimeStyles.RoundtripKind, out expiresUtc);
|
||||
}
|
||||
@@ -10,27 +10,9 @@ namespace DorkNet.Server.Services;
|
||||
public sealed class JoinTimeoutService(
|
||||
PlayerPresenceService presence,
|
||||
NotificationService notifications,
|
||||
PresenceBroadcastService fanout,
|
||||
ILogger<JoinTimeoutService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// How long a join may stay unreported before the server gives up on it.
|
||||
///
|
||||
/// This used to be 30 seconds, which is shorter than a real 2023 room
|
||||
/// load: the client reports its join result only after the room's assets
|
||||
/// have downloaded and the player has spawned, and a large maker room
|
||||
/// regularly takes longer than that (in the field: matchmake/room at
|
||||
/// 09:16:35, Photon joined at 09:16:41, the load still running at 09:17:33).
|
||||
/// The watchdog then cleared presence mid-load, the next heartbeat found
|
||||
/// no room and answered with the dorm, and the client logged "presence
|
||||
/// heartbeat response indicates local presence is out-of-sync" and
|
||||
/// cancelled the load ("OnPlayerPresenceUpdate is cancelling room load").
|
||||
///
|
||||
/// The 2023 client reports its own failures (reportjoinresult with
|
||||
/// FailedTo*) and runs its fallback chain, so this is only a backstop for
|
||||
/// a client that silently stopped mid-join.
|
||||
/// </summary>
|
||||
public static TimeSpan Timeout { get; } = TimeSpan.FromMinutes(3);
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
|
||||
private readonly ConcurrentDictionary<long, PendingJoin> _pending = new();
|
||||
|
||||
public void MarkPending(long playerId, RoomInstanceDto? room, bool deferPresenceCommit = false)
|
||||
@@ -58,10 +40,6 @@ public sealed class JoinTimeoutService(
|
||||
_ = WatchAsync(playerId, pending);
|
||||
}
|
||||
|
||||
/// <summary>Drop the player's pending join without touching presence —
|
||||
/// the client abandoned it (matchmake/none, logout).</summary>
|
||||
public void Cancel(long playerId, string reason) => ClearForPlayer(playerId, reason);
|
||||
|
||||
public (RoomInstanceDto TargetRoom, bool DeferPresenceCommit)? GetPending(long playerId)
|
||||
{
|
||||
if (!_pending.TryGetValue(playerId, out var pending)) return null;
|
||||
@@ -97,15 +75,11 @@ public sealed class JoinTimeoutService(
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Room only: the player is still online and about to run the
|
||||
// client's fallback chain. A full Clear also dropped the activity
|
||||
// marker, so friends saw them go offline after every failed join.
|
||||
presence.ClearRoom(playerId);
|
||||
presence.Clear(playerId);
|
||||
logger.LogWarning(
|
||||
"[join-watchdog] cleared failed join player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId} outcome={Outcome}",
|
||||
playerId, failedRoom.RoomId, failedRoom.Name, instanceId, outcome);
|
||||
// Whatever room friends were last told about is gone; correct them.
|
||||
return fanout.PublishCurrentAsync(playerId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task WatchAsync(long playerId, PendingJoin pending)
|
||||
@@ -136,9 +110,8 @@ public sealed class JoinTimeoutService(
|
||||
return;
|
||||
}
|
||||
|
||||
presence.ClearRoom(playerId);
|
||||
presence.Clear(playerId);
|
||||
await notifications.KickPlayerAsync(playerId, "Room join timed out. Returning to dorm.");
|
||||
await fanout.PublishCurrentAsync(playerId);
|
||||
logger.LogWarning(
|
||||
"[join-watchdog] timed out player={PlayerId} room={RoomId}/{RoomName} instance={InstanceId}",
|
||||
playerId, pending.TargetRoom.RoomId, pending.TargetRoom.Name, pending.InstanceId);
|
||||
|
||||
@@ -41,13 +41,6 @@ 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
|
||||
@@ -89,13 +82,9 @@ 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 { row.Balance, CurrencyType = currencyType, Platform = 0 });
|
||||
new { CurrencyType = currencyType, row.Balance, Reason = reason });
|
||||
return row.Balance;
|
||||
}
|
||||
|
||||
|
||||
@@ -170,25 +170,45 @@ public class NotificationService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Push fresh account payloads after a profile mutation.
|
||||
/// The owner gets <c>SelfAccountUpdate</c> (private self DTO) and every
|
||||
/// connected client gets <c>AccountUpdate</c> (public DTO). The 2023
|
||||
/// client subscribes to both strings (RecNet.Runtime
|
||||
/// OPMAPIOEIFG.txt:267 "AccountUpdate" → Action<Account>, :279
|
||||
/// "SelfAccountUpdate" → Action<SelfAccount>) and refreshes its
|
||||
/// cached account from the payload — the profile-edit PUTs return no
|
||||
/// account, so this push is the ONLY way the new emoji / banner /
|
||||
/// pronouns / identity flags reach the editor's own UI and anyone else
|
||||
/// already looking at the profile. Sending AccountUpdate to the owner
|
||||
/// alone (the previous behaviour) left every other player rendering the
|
||||
/// stale cached account until relog.</summary>
|
||||
/// <summary>Push fresh account payloads to the affected player's
|
||||
/// watch. The 2020 client caches Account/SelfAccount objects in memory;
|
||||
/// without this, username and display-name edits persist server-side
|
||||
/// but the current session keeps rendering the stale cached names until
|
||||
/// a full relog rebuilds the account cache.</summary>
|
||||
public Task AccountChanged(PlayerEntity player)
|
||||
{
|
||||
var account = AccountDtos.Public(player);
|
||||
var selfAccount = AccountDtos.Self(player);
|
||||
var account = new RecNetAccount
|
||||
{
|
||||
AccountId = (int)player.Id,
|
||||
RawUsername = player.Username,
|
||||
Username = player.Username,
|
||||
DisplayName = string.IsNullOrWhiteSpace(player.DisplayName) ? player.Username : player.DisplayName,
|
||||
ProfileImage = player.ProfileImageName ?? string.Empty,
|
||||
TreatAsJunior = player.IsJunior,
|
||||
HasBirthday = true,
|
||||
Platforms = 1,
|
||||
CreatedAt = player.CreatedAt,
|
||||
};
|
||||
var selfAccount = new RecNetSelfAccount
|
||||
{
|
||||
AccountId = account.AccountId,
|
||||
RawUsername = account.RawUsername,
|
||||
Username = account.Username,
|
||||
DisplayName = account.DisplayName,
|
||||
ProfileImage = account.ProfileImage,
|
||||
TreatAsJunior = account.TreatAsJunior,
|
||||
HasBirthday = account.HasBirthday,
|
||||
Platforms = account.Platforms,
|
||||
CreatedAt = account.CreatedAt,
|
||||
Email = player.Email ?? string.Empty,
|
||||
Phone = player.Phone ?? string.Empty,
|
||||
Birthday = player.Birthday ?? new DateTime(2000, 1, 1),
|
||||
JuniorState = player.IsJunior ? 1 : 0,
|
||||
ParentAccountId = null,
|
||||
};
|
||||
|
||||
return Task.WhenAll(
|
||||
BroadcastAsync(PushNotificationId.SubscriptionUpdateProfile, account),
|
||||
NotifyAsync(player.Id, PushNotificationId.SubscriptionUpdateProfile, account),
|
||||
NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
|
||||
}
|
||||
|
||||
@@ -212,17 +232,25 @@ public class NotificationService(
|
||||
|
||||
// ── Higher-level event helpers ─────────────────────────────────────
|
||||
|
||||
/// <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);
|
||||
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 a presence delta so subscribed friends re-render
|
||||
/// their friend-list row without waiting for the watch's 15-45 s
|
||||
@@ -243,66 +271,17 @@ public class NotificationService(
|
||||
/// the runtime-type path can pick the wrong contract under certain
|
||||
/// option combos and emit an empty <c>{}</c> body — which silently
|
||||
/// drops the presence update at the watch.</summary>
|
||||
public async Task PlayerPresenceChanged(
|
||||
IEnumerable<long> friendIds,
|
||||
long playerId,
|
||||
RoomInstanceDto? room,
|
||||
bool? isOnline = null,
|
||||
int statusVisibility = 0)
|
||||
public async Task PlayerPresenceChanged(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room)
|
||||
{
|
||||
var groups = friendIds.Select(NotifyHub.GroupForPlayer).ToArray();
|
||||
if (groups.Length == 0) return;
|
||||
|
||||
var payload = BuildPresenceUpdatePayload(playerId, room, isOnline, statusVisibility);
|
||||
|
||||
logger.LogDebug(
|
||||
"[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}",
|
||||
playerId, groups.Length, payload);
|
||||
|
||||
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
|
||||
}
|
||||
|
||||
/// <summary>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
|
||||
/// presence DTO stores it in its own field (LCEHINCCHOB +0x50,
|
||||
/// RecNet.Runtime/LCEHINCCHOB.txt:635-653) and DNKHCBFPLJG treats
|
||||
/// "online with no room" as a real state — a player in the offline dorm
|
||||
/// after matchmake/none. Deriving it from the room, as this used to, sent
|
||||
/// such players to their friends as OFFLINE. When omitted it still falls
|
||||
/// back to "has a room" for callers that only know about room moves.
|
||||
/// An offline frame never carries a room.</summary>
|
||||
public static string BuildPresenceUpdatePayload(
|
||||
long playerId,
|
||||
RoomInstanceDto? room,
|
||||
bool? isOnline = null,
|
||||
int statusVisibility = 0)
|
||||
{
|
||||
var online = isOnline ?? room is not null;
|
||||
var redacted = online ? RoomInstanceDto.Redact(room) : null;
|
||||
var redacted = RoomInstanceDto.Redact(room);
|
||||
var msg = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
["playerId"] = (int)playerId,
|
||||
// The player's stored /player/statusvisibility choice (default
|
||||
// 0 = Everyone), the same value their own heartbeat reports.
|
||||
["statusVisibility"] = statusVisibility,
|
||||
// Everyone=0 (was 1=FriendsOnly, which hid you from non-friends).
|
||||
["statusVisibility"] = 0,
|
||||
["deviceClass"] = 0,
|
||||
["vrMovementMode"] = 0,
|
||||
["roomInstance"] = redacted is null
|
||||
@@ -328,7 +307,7 @@ public class NotificationService(
|
||||
["EncryptVoiceChat"] = redacted.EncryptVoiceChat,
|
||||
},
|
||||
// 2020.12 PlayerPresence.Deserialize requires these.
|
||||
["isOnline"] = online,
|
||||
["isOnline"] = redacted is not null,
|
||||
// 2023.03.21 client build version; must match the joining
|
||||
// client's own version or it reports a version mismatch. MUST be
|
||||
// a STRING ("20230317"): the client's presence DTO reads
|
||||
@@ -346,7 +325,13 @@ public class NotificationService(
|
||||
["Id"] = WireName(PushNotificationId.SubscriptionUpdatePresence),
|
||||
["Msg"] = msg,
|
||||
};
|
||||
return envelope.ToJsonString();
|
||||
var payload = envelope.ToJsonString();
|
||||
|
||||
logger.LogDebug(
|
||||
"[notify] presence push player={PlayerId} → {FriendCount} friend(s), payload={Payload}",
|
||||
playerId, groups.Length, payload);
|
||||
|
||||
await hub.Clients.Groups(groups).SendAsync(HubMethod, payload);
|
||||
}
|
||||
|
||||
public Task PlayerCameOnline(IEnumerable<long> friendIds, long playerId, RoomInstanceDto? room) =>
|
||||
@@ -358,85 +343,86 @@ public class NotificationService(
|
||||
public Task PlayerEnteredRoom(IEnumerable<long> friendIds, long playerId, RoomInstanceDto room) =>
|
||||
PlayerPresenceChanged(friendIds, playerId, room);
|
||||
|
||||
/// <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,
|
||||
}
|
||||
/// <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>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 });
|
||||
public Task KickPlayerAsync(long playerId, string reason, bool isBan = false) =>
|
||||
NotifyAsync(playerId, PushNotificationId.ModerationKick,
|
||||
ModerationKickPayload(reason, isBan, source: "admin_kick"));
|
||||
|
||||
/// <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
|
||||
/// <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
|
||||
/// <c>Clients.Clients(ids)</c> to whichever node owns each id.</summary>
|
||||
public Task LogoutConnectionsAsync(IReadOnlyCollection<string> connectionIds)
|
||||
public Task KickConnectionsAsync(IReadOnlyCollection<string> connectionIds, string reason)
|
||||
{
|
||||
if (connectionIds.Count == 0) return Task.CompletedTask;
|
||||
var payload = Serialize(PushNotificationId.Logout, new { Reason = (int)LogoutReason.SessionTakeover });
|
||||
var payload = Serialize(PushNotificationId.ModerationKick,
|
||||
ModerationKickPayload(reason, isBan: false, source: "session_replaced"));
|
||||
return hub.Clients.Clients(connectionIds).SendAsync(HubMethod, payload);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
private static object ModerationKickPayload(string reason, bool isBan, string source)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(reason) ? "Kicked to Dormroom" : reason.Trim();
|
||||
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,
|
||||
};
|
||||
|
||||
return new
|
||||
{
|
||||
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,
|
||||
Reason = blockDetail,
|
||||
DisplayReason = message,
|
||||
BannedByPlayerId = 1,
|
||||
Source = source,
|
||||
};
|
||||
}
|
||||
|
||||
/// <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,34 +148,6 @@ 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
|
||||
|
||||
@@ -48,7 +48,6 @@ public class PlayerPresenceService
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localRoomExpires = new();
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localActiveExpires = new();
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localBootDormGraceExpires = new();
|
||||
private readonly ConcurrentDictionary<long, DateTimeOffset> _localNoRoomExpires = new();
|
||||
private readonly IConnectionMultiplexer? _redis;
|
||||
private readonly ILogger<PlayerPresenceService>? _log;
|
||||
|
||||
@@ -102,9 +101,7 @@ public class PlayerPresenceService
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(room, JsonOpts);
|
||||
var db = mux.GetDatabase();
|
||||
db.StringSet(Key(playerId), json, PresenceTtl);
|
||||
db.KeyDelete(NoRoomKey(playerId));
|
||||
mux.GetDatabase().StringSet(Key(playerId), json, PresenceTtl);
|
||||
_log?.LogDebug("[presence] set redis player={PlayerId} room={Room} bytes={Bytes}",
|
||||
playerId, room?.Name ?? "<null>", json.Length);
|
||||
}
|
||||
@@ -120,7 +117,6 @@ public class PlayerPresenceService
|
||||
{
|
||||
_local[playerId] = room;
|
||||
_localRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||||
_log?.LogDebug("[presence] set local player={PlayerId} room={Room}",
|
||||
playerId, room?.Name ?? "<null>");
|
||||
}
|
||||
@@ -337,7 +333,6 @@ public class PlayerPresenceService
|
||||
db.KeyDelete(Key(playerId));
|
||||
db.KeyDelete(ActiveKey(playerId));
|
||||
db.KeyDelete(BootDormGraceKey(playerId));
|
||||
db.KeyDelete(NoRoomKey(playerId));
|
||||
db.SetRemove(ActiveSetKey, playerId);
|
||||
}
|
||||
else
|
||||
@@ -346,119 +341,9 @@ public class PlayerPresenceService
|
||||
_localRoomExpires.TryRemove(playerId, out _);
|
||||
_localActiveExpires.TryRemove(playerId, out _);
|
||||
_localBootDormGraceExpires.TryRemove(playerId, out _);
|
||||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forget the player's room but keep everything that says they
|
||||
/// are still here (activity marker, login lock, boot grace). Used when a
|
||||
/// join fails or times out: the player is still online and about to run
|
||||
/// the client's fallback chain, so friends must not see them go offline,
|
||||
/// they just must not see a room the player never reached.</summary>
|
||||
public void ClearRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
mux.GetDatabase().KeyDelete(Key(playerId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis room DEL failed for player={PlayerId}", playerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_local.TryRemove(playerId, out _);
|
||||
_localRoomExpires.TryRemove(playerId, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record that the player deliberately left online matchmaking: they are
|
||||
/// online, but in no room instance.
|
||||
///
|
||||
/// The 2023 client does this in TryGoToOfflineRoom
|
||||
/// (RecNet.Runtime/RecNet/Matchmaking.txt:12677, the only caller of
|
||||
/// <c>matchmake/none</c> at :13279): it swaps its cached presence to the
|
||||
/// local offline instance (RoomInstanceId -1) and posts matchmake/none,
|
||||
/// ignoring the reply (<c><TryGoToOfflineRoom>b__119_0</c> returns a
|
||||
/// constant). From then on the heartbeat handler
|
||||
/// (Matchmaking.txt:18103, compare at :18415-18467) treats a negative RoomInstanceId like no
|
||||
/// room (HCHDEHIGEBE.GIICPDHJOOH = <c>RoomInstanceId < 0</c>) and is
|
||||
/// only in sync while the server answers <c>roomInstance: null</c>. Any
|
||||
/// room here — the old one, or a synthesised dorm — reads as out-of-sync
|
||||
/// and the client cancels its offline load to chase the server's room.
|
||||
/// The marker keeps the heartbeat's dorm fallback from firing for this
|
||||
/// player until they matchmake somewhere again.
|
||||
/// </summary>
|
||||
public void SetNoRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
var db = mux.GetDatabase();
|
||||
db.KeyDelete(Key(playerId));
|
||||
db.StringSet(NoRoomKey(playerId), "1", PresenceTtl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis no-room SET failed for player={PlayerId}", playerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_local.TryRemove(playerId, out _);
|
||||
_localRoomExpires.TryRemove(playerId, out _);
|
||||
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
}
|
||||
|
||||
/// <summary>True while the player is online with no room after
|
||||
/// <see cref="SetNoRoom"/>. Cleared by the next <see cref="SetRoom"/> or
|
||||
/// <see cref="Clear"/>.</summary>
|
||||
public bool IsInNoRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
return mux.GetDatabase().KeyExists(NoRoomKey(playerId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis no-room GET failed for player={PlayerId}", playerId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_localNoRoomExpires.TryGetValue(playerId, out var expires)) return false;
|
||||
if (expires > DateTimeOffset.UtcNow) return true;
|
||||
_localNoRoomExpires.TryRemove(playerId, out _);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Keep the no-room marker alive while the player keeps
|
||||
/// heartbeating from the offline room.</summary>
|
||||
public void TouchNoRoom(long playerId)
|
||||
{
|
||||
if (_redis is { } mux)
|
||||
{
|
||||
try
|
||||
{
|
||||
mux.GetDatabase().KeyExpire(NoRoomKey(playerId), PresenceTtl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.LogWarning(ex, "[presence] redis no-room EXPIRE failed for player={PlayerId}", playerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_localNoRoomExpires.ContainsKey(playerId))
|
||||
_localNoRoomExpires[playerId] = DateTimeOffset.UtcNow.Add(PresenceTtl);
|
||||
}
|
||||
|
||||
/// <summary>List the currently-active instances of a given room,
|
||||
/// grouped by PhotonRoomId, with the player ids currently presenced
|
||||
/// in each. Drives the watch's RoomInstanceBrowser
|
||||
@@ -501,7 +386,6 @@ public class PlayerPresenceService
|
||||
private static string Key(long playerId) => $"presence:{playerId}";
|
||||
private static string ActiveKey(long playerId) => $"presence-active:{playerId}";
|
||||
private static string BootDormGraceKey(long playerId) => $"presence-boot-dorm-grace:{playerId}";
|
||||
private static string NoRoomKey(long playerId) => $"presence-noroom:{playerId}";
|
||||
private const string ActiveSetKey = "presence-active";
|
||||
|
||||
// ── LoginLock single-session enforcement ─────────────────────────
|
||||
|
||||
@@ -423,38 +423,6 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
outfitsFixed, inventoryFixed, colorsFixed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True for a platform id that identifies nobody: blank, "0" (no platform
|
||||
/// session) or "1". A Quest client patched without its own Meta app
|
||||
/// (DorkNet.Quest, no-Meta mode) has no platform session, so it reports
|
||||
/// the same placeholder user id "1" from every headset. Treating that as a
|
||||
/// real id would list every Quest player's account in every other Quest
|
||||
/// player's remembered-logins prompt and, with no device id, merge them
|
||||
/// into one synthetic account. Placeholders are never stored on an
|
||||
/// account, never matched by the cached-login lookup and never used to
|
||||
/// synthesise a device id — those players are keyed by deviceId alone.
|
||||
/// </summary>
|
||||
public static bool IsPlaceholderPlatformId(string? platformId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platformId)) return true;
|
||||
var trimmed = platformId.Trim();
|
||||
return trimmed == "0" || trimmed == "1";
|
||||
}
|
||||
|
||||
/// <summary>Lookup-only counterpart to
|
||||
/// <see cref="GetOrCreateByDeviceAsync"/>. Returns the existing player
|
||||
/// row for the given deviceId, or null if none exists — never creates
|
||||
/// a new account. Callers that want to enforce the admin "signups
|
||||
/// disabled" toggle use this to gate the device-id login fallback
|
||||
/// without accidentally minting an account.</summary>
|
||||
public async Task<PlayerEntity?> GetByDeviceAsync(string? deviceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return null;
|
||||
return await db.Players
|
||||
.Include(p => p.Avatar)
|
||||
.FirstOrDefaultAsync(p => p.DeviceId == deviceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up an existing account by deviceId, or create a new one if no
|
||||
/// account is bound to that device yet. This is the canonical
|
||||
@@ -472,13 +440,26 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
/// - When deviceId is empty (rare paths), fall back to a generated
|
||||
/// synthetic id so we never create accidentally-shared accounts.
|
||||
/// </summary>
|
||||
/// <summary>Lookup-only counterpart to
|
||||
/// <see cref="GetOrCreateByDeviceAsync"/>. Returns the existing player
|
||||
/// row for the given deviceId, or null if none exists — never creates
|
||||
/// a new account. Callers that want to enforce the admin "signups
|
||||
/// disabled" toggle use this to gate the device-id login fallback
|
||||
/// without accidentally minting an account.</summary>
|
||||
public async Task<PlayerEntity?> GetByDeviceAsync(string? deviceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return null;
|
||||
return await db.Players
|
||||
.Include(p => p.Avatar)
|
||||
.FirstOrDefaultAsync(p => p.DeviceId == deviceId);
|
||||
}
|
||||
|
||||
public async Task<PlayerEntity> GetOrCreateByDeviceAsync(
|
||||
string? deviceId,
|
||||
int platform = 0,
|
||||
string? platformId = null,
|
||||
string? displayName = null)
|
||||
{
|
||||
if (IsPlaceholderPlatformId(platformId)) platformId = null;
|
||||
if (string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
// Synthesize a stable id from the platformId or generate one so
|
||||
@@ -578,7 +559,6 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
string? platformId = null,
|
||||
string? displayName = null)
|
||||
{
|
||||
if (IsPlaceholderPlatformId(platformId)) platformId = null;
|
||||
// Synthesise deviceId / platformId for empty inputs so the
|
||||
// audit trail still lands somewhere meaningful.
|
||||
var effectiveDeviceId = string.IsNullOrWhiteSpace(deviceId)
|
||||
@@ -666,7 +646,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
if (player is null) return;
|
||||
player.LastSeenAt = DateTime.UtcNow;
|
||||
if (platform != 0) player.LastPlatform = platform;
|
||||
if (!IsPlaceholderPlatformId(platformId)) player.LastPlatformId = platformId!;
|
||||
if (!string.IsNullOrWhiteSpace(platformId)) player.LastPlatformId = platformId;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -683,7 +663,7 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
/// </summary>
|
||||
public async Task<List<PlayerEntity>> GetCachedLoginsAsync(int platform, string? platformId, int take = 10)
|
||||
{
|
||||
if (IsPlaceholderPlatformId(platformId))
|
||||
if (string.IsNullOrWhiteSpace(platformId))
|
||||
return [];
|
||||
return await db.Players
|
||||
.Where(p => p.LastPlatform == platform && p.LastPlatformId == platformId)
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
using DorkNet.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Online-status visibility enum (2023 client, dump.cs IPLLHDINPBL). Picked in
|
||||
/// the profile's "Set Online Status Visibility" dialog (Assembly-CSharp
|
||||
/// RRUI/Data/BaseAccountModel.txt, ShowChangeOnlineStatusVisibilityDialog) and
|
||||
/// sent as PUT player/statusvisibility form statusVisibility (RecNet.Runtime
|
||||
/// RecNet/Matchmaking.txt:17147-17161).
|
||||
/// </summary>
|
||||
public static class PresenceVisibility
|
||||
{
|
||||
public const int Public = 0;
|
||||
public const int FriendsOnly = 1;
|
||||
public const int FavoriteFriendsOnly = 2;
|
||||
public const int Offline = 3;
|
||||
|
||||
public static bool IsValid(int value) => value is >= Public and <= Offline;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells a player's friends when that player's presence changes, from one
|
||||
/// place, so every transition sends the same frame built from the same state.
|
||||
///
|
||||
/// Before this existed only the immediate <c>/goto</c> commit pushed anything.
|
||||
/// Deferred commits (matchmake/dorm, private-instance hops) committed in
|
||||
/// reportjoinresult without a push, matchmake/none and logout changed presence
|
||||
/// silently, and SignalR connect/disconnect pushed with <c>isOnline</c> derived
|
||||
/// from "has a room" — so a friend's list showed people in rooms they had left,
|
||||
/// offline while they sat in their dorm, and never noticed a status-visibility
|
||||
/// change.
|
||||
///
|
||||
/// The client's PresenceUpdate handler (Matchmaking.EGPABEDPEJO, registered at
|
||||
/// RecNet.Runtime/RecNet/Matchmaking.txt:1436) feeds the payload straight into
|
||||
/// FLAOOBNJAAD (:6917), the same routine the heartbeat uses to apply an out-of-sync
|
||||
/// correction (:18467). A frame about the LOCAL player therefore moves that player, so
|
||||
/// these frames 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,
|
||||
PlayerPresenceService presence,
|
||||
OnlinePresenceService connections,
|
||||
NotificationService notifications,
|
||||
ILogger<PresenceBroadcastService> logger)
|
||||
{
|
||||
/// <summary>PlayerSettings key for the persisted
|
||||
/// <c>/player/statusvisibility</c> choice.</summary>
|
||||
public const string StatusVisibilityPrefKey = "pref:statusVisibility";
|
||||
|
||||
/// <summary>A player counts as online while they heartbeat or hold a
|
||||
/// SignalR connection. Being in a room is NOT required: the client's
|
||||
/// presence DTO carries <c>isOnline</c> separately (LCEHINCCHOB +0x50) and
|
||||
/// DNKHCBFPLJG reads "online, in no room / the offline room" as a valid
|
||||
/// state, which is exactly what a player in the offline dorm is.</summary>
|
||||
public bool IsOnline(long playerId) =>
|
||||
presence.IsRecentlyActive(playerId) || connections.GetConnections(playerId).Count > 0;
|
||||
|
||||
/// <summary>Push the player's current presence (online state + committed
|
||||
/// room) to every friend and subscriber.</summary>
|
||||
public Task PublishCurrentAsync(long playerId) => PublishAsync(playerId, forceOffline: false);
|
||||
|
||||
/// <summary>Push an offline frame regardless of any lingering markers —
|
||||
/// for an explicit logout.</summary>
|
||||
public Task PublishOfflineAsync(long playerId) => PublishAsync(playerId, forceOffline: true);
|
||||
|
||||
/// <summary>
|
||||
/// The last SignalR connection dropped but the player may still be
|
||||
/// playing: the socket can be closed under a live session ("RecNet
|
||||
/// HubConnection error: ... TCP Stream closed unexpectedly by the remote
|
||||
/// server") and the client reconnects on its own. Pushing offline right
|
||||
/// away greys the player out for every friend until that reconnect. Wait
|
||||
/// one activity window and only then decide: still silent means the game
|
||||
/// is really gone.
|
||||
/// </summary>
|
||||
public void ScheduleOfflineCheck(long playerId)
|
||||
{
|
||||
var delay = PlayerPresenceService.ActivityTtl + TimeSpan.FromSeconds(5);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay);
|
||||
if (IsOnline(playerId)) return;
|
||||
await PublishAsync(playerId, forceOffline: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "[presence-fanout] deferred offline check failed player={PlayerId}", playerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task PublishAsync(long playerId, bool forceOffline)
|
||||
{
|
||||
if (playerId <= 0) return;
|
||||
try
|
||||
{
|
||||
await using var scope = scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<ServerSettingsService>();
|
||||
|
||||
var friendIds = (await RelationshipQueries.EffectiveFriendIdsAsync(db, settings, playerId))
|
||||
.Where(id => id != playerId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var online = !forceOffline && IsOnline(playerId);
|
||||
var room = online ? presence.GetRoom(playerId) : null;
|
||||
var visibility = await GetStatusVisibilityAsync(db, playerId);
|
||||
|
||||
// Honour the player's visibility choice the same way GET /player
|
||||
// does (PlayerPresenceController.ResolveHiddenAsync): recipients
|
||||
// who may not see them get the frame a genuinely offline player
|
||||
// produces. Every recipient here is already a friend, so
|
||||
// FriendsOnly needs no split; FavoriteFriendsOnly reaches only the
|
||||
// friends this player favorited (Favorited lives on the
|
||||
// favoriter's own row, RequesterId = favoriter).
|
||||
var favorited = visibility == PresenceVisibility.FavoriteFriendsOnly
|
||||
? (await db.Relationships.AsNoTracking()
|
||||
.Where(r => r.RequesterId == playerId && r.Favorited && friendIds.Contains(r.TargetId))
|
||||
.Select(r => r.TargetId)
|
||||
.ToListAsync()).ToHashSet()
|
||||
: new HashSet<long>();
|
||||
var (visibleTo, hiddenFrom) = SplitRecipients(friendIds, visibility, favorited);
|
||||
|
||||
logger.LogInformation(
|
||||
"[presence-fanout] player={PlayerId} online={Online} room={RoomId} instance={InstanceId} visibility={Visibility} -> {FriendCount} friend(s), {HiddenCount} hidden",
|
||||
playerId, online, room?.RoomId, room?.RoomInstanceId, visibility, visibleTo.Count, hiddenFrom.Count);
|
||||
|
||||
if (visibleTo.Count > 0)
|
||||
await notifications.PlayerPresenceChanged(visibleTo, playerId, room, online, visibility);
|
||||
if (hiddenFrom.Count > 0)
|
||||
await notifications.PlayerPresenceChanged(hiddenFrom, playerId, null, false, visibility);
|
||||
|
||||
// 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)
|
||||
{
|
||||
// Best-effort: a fan-out failure must never fail the matchmake,
|
||||
// logout or hub event that triggered it.
|
||||
logger.LogWarning(ex, "[presence-fanout] push failed player={PlayerId}", playerId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Split a player's friends into those who may see their real
|
||||
/// presence and those who must see them offline, per their status
|
||||
/// visibility. <paramref name="favorited"/> holds the friends the player
|
||||
/// favorited; it only matters for FavoriteFriendsOnly.</summary>
|
||||
public static (List<long> VisibleTo, List<long> HiddenFrom) SplitRecipients(
|
||||
IReadOnlyList<long> friendIds, int visibility, IReadOnlySet<long> favorited) =>
|
||||
visibility switch
|
||||
{
|
||||
PresenceVisibility.Offline => (new List<long>(), friendIds.ToList()),
|
||||
PresenceVisibility.FavoriteFriendsOnly => (
|
||||
friendIds.Where(favorited.Contains).ToList(),
|
||||
friendIds.Where(id => !favorited.Contains(id)).ToList()),
|
||||
_ => (friendIds.ToList(), new List<long>()),
|
||||
};
|
||||
|
||||
/// <summary>Stored status visibility for one player (0 = default).</summary>
|
||||
public static async Task<int> GetStatusVisibilityAsync(DorkNetDbContext db, long playerId)
|
||||
{
|
||||
var raw = await db.PlayerSettings.AsNoTracking()
|
||||
.Where(s => s.PlayerId == playerId && s.Key == StatusVisibilityPrefKey)
|
||||
.Select(s => s.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
return int.TryParse(raw, out var v) && PresenceVisibility.IsValid(v) ? v : PresenceVisibility.Public;
|
||||
}
|
||||
|
||||
/// <summary>Stored status visibility for many players in one query.
|
||||
/// Players with no stored choice are absent from the result.</summary>
|
||||
public static async Task<Dictionary<long, int>> GetStatusVisibilitiesAsync(
|
||||
DorkNetDbContext db, IReadOnlyCollection<long> playerIds)
|
||||
{
|
||||
if (playerIds.Count == 0) return new Dictionary<long, int>();
|
||||
var rows = await db.PlayerSettings.AsNoTracking()
|
||||
.Where(s => s.Key == StatusVisibilityPrefKey && playerIds.Contains(s.PlayerId))
|
||||
.Select(s => new { s.PlayerId, s.Value })
|
||||
.ToListAsync();
|
||||
var result = new Dictionary<long, int>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (int.TryParse(row.Value, out var v) && PresenceVisibility.IsValid(v)) result[row.PlayerId] = v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Services;
|
||||
@@ -176,25 +175,7 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
var row = await GetAsync();
|
||||
return ToGameConfigurations(
|
||||
ToRecCenterDoors(row),
|
||||
ToDiscoveredGameConfigs(row),
|
||||
ToCircuitsV2Limits(row));
|
||||
}
|
||||
|
||||
public async Task<CircuitsV2LimitSettings> GetCircuitsV2LimitsAsync()
|
||||
{
|
||||
var row = await GetAsync();
|
||||
return ToCircuitsV2Limits(row);
|
||||
}
|
||||
|
||||
public async Task<CircuitsV2LimitSettings> SetCircuitsV2LimitsAsync(
|
||||
CircuitsV2LimitSettings settings)
|
||||
{
|
||||
var normalized = NormalizeCircuitsV2Limits(settings);
|
||||
var existing = await GetTrackedRowAsync();
|
||||
existing.CircuitsV2LimitsJson = JsonSerializer.Serialize(normalized, JsonOptions);
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return normalized with { UpdatedAt = existing.UpdatedAt };
|
||||
ToDiscoveredGameConfigs(row));
|
||||
}
|
||||
|
||||
public async Task<PlayMenuTagSettings> SetPlayMenuTagsAsync(
|
||||
@@ -682,86 +663,9 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
SplitTestSegmentProbabilities: "{}",
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
|
||||
/// <summary>
|
||||
/// Retail shipped one network entry — 5 RPCs per frame, 1024 bytes per
|
||||
/// 60 frames — which is what makes busy CircuitsV2 rooms drop chip
|
||||
/// sends. DorkNet ships the budgets effectively unlimited instead, and
|
||||
/// raises the room-size limits to values the client survives with
|
||||
/// headroom (each is justified in docs/recroom-2023-circuitsv2-limits.md):
|
||||
/// <list type="bullet">
|
||||
/// <item>Compute 50000 → 500000: the per-frame CPU budget shared by
|
||||
/// every circuit in the room. It is the only thing that stops a runaway
|
||||
/// loop, so it stays finite.</item>
|
||||
/// <item>Chips 3000 → 30000: the room's chip cap ("Too many
|
||||
/// chips.").</item>
|
||||
/// <item>CloudValues 100 → 1000: the room's cloud-variable cap.</item>
|
||||
/// <item>UnculledChips / VisibleChips 500 → 3000: chips past these
|
||||
/// counts get culled, so every chip of a retail-sized room stays
|
||||
/// visible.</item>
|
||||
/// <item>InkPerChip 15 → 0: this is an ink COST, charged against the
|
||||
/// client's hard-coded 30000-ink room cap, so lowering it is the
|
||||
/// increase. 0 means chips stop eating room ink.</item>
|
||||
/// </list>
|
||||
/// CloudValuesAutosaveSeconds stays at retail's 300: it is a save
|
||||
/// cadence, not a capacity limit. Admins can dial any of it back from
|
||||
/// the SPA.
|
||||
/// </summary>
|
||||
public static CircuitsV2LimitSettings DefaultCircuitsV2Limits() =>
|
||||
new(
|
||||
Network:
|
||||
[
|
||||
new(
|
||||
NumPlayersForActivation: 0,
|
||||
Bytes: UnlimitedBudget,
|
||||
BytesTimeWindowFrame60s: 60,
|
||||
Rpcs: UnlimitedBudget,
|
||||
RpcTimeWindowFrame60s: 1),
|
||||
],
|
||||
Compute: 500_000,
|
||||
Chips: 30_000,
|
||||
CloudValues: 1_000,
|
||||
CloudValuesAutosaveSeconds: 300f,
|
||||
UnculledChips: 3_000,
|
||||
VisibleChips: 3_000,
|
||||
InkPerChip: 0,
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
|
||||
/// <summary>The budget value that stands in for "unlimited". The client
|
||||
/// only ever compares a running counter against it, so any large int
|
||||
/// works; this leaves 2x headroom under int.MaxValue.</summary>
|
||||
private const int UnlimitedBudget = 1_000_000_000;
|
||||
|
||||
/// <summary>Time windows size a <c>new int[N]</c> ring buffer — one
|
||||
/// bucket per frame — inside the client's rate limiter, so a huge
|
||||
/// window is an allocation, not a bigger budget. 3600 frames (a minute
|
||||
/// at 60fps) is 14KB and far past anything useful.</summary>
|
||||
private const int MaxTimeWindowFrames = 3600;
|
||||
|
||||
/// <summary>Play-menu carousel order. <c>PlayMenuCarouselModel.TryGetDisplayPriorityMap</c>
|
||||
/// looks this exact key up (literal at Assembly-CSharp/RRUI/Data/PlayMenuCarouselModel.txt:547)
|
||||
/// and, when it is absent, logs "Could not find GameConfig for PlayMenuCarouselOrdering_1"
|
||||
/// (:758) and leaves the carousels in prefab order. The value is split on ';' then '='
|
||||
/// into id → Int32 priority (:603, :632, :655); <c>PlayMenuCarouselController.DisplayOrderPriorityImpl</c>
|
||||
/// then reorders the carousel children with SetSiblingIndex.
|
||||
///
|
||||
/// The ids are the carousel ids serialized on that component in the 2023-03-21 build's
|
||||
/// Addressables bundle (3900770abc33861326d1865e454c0901.bundle, MonoBehaviour
|
||||
/// PlayMenuCarouselController/DisplayOrderPriorityImpl): continueplaying, friendsplaying,
|
||||
/// myclubhouses, myevents, mysubscriptions, recommended, hotrooms, topcreators, featured,
|
||||
/// myfavorites, recroomoriginals, new, limitedtimeevent. Priorities follow the live
|
||||
/// service's 2023 "PlayMenuCarouselOrdering_3" segment (higher first), with the
|
||||
/// time-limited event carousel put on top.</summary>
|
||||
public const string PlayMenuCarouselOrderingKey = "PlayMenuCarouselOrdering_1";
|
||||
|
||||
public const string PlayMenuCarouselOrdering =
|
||||
"limitedtimeevent=15;hotrooms=14;new=13;recroomoriginals=12;continueplaying=11;" +
|
||||
"myfavorites=10;friendsplaying=9;recommended=8;mysubscriptions=7;featured=5;" +
|
||||
"myclubhouses=4;myevents=3;topcreators=2";
|
||||
|
||||
public static IReadOnlyList<GameConfigurationSetting> ToGameConfigurations(
|
||||
RecCenterDoorSettings doors,
|
||||
DiscoveredGameConfigSettings discovered,
|
||||
CircuitsV2LimitSettings circuits)
|
||||
DiscoveredGameConfigSettings discovered)
|
||||
{
|
||||
var rows = new List<GameConfigurationSetting>();
|
||||
foreach (var door in NormalizeRecCenterDoors(doors.Doors).Doors)
|
||||
@@ -786,26 +690,8 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
new("splitTestSoftOverrides", gameConfig.SplitTestSoftOverrides),
|
||||
new("splitTestHardOverrides", gameConfig.SplitTestHardOverrides),
|
||||
new("splitTestSegmentProbabilities", gameConfig.SplitTestSegmentProbabilities),
|
||||
new(PlayMenuCarouselOrderingKey, PlayMenuCarouselOrdering),
|
||||
]);
|
||||
|
||||
var limits = NormalizeCircuitsV2Limits(circuits);
|
||||
rows.AddRange([
|
||||
new("UGC.CircuitsV2.Limit.Network", JsonSerializer.Serialize(limits.Network, JsonOptions)),
|
||||
new("UGC.CircuitsV2.Limit.Compute", Int(limits.Compute)),
|
||||
new("UGC.CircuitsV2.Limit.Chips", Int(limits.Chips)),
|
||||
new("UGC.CircuitsV2.Limit.CloudValues", Int(limits.CloudValues)),
|
||||
new("UGC.CircuitsV2.Limit.CloudValuesAutosaveSeconds",
|
||||
limits.CloudValuesAutosaveSeconds.ToString("0.###", CultureInfo.InvariantCulture)),
|
||||
new("UGC.CircuitsV2.Limit.UnculledChips", Int(limits.UnculledChips)),
|
||||
new("UGC.CircuitsV2.Limit.VisibleChips", Int(limits.VisibleChips)),
|
||||
new("UGC.CircuitsV2.Limit.InkPerChip", Int(limits.InkPerChip)),
|
||||
]);
|
||||
return rows;
|
||||
|
||||
// The client parses every scalar with int/float.TryParse under
|
||||
// InvariantCulture, so no thousands separators.
|
||||
static string Int(int value) => value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static PlayMenuTagSettings NormalizePlayMenuTags(
|
||||
@@ -914,104 +800,6 @@ public class ServerSettingsService(DorkNetDbContext db)
|
||||
SplitTestSegmentProbabilities: JsonObjectOrDefault(settings.SplitTestSegmentProbabilities),
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clamps the limits to what the client can actually survive. Four of
|
||||
/// these are crash guards rather than taste:
|
||||
/// <list type="bullet">
|
||||
/// <item>Rpcs of 0 divides by zero in the CircuitsV2 debug subscreen,
|
||||
/// which renders <c>RpcRecentMax / Rpcs</c>.</item>
|
||||
/// <item>A time window sizes a per-frame ring buffer, so an
|
||||
/// unbounded value is an out-of-memory kill at circuit init.</item>
|
||||
/// <item>Chips sizes four list preallocations in the chip-culling
|
||||
/// manager, and InkPerChip × CloudValues is an unchecked int multiply,
|
||||
/// so both are capped.</item>
|
||||
/// <item>The client rejects the whole network array — falling back to
|
||||
/// the retail 5-RPC budget — unless some entry has
|
||||
/// NumPlayersForActivation 0, so the lowest entry is forced to 0.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static CircuitsV2LimitSettings NormalizeCircuitsV2Limits(
|
||||
CircuitsV2LimitSettings settings)
|
||||
{
|
||||
var seen = new HashSet<int>();
|
||||
var network = (settings.Network ?? [])
|
||||
.Where(entry => entry is not null)
|
||||
.Select(entry => new CircuitsV2NetworkLimit(
|
||||
NumPlayersForActivation: Math.Clamp(entry.NumPlayersForActivation, 0, 1000),
|
||||
Bytes: Math.Clamp(entry.Bytes, 1, UnlimitedBudget),
|
||||
BytesTimeWindowFrame60s: Math.Clamp(entry.BytesTimeWindowFrame60s, 1, MaxTimeWindowFrames),
|
||||
Rpcs: Math.Clamp(entry.Rpcs, 1, UnlimitedBudget),
|
||||
RpcTimeWindowFrame60s: Math.Clamp(entry.RpcTimeWindowFrame60s, 1, MaxTimeWindowFrames)))
|
||||
.OrderBy(entry => entry.NumPlayersForActivation)
|
||||
.Where(entry => seen.Add(entry.NumPlayersForActivation))
|
||||
.Take(16)
|
||||
.ToList();
|
||||
|
||||
if (network.Count == 0)
|
||||
network = DefaultCircuitsV2Limits().Network;
|
||||
else if (network[0].NumPlayersForActivation != 0)
|
||||
network[0] = network[0] with { NumPlayersForActivation = 0 };
|
||||
|
||||
return new CircuitsV2LimitSettings(
|
||||
Network: network,
|
||||
// Compute is the per-frame CPU budget: CircuitsV2Manager's frame
|
||||
// tick (CircuitsV2Manager.txt OCGDEIJJBAG ISIL 240, :21241)
|
||||
// builds a fresh BENFNKBKHEA(Compute) every frame, which sums
|
||||
// four int counters and compares them <= the budget
|
||||
// (Circuits.V2/BENFNKBKHEA.txt). Nothing is allocated from it,
|
||||
// but it is the only runaway-loop guard: a budget near
|
||||
// int.MaxValue lets the summed counters wrap negative and never
|
||||
// trip. 10M keeps 200x headroom under int.MaxValue.
|
||||
Compute: Math.Clamp(settings.Compute, 1, 10_000_000),
|
||||
// The chip-culling manager's ctor preallocates four
|
||||
// List<Chip>(Chips) (PAFDMBHNMBP.txt .ctor ISIL 053-108), so
|
||||
// 0 or a negative is an ArgumentOutOfRange and 1M is 32MB of
|
||||
// pointer arrays — the ceiling.
|
||||
Chips: Math.Clamp(settings.Chips, 1, 1_000_000),
|
||||
// CalculateActiveResourceCost computes
|
||||
// inkCost += InkPerChip * Math.Min(numChips, CloudValues) in
|
||||
// plain int (CreationManager.txt ISIL 389-415, :6136-6162).
|
||||
// With InkPerChip capped at 30000 below, 10000 keeps that
|
||||
// product at 3e8, well under int.MaxValue.
|
||||
CloudValues: Math.Clamp(settings.CloudValues, 0, 10_000),
|
||||
// A non-finite value would serialize as "NaN"/"Infinity", which
|
||||
// the client's float.TryParse happily accepts.
|
||||
CloudValuesAutosaveSeconds: float.IsFinite(settings.CloudValuesAutosaveSeconds)
|
||||
? Math.Clamp(settings.CloudValuesAutosaveSeconds, 1f, 3600f)
|
||||
: 300f,
|
||||
UnculledChips: Math.Clamp(settings.UnculledChips, 1, 1_000_000),
|
||||
VisibleChips: Math.Clamp(settings.VisibleChips, 1, 1_000_000),
|
||||
// InkPerChip is an ink cost against the room's hard-coded
|
||||
// 30000-ink cap (CostInfo(0x7530, 64, 101, Chips, CloudValues)
|
||||
// in CreationManager.OnCircuitsV2LimitsRefreshed, ISIL 060,
|
||||
// :3374). Past 30000 a single chip is unaffordable, and the
|
||||
// cap also bounds the int multiply noted on CloudValues. No
|
||||
// client code divides by it, so 0 is safe.
|
||||
InkPerChip: Math.Clamp(settings.InkPerChip, 0, 30_000),
|
||||
UpdatedAt: DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private static CircuitsV2LimitSettings ToCircuitsV2Limits(ServerSettingsEntity row)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(row.CircuitsV2LimitsJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<CircuitsV2LimitSettings>(
|
||||
row.CircuitsV2LimitsJson,
|
||||
JsonOptions);
|
||||
if (parsed is not null)
|
||||
return NormalizeCircuitsV2Limits(parsed) with { UpdatedAt = row.UpdatedAt };
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Bad settings should not break gameconfig bootstrap.
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultCircuitsV2Limits() with { UpdatedAt = row.UpdatedAt };
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WeeklyChallengeSettings(
|
||||
@@ -1060,38 +848,6 @@ public sealed record GameConfigurationSetting(
|
||||
DateTime? StartTime = null,
|
||||
DateTime? EndTime = null);
|
||||
|
||||
/// <summary>
|
||||
/// One entry of the <c>UGC.CircuitsV2.Limit.Network</c> array. The client
|
||||
/// sorts entries ascending by <see cref="NumPlayersForActivation"/> and
|
||||
/// activates the highest one whose threshold is at or below the room's max
|
||||
/// player capacity, so a single entry with a threshold of 0 applies
|
||||
/// everywhere. Field names are the literal JSON keys the client's
|
||||
/// Newtonsoft reader binds to <c>LimitConfig.NetworkConfig</c>.
|
||||
/// </summary>
|
||||
public sealed record CircuitsV2NetworkLimit(
|
||||
int NumPlayersForActivation,
|
||||
int Bytes,
|
||||
int BytesTimeWindowFrame60s,
|
||||
int Rpcs,
|
||||
int RpcTimeWindowFrame60s);
|
||||
|
||||
/// <summary>
|
||||
/// The <c>UGC.CircuitsV2.Limit.*</c> gameconfig family, read by
|
||||
/// <c>RecRoom.CircuitsV2.Config.LimitConfig</c>. Every field maps to one
|
||||
/// key; the client falls back to its baked-in defaults for any key the
|
||||
/// server omits or sends unparseably.
|
||||
/// </summary>
|
||||
public sealed record CircuitsV2LimitSettings(
|
||||
List<CircuitsV2NetworkLimit> Network,
|
||||
int Compute,
|
||||
int Chips,
|
||||
int CloudValues,
|
||||
float CloudValuesAutosaveSeconds,
|
||||
int UnculledChips,
|
||||
int VisibleChips,
|
||||
int InkPerChip,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
public sealed record DiscoveredGameConfigSettings(
|
||||
int FriendsPostGamePromptUnderFriendCount,
|
||||
int FriendsSuggestFriendCodeOnFriendsScreenCount,
|
||||
|
||||
@@ -179,11 +179,7 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
||||
foreach (var row in stale)
|
||||
{
|
||||
var guid = row.Slug.Substring("wardrobe-".Length);
|
||||
// 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 (!safe.Contains(guid)) toDelete.Add(row.Id);
|
||||
}
|
||||
if (toDelete.Count > 0)
|
||||
{
|
||||
@@ -694,9 +690,6 @@ 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);
|
||||
@@ -1198,32 +1191,6 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
||||
/// id-lists (toptoday) resolve against the gift-drop cache.</summary>
|
||||
public static int PurchasableItemIdFor(StoreItemEntity i) => (int)(i.Id & 0x7fffffff);
|
||||
|
||||
/// <summary>Shop metadata for an avatar item, keyed by the exact
|
||||
/// AvatarItemDesc a shop tile carries.</summary>
|
||||
public sealed record AvatarItemStoreInfo(string FriendlyName, string Tooltip, int Rarity);
|
||||
|
||||
/// <summary>FriendlyName / Tooltip / Rarity of every avatar-item and
|
||||
/// hair-dye shop tile, keyed by the same AvatarItemDesc
|
||||
/// <see cref="BuildGiftDrop"/> sends (ordinal: colour-variant
|
||||
/// combination ids are case-sensitive). The owned-items list uses it so
|
||||
/// an item reads the same in the wardrobe as in the shop.</summary>
|
||||
public static async Task<Dictionary<string, AvatarItemStoreInfo>> LoadAvatarItemStoreInfoAsync(DorkNetDbContext db)
|
||||
{
|
||||
var rows = await db.StoreItems.AsNoTracking()
|
||||
.Select(i => new { i.Slug, i.DisplayName, i.Description, i.Category })
|
||||
.ToListAsync();
|
||||
var info = new Dictionary<string, AvatarItemStoreInfo>(StringComparer.Ordinal);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
string desc;
|
||||
if (TryGetHairDyePayload(row.Slug, out _, out var hairColorGuid)) desc = hairColorGuid;
|
||||
else if (!TryGetAvatarItemPayload(row.Slug, out _, out desc)) continue;
|
||||
if (string.IsNullOrEmpty(desc)) continue;
|
||||
info.TryAdd(desc, new AvatarItemStoreInfo(row.DisplayName ?? string.Empty, row.Description ?? string.Empty, MapRarity(row.Category)));
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>GiftManager.GiftRarity wire values:
|
||||
/// None=-1, Common=0, Uncommon=10, Rare=20, Epic=30, Legendary=50.</summary>
|
||||
private static int MapRarity(string? category) => category?.ToLowerInvariant() switch
|
||||
|
||||
@@ -28,8 +28,6 @@ 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;
|
||||
@@ -41,8 +39,6 @@ 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
|
||||
|
||||
@@ -536,8 +536,6 @@ public static class DatabaseBootstrap
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""BaseRoomNamesJson"" text NOT NULL DEFAULT '';");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""RoomBlobVersionClampDisabled"" boolean NOT NULL DEFAULT false;");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN IF NOT EXISTS ""CircuitsV2LimitsJson"" text NOT NULL DEFAULT '';");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -648,18 +646,6 @@ public static class DatabaseBootstrap
|
||||
// ignore the duplicate-column error; any other schema problem
|
||||
// will surface when EF reads ServerSettings.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
@"ALTER TABLE ""ServerSettings"" ADD COLUMN ""CircuitsV2LimitsJson"" TEXT NOT NULL DEFAULT '';");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// SQLite has no ADD COLUMN IF NOT EXISTS. If it already exists,
|
||||
// ignore the duplicate-column error; any other schema problem
|
||||
// will surface when EF reads ServerSettings.
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureInventorySchemaAsync(DorkNetDbContext db)
|
||||
@@ -869,11 +855,6 @@ public static class DatabaseBootstrap
|
||||
@"ALTER TABLE ""Clubs"" ADD COLUMN IF NOT EXISTS ""ClubChatEnabled"" boolean NOT NULL DEFAULT true;",
|
||||
@"ALTER TABLE ""ChatMessages"" ADD COLUMN IF NOT EXISTS ""ModerationState"" integer NOT NULL DEFAULT 0;",
|
||||
@"ALTER TABLE ""TestCases"" ADD COLUMN IF NOT EXISTS ""CommentsJson"" text NOT NULL DEFAULT '[]';",
|
||||
// 2023 profile edits (PUT account/me/{emoji,bannerimage,personalpronouns,identityflags}).
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""DisplayEmoji"" character varying(32) NOT NULL DEFAULT '';",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""BannerImageName"" character varying(256) NULL;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""PersonalPronouns"" integer NOT NULL DEFAULT 0;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN IF NOT EXISTS ""IdentityFlags"" integer NOT NULL DEFAULT 0;",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "ClubRolePermissions" (
|
||||
"Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
@@ -930,10 +911,6 @@ public static class DatabaseBootstrap
|
||||
// valid JSON and made the comment reader throw. Idempotent: ''
|
||||
// is never a legitimate value for this column.
|
||||
@"UPDATE ""TestCases"" SET ""CommentsJson"" = '[]' WHERE ""CommentsJson"" = '';",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""DisplayEmoji"" TEXT NOT NULL DEFAULT '';",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""BannerImageName"" TEXT NULL;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""PersonalPronouns"" INTEGER NOT NULL DEFAULT 0;",
|
||||
@"ALTER TABLE ""Players"" ADD COLUMN ""IdentityFlags"" INTEGER NOT NULL DEFAULT 0;",
|
||||
};
|
||||
foreach (var sql in sqlite)
|
||||
{
|
||||
|
||||
@@ -234,8 +234,6 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<PlayerPresenceService>();
|
||||
services.AddSingleton<JoinTimeoutService>();
|
||||
services.AddSingleton<OnlinePresenceService>();
|
||||
// Friend presence fan-out; resolves its scoped DB per push.
|
||||
services.AddSingleton<PresenceBroadcastService>();
|
||||
services.AddSingleton<PlayerLogService>();
|
||||
// S3-compatible object storage (Garage in production, MinIO/disk in dev)
|
||||
// — holds profile images + room-blob bytes. Stateless wrapper around
|
||||
@@ -416,18 +414,15 @@ public static class ServiceCollectionExtensions
|
||||
kopts.Limits.MinResponseDataRate = null;
|
||||
});
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
var signalR = builder.Services.AddSignalR(opts =>
|
||||
{
|
||||
opts.KeepAliveInterval = TimeSpan.FromSeconds(10);
|
||||
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(180);
|
||||
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
|
||||
opts.HandshakeTimeout = TimeSpan.FromSeconds(15);
|
||||
// Allow large gift / community-board payloads (default 32 KB
|
||||
// truncates anything bigger and disconnects the client).
|
||||
|
||||
+481
-568
@@ -9,23 +9,20 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router": "^8.3.0"
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.25",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.0"
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -42,13 +39,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -57,9 +54,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
|
||||
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -67,21 +64,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -98,14 +95,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
|
||||
"integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@babel/types": "^7.29.8",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -115,14 +112,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
@@ -132,9 +129,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -142,29 +139,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -174,9 +171,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-plugin-utils": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
|
||||
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
||||
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -184,9 +181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -194,9 +191,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -204,9 +201,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -214,27 +211,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.8"
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -244,13 +241,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
|
||||
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
||||
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -260,13 +257,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
|
||||
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
|
||||
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -276,33 +273,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
|
||||
"integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.8",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.8",
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -310,23 +307,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -337,13 +334,13 @@
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -354,13 +351,13 @@
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -371,13 +368,13 @@
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -388,13 +385,13 @@
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -405,13 +402,13 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -422,13 +419,13 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -439,13 +436,13 @@
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -456,13 +453,13 @@
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -473,13 +470,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -490,13 +487,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -507,13 +504,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -524,13 +521,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -541,13 +538,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -558,13 +555,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -575,13 +572,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -592,13 +589,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -609,30 +606,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -643,30 +623,13 @@
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -677,30 +640,13 @@
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -711,13 +657,13 @@
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -728,13 +674,13 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -745,13 +691,13 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -762,7 +708,7 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
@@ -815,26 +761,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
"integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -873,17 +799,26 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
|
||||
"integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
|
||||
"integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -895,9 +830,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
|
||||
"integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -909,9 +844,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
|
||||
"integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -923,9 +858,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
|
||||
"integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
|
||||
"integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -937,9 +872,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
|
||||
"integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -951,9 +886,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
|
||||
"integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
|
||||
"integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -965,9 +900,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
|
||||
"integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
|
||||
"integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -982,9 +917,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
|
||||
"integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
|
||||
"integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -999,9 +934,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
|
||||
"integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1016,9 +951,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
|
||||
"integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1033,9 +968,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
|
||||
"integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1050,9 +985,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
|
||||
"integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1067,9 +1002,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
|
||||
"integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1084,9 +1019,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
|
||||
"integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1101,9 +1036,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
|
||||
"integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1118,9 +1053,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
|
||||
"integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1135,9 +1070,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
|
||||
"integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1152,9 +1087,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
|
||||
"integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1169,9 +1104,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
|
||||
"integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1186,9 +1121,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
|
||||
"integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
|
||||
"integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1200,9 +1135,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
|
||||
"integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1214,9 +1149,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
|
||||
"integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
|
||||
"integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1228,9 +1163,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
|
||||
"integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
|
||||
"integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1242,9 +1177,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
|
||||
"integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1256,9 +1191,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
|
||||
"integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
|
||||
"integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1315,67 +1250,75 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||
"integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.3",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.18.0"
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zip.js/zip.js": {
|
||||
"version": "2.8.34",
|
||||
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.34.tgz",
|
||||
"integrity": "sha512-+6a3lyqq69rpseLbvDPiVIWsZ/HdTGAAD6afFtug6ECPDGttb2dHnPC6cJgdPofYkzL9OvXizegq+DQVfL2rnA==",
|
||||
"version": "2.8.26",
|
||||
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz",
|
||||
"integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"bun": ">=0.7.0",
|
||||
@@ -1412,9 +1355,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.4",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz",
|
||||
"integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
||||
"integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1432,8 +1375,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.6",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"browserslist": "^4.28.2",
|
||||
"caniuse-lite": "^1.0.30001787",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
@@ -1449,9 +1392,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.10",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz",
|
||||
"integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==",
|
||||
"version": "2.10.29",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
|
||||
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1488,9 +1431,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.7",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1508,10 +1451,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"electron-to-chromium": "^1.5.393",
|
||||
"node-releases": "^2.0.51",
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
"electron-to-chromium": "^1.5.328",
|
||||
"node-releases": "^2.0.36",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1532,9 +1475,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"version": "1.0.30001792",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
|
||||
"integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1607,12 +1550,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-es": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
|
||||
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -1666,9 +1603,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.399",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz",
|
||||
"integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==",
|
||||
"version": "1.5.356",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
|
||||
"integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -1683,9 +1620,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1693,35 +1630,32 @@
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
@@ -1850,9 +1784,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1938,7 +1872,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
@@ -1987,6 +1920,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -2041,9 +1986,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2060,14 +2005,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||
"version": "2.0.44",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
|
||||
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
@@ -2147,9 +2089,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2167,7 +2109,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -2289,9 +2231,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
|
||||
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2331,30 +2273,34 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.8"
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
"integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2362,24 +2308,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz",
|
||||
"integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==",
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-es": "^3.1.1"
|
||||
"@remix-run/router": "1.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.0"
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19.2.7",
|
||||
"react-dom": ">=19.2.7"
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
@@ -2439,13 +2396,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
|
||||
"integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
@@ -2455,32 +2412,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.62.4",
|
||||
"@rollup/rollup-android-arm64": "4.62.4",
|
||||
"@rollup/rollup-darwin-arm64": "4.62.4",
|
||||
"@rollup/rollup-darwin-x64": "4.62.4",
|
||||
"@rollup/rollup-freebsd-arm64": "4.62.4",
|
||||
"@rollup/rollup-freebsd-x64": "4.62.4",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.62.4",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.62.4",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.62.4",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.62.4",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.62.4",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.62.4",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.62.4",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.62.4",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.62.4",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.62.4",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.62.4",
|
||||
"@rollup/rollup-linux-x64-musl": "4.62.4",
|
||||
"@rollup/rollup-openbsd-x64": "4.62.4",
|
||||
"@rollup/rollup-openharmony-arm64": "4.62.4",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.62.4",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.62.4",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.62.4",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.62.4",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.4",
|
||||
"@rollup/rollup-android-arm64": "4.60.4",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.4",
|
||||
"@rollup/rollup-darwin-x64": "4.60.4",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.4",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.4",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.4",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.4",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.4",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.4",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.4",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.4",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.4",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -2509,10 +2465,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
@@ -2632,9 +2591,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2667,9 +2626,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2759,24 +2718,21 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.43.0",
|
||||
"tinyglobby": "^0.2.15"
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
"rollup": "^4.20.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
@@ -2785,25 +2741,19 @@
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -2824,46 +2774,9 @@
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.22.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
@@ -14,19 +11,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router": "^8.3.0"
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.25",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^7.3.6"
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Layout } from './components/Layout';
|
||||
import { RequireAuth } from './components/RequireAuth';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { LogOut, Menu } from './Icons';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getToken, subscribeAuth } from '../lib/auth';
|
||||
|
||||
// Gate every authenticated route. We re-render on any auth state
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Users, Building2, Activity,
|
||||
Megaphone, MessageSquare, Settings, X,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user