Compare commits

..

35 Commits

Author SHA1 Message Date
Alexa e1fc786ac1 docs: add .backups to .gitignore 2026-06-16 00:35:46 +03:00
Alexa 8f7fc09266 docs(faq): explain why no antivirus exceptions are needed
Add an FAQ entry contrasting DorkNet with closed-source private-server
tools that ask users to disable antivirus: DorkNet is fully open source
and nothing is obfuscated, so there's nothing hidden for a scanner to flag
and no reason to weaken protection to run it.
2026-06-16 00:31:00 +03:00
Alexa e3fb9477b1 docs: add intro, mark VR/mobile supported, drop Junior mention
Add a short plain-language intro at the top of the README explaining what
DorkNet is. Mark VR and Mobile as supported in the feature table, and
remove the Junior accounts row.
2026-06-16 00:27:24 +03:00
Alexa d1b75aba57 Merge pull request #7 from DorkSquadRR/docs-main-refresh
Refresh main docs
2026-06-16 00:03:10 +03:00
Alexa 8986b1301e Refresh main branch documentation 2026-06-15 23:58:36 +03:00
Alexa a9e08dbe0c docs: update FAQ with details on Quest standalone support and patching process 2026-06-09 00:16:32 +03:00
Alexa 3098593de5 Ignore local tool settings 2026-06-01 20:55:36 +03:00
Alexa 0a76a4ec13 launcher: EAC-aware exe pick + optional TLS trust bypass
Launch RecRoom.exe directly on 2020.12 EAC builds (skip the
Recroom_Release.exe EAC wrapper); fall back to whichever single
binary exists. Add an opt-in "trust self-signed TLS certs" toggle
that flows through ClientPatcher into the ClientMod config, off by
default since Localtunnel/Tunnelto serve valid certs.
2026-05-31 12:32:29 +03:00
Alexa 43b27e674a advanced-setup: patch client via MelonLoader, not BepInEx
The BepInEx client patcher is gone; document the MelonLoader installer
(install-melon.ps1 on the version branch) dropping DorkNet.ClientMod
into Mods/ instead of a BepInEx plugin into BepInEx/plugins/.
2026-05-30 02:13:35 +03:00
Alexa f5ad4b59ef Localtunnel hosting + admin panel + AppData db
- Default hosting mode is now Localtunnel — public *.loca.lt URL,
  no account, auto-fetched on first run. Replaces the prototyped
  Tailscale mode.
- Tailscale code purged from launcher + CLI; docs swapped to
  Localtunnel throughout (easy-setup, joining-a-server, server-cli,
  troubleshooting, faq, READMEs).
- Launcher sidebar gains an Open admin panel button — opens
  https://admin.<apex> in the browser once the server is live.
- SQLite db moves to %APPDATA%\DorkNet\dorknet.db (server reads
  Database__SqlitePath env var, launcher passes it). Existing dbs
  under <serverDir>\data\ are migrated on first run.
- CLI Program.cs: --mode default flips from tailscale to localtunnel;
  csproj links in LocaltunnelTunnel + LocaltunnelInstaller.
- New Backend files: LauncherNameServer, LocaltunnelInstaller,
  LocaltunnelTunnel, SingleOriginServiceMap.
- .gitignore: exclude installer/out/.
2026-05-28 01:39:38 +03:00
Alexa 7852b410ee Add installer updater and server CLI 2026-05-27 20:54:53 +03:00
Alexa 4f44505759 Merge branch 'main' of https://github.com/DorkSquadRR/DorkNet 2026-05-27 18:52:38 +03:00
Alexa 25662a864e Add sanitized README screenshots 2026-05-27 18:52:27 +03:00
Alexa b6a0990afe Add Tunnelto hosting support 2026-05-27 18:26:39 +03:00
Alexa 71ea59db00 Update README.md 2026-05-27 17:49:09 +03:00
Alexa f9a40ba788 patcher: install mod to <root>/Mods, not <root>/MelonLoader/Mods
MelonLoader 0.6.x scans <install root>\Mods\ for plugin DLLs and
ignores anything under MelonLoader\Mods\. Our default plugin_dest
was MelonLoader/Mods, so DorkNet.ClientMod.dll landed somewhere
MelonLoader never looked at — Player.log showed '0 Mods loaded.'
and Rec Room booted unpatched, hitting the real rec.net version
check which returns UpdateRequired.

Changed ClientPatcher's default + the manifest doc to 'Mods'.
Per-version branches also need 'plugin_dest' = 'Mods' in their
build-patcher-zip.ps1 manifest (separate commits).
2026-05-27 13:13:20 +03:00
Alexa c366b6190a launcher: ClientPatcher handles steam_stub manifest block
Per-version patcher zips now ship a Goldberg steam_api64.dll +
steam_appid.txt alongside the existing MelonLoader stuff. The
manifest's new steam_stub block describes where to install them:

- api_dll / api_dest: stub DLL location and target path under
  Rec Room root (typically Recroom_Release_Data/Plugins/...)
- api_backup_suffix: the original Valve DLL gets renamed to
  <dest> + suffix on first install; re-runs detect the existing
  backup and leave it alone so the real DLL is preserved
- appid_file / appid_value + appid_dest: writes a steam_appid.txt
  next to the exe so the stub knows what game ID to claim

Block is optional — manifests without steam_stub patch nothing
extra, preserving back-compat with pre-stub patcher zips.
2026-05-27 13:04:08 +03:00
Alexa 26786527cd launcher: auto-strip SteamStub DRM before patching
Steam-purchased Rec Room ships with SteamStub Variant 3.1 wrapping
Recroom_Release.exe so it refuses to launch without Steam loaded.
DorkNet spawns the exe directly, so for Steam installs we have to
unwrap it first or the user gets a silent crash on Launch.

- SteamDrmStripper.StripIfNeededAsync: PE-walks the exe's section
  table looking for the .bind section SteamStub adds, and only
  invokes Steamless when that section is present. Re-runs detect
  the already-unwrapped exe and short-circuit. Backs up the
  original next to the exe as Recroom_Release.exe.steamwrapped.
- SteamlessInstaller: resolves a usable Steamless.CLI.exe via
  side-by-side -> %LOCALAPPDATA%\DorkNet\Steamless\ cache ->
  download v3.1.0.5 from atom0s/Steamless GitHub releases. Same
  pattern as CloudflaredInstaller.
- Inserted as a step between "Download client patcher" and "Patch
  your Rec Room install" in both host (5 -> 6 steps) and join
  (2 -> 3 steps). Step detail reports Stripped / AlreadyUnpacked /
  NotApplicable so the user sees what happened.

Oculus / Quest builds and already-unwrapped installs skip the
Steamless download entirely.
2026-05-27 12:10:27 +03:00
Alexa de98ea694f launcher: side-by-side versions.json fallback + empty-state UX
The version dropdown sat silently empty when the GitHub manifest
fetch 404'd (private repo, network down, etc.) and there was no
DORKNET_LOCAL_MANIFEST to fall back to. Two fixes:

- csproj copies the repo-root versions.json next to dorknet.exe on
  build, and LocalOverrides.GetLocalManifestPath checks that path
  as a fallback after the env var. Dev checkouts against a private
  repo now Just Work — no env vars needed.
- When the manifest can't be loaded, RefreshManifestAsync now
  inserts a disabled placeholder "(no versions - check internet or
  set DORKNET_LOCAL_MANIFEST)" in the dropdown so the user sees the
  problem at a glance instead of a silent blank.
- LocalOverrides.DescribeManifestSource shows in Settings whether
  the active manifest came from the env var, the side-by-side
  fallback, or the live GitHub fetch.
2026-05-27 12:05:21 +03:00
Alexa a5f1d74a8e launcher: surface env vars even when path is missing on disk
Previously DescribeActive() only returned overrides when the path
resolved, so DORKNET_LOCAL_RELEASES set to a typo'd or unmounted
path would silently fall through to the GitHub fetch and the
Settings panel would stay hidden. Now the panel shows the env
var value with a [SET BUT NOT FOUND on disk] tag when the path
isn't there, so the user can debug 'I set it but it didn't work'
without reading the source.
2026-05-27 11:53:40 +03:00
Alexa 7d1ac82cf8 launcher: DORKNET_LOCAL_* env-var overrides for dev testing
Set DORKNET_LOCAL_MANIFEST and/or DORKNET_LOCAL_RELEASES to point
the launcher at on-disk artifacts instead of the public repo:

- DORKNET_LOCAL_MANIFEST: full path to a versions.json file.
  VersionsManifest.FetchAsync reads it from disk and skips the
  GitHub round-trip.
- DORKNET_LOCAL_RELEASES: path to a directory laid out as
  {root}/{version_key}/server/ + .../patcher/. EnsureServerAsync /
  EnsurePatcherAsync return those paths in-place — no download,
  no copy, no marker files.

Both are silent no-ops when unset / missing on disk, so it's safe
to leave them set permanently in a dev profile. When at least one
is active, the Settings view surfaces a "Dev overrides active"
panel listing the paths so it's obvious the launcher isn't using
the public manifest.

Lets contributors iterate on per-version branch changes against
the launcher UI without needing to tag a public Release.
2026-05-27 11:45:08 +03:00
Alexa 5452ff06ee launcher: welcome screen + guided setup wizard
First launch went straight to a Host/Join card picker with no
framing. Replaced with a two-phase onboarding:

WelcomeView — branded intro with the wordmark, three feature
bullets (2 min setup, free, works with regular Rec Room), and one
big "Get started" button. Dismissed via AppState.WelcomeSeen.

SetupView — multi-step wizard with a STEP X OF N indicator:
1. Host vs Join card picker (self-advances on click)
2. Rec Room install (shows auto-detected path with a green "we
   found it" banner; Pick... overrides)
3. (Host only) Server name + WHO CAN JOIN radios
4. (Host only) Photon AppIds — "Open setup walkthrough" launches
   the existing PhotonWizard, or paste fields below for advanced
   users; live status line confirms when AppIds are set
5. Done card with Finish button

Join mode hops 2 -> done since it doesn't need server config or
Photon. AppState.SetupComplete gates entry; future launches skip
the wizard and route directly to HostView / JoinView. Existing
state files get auto-migrated on load (Mode set + RecRoomPath
populated = WelcomeSeen + SetupComplete backfilled to true, then
flushed). Settings -> "Re-run setup" clears the flag for users
who want to walk through again.
2026-05-27 11:25:28 +03:00
Alexa 5b98886937 launcher: friendly errors + Retry / Get help buttons
Raw exception messages ("Access is denied", HttpRequestException
stack traces) gave the user nothing to do. The flow now translates
the common failures into plain language and offers a one-click
retry that re-runs the host/join flow from the top:

- ErrorTranslator handles HttpRequestException (network down),
  TaskCanceledException (timeout), SocketException (firewall),
  UnauthorizedAccessException + IOException "in use" (Rec Room
  open), missing folder / files (re-pick install), cloudflared
  download truncation (suggests LAN mode), port 5005 already bound
- Patcher log output goes through TranslateMessage which catches
  the same "access denied" / missing files cases
- HostStepsPanel / JoinStepsPanel gain a Retry + "Get help" row
  below the steps; visible only on failure
- "Get help" opens README.md#troubleshooting on GitHub
- Stop / new run hides the retry row so a fresh start is clean
2026-05-27 11:17:39 +03:00
Alexa 7c974e39ab launcher: Photon AppId setup walkthrough
The two empty "REALTIME APPID / VOICE APPID" textboxes assumed the
user already knew what Photon was, where to sign up, and which app
type to create. New 5-step wizard fills that gap:

1. What's Photon? — one-paragraph explanation tying back to DorkNet
2. Make an account — Open Dashboard button launches browser
3. Create a Realtime app — numbered click-by-click instructions
4. Create a Voice app — same, with a Skip button (voice is optional)
5. Paste your AppIds — text fields with live regex validation,
   region picker, Save commits to AppState

Hyphens optional on input; we normalise to canonical 8-4-4-4-12 form
before writing back. The existing Photon section keeps its three
fields for advanced users, with a "Setup walkthrough" button at the
top opening the wizard prefilled with current values.
2026-05-27 11:14:23 +03:00
Alexa 3aa0b55512 launcher: QR code + share buttons on host ready panel
The single Copy button left it on the user to format a message and
get the code into Discord/email. Added a layout that makes sharing
obvious:

- QR encodes the join code text so a friend with a phone can scan
  the host's screen and forward the code without typing it
- "Copy as message" formats a one-line "Join {server} on DorkNet!
  Download at github.com/... and paste this code:" pitch
- "Email to friend" opens the default mail client with subject +
  body prefilled, code in the body
- QR built via QRCoder -> WriteableBitmap directly (no
  System.Drawing dependency); palette matches the launcher
2026-05-27 11:11:31 +03:00
Alexa 3039bebc6c launcher: step-by-step progress UI replaces single-line status
The single HostStatusText/JoinStatusText line gave no sense of where
the user was in the flow. Replaced with a step list:

- StartupStep model (INotifyPropertyChanged) tracks Title, State
  (Pending/Active/Done/Failed), Detail, optional Progress 0..1
- ItemsControl + DataTemplate in App.xaml renders each step as a
  status bullet (empty ring -> filled brand -> green check -> red X),
  with conditional detail line + restyled mini progress bar
- StartupFlow.NewHostFlow / NewJoinFlow build the canonical 5-step
  host and 2-step join lists; host flow swaps the tunnel step label
  for LocalNetwork mode
- OnHostStart / OnJoinApply advance steps via StartStep/CompleteStep/
  FailStep helpers and route progress callbacks to UpdateStepProgress
  so each download phase has its own bar
- Errors mark the active step Failed with the message inline, leaving
  the rest of the list visible so the user sees where it broke
2026-05-27 11:09:20 +03:00
Alexa 65ca509fa8 launcher: grandma-proof batch 1 — auto-detect + LAN mode
- RecRoomPicker.Detect: scan common manual-install paths (Documents/Recnet/dist, Downloads, C:\Games, etc.) on first run; never touches Steam libraries per policy
- RecRoomVersionDetector: three-signal cascade (version.txt -> PE timestamp -> file size) maps install to a manifest branch so version dropdown defaults correctly
- CloudflaredInstaller: download latest cloudflared-windows-amd64.exe from GitHub releases to %LOCALAPPDATA%\DorkNet on first host; resolves side-by-side -> cache -> PATH -> download
- LocalNetwork.GetLanIp + AppState.HostingMode (Internet|LocalNetwork) + Host view "WHO CAN JOIN?" radio: LAN-only mode skips Cloudflare entirely for families on one network
- OnHostStart branches on HostingMode; status text reflects local IP vs public tunnel URL
2026-05-27 11:03:00 +03:00
Alexa a5ae5da344 launcher: pass PhotonCloudRegion to patcher template
DorkNet.ClientMod's config reads PhotonCloudRegion alongside the
AppIds, and the joining client needs the host's region to connect
to the right Photon cloud. Adds {PHOTON_REGION} to the substitution
map and threads region through ClientPatcher.ApplyAsync from both
host (AppState.PhotonRegion) and join (JoinPayload.PhotonRegion)
flows.
2026-05-27 01:02:25 +03:00
Alexa b155ae5dbe launcher: Rec-Room-styled UI; PowerShell-free patcher; Launch button
UI redesign:
- Warm coral + teal palette over deep purple-navy (closer to Rec Room's
  marketing aesthetic). New wordmark gradient (coral -> pink -> teal).
- Chunky 18px rounded cards on the first-run wizard with per-mode
  accent tinting (coral for host, teal for join) and clear CTAs.
- Form fields wrapped in elevated panels with sectional titles + pill
  status chips in the header (HOST MODE / JOIN MODE / SETTINGS).
- Drop-shadow effects on primary buttons + cards for depth.

Patcher rewrite:
- ClientPatcher.cs no longer shells out to PowerShell; the launcher
  unzips MelonLoader, copies the DorkNet plugin DLL, and renders the
  user-data config template — all in C# in-process.
- Patcher zip now driven by a manifest.json at root (schema v1).
  Per-version branches publish: manifest.json + MelonLoader.zip +
  DorkNet.ClientMod.dll + config template.
- RELEASES.md updated with the new MelonLoader manifest contract and
  the placeholder substitution rules.

Rec Room launch path:
- New Backend/RecRoomLauncher.cs runs Recroom_Release.exe directly
  from the picked install folder's parent dir (no Steam).
- 'Launch Rec Room' button appears after a successful patch in both
  host + join modes.
2026-05-27 00:55:22 +03:00
Alexa 446a23da2b launcher: rewrite as native WPF, drop PhotinoNET/WebView2
Initial v0.1 (commit df9dac0) used PhotinoNET wrapping WebView2 + HTML/JS,
but rendered as a blank window on first run — WebView2 + file:// URI
quirks. Switching to native WPF: built into the .NET 9 Windows SDK
(no NuGet), single-file self-contained publish, instant window open.

All Backend/*.cs orchestration logic carried over unchanged (they're
UI-framework-agnostic). Replaced:
- Program.cs (PhotinoNET bootstrap) -> App.xaml(.cs) + MainWindow.xaml(.cs)
- ui/index.html + style.css + app.js -> MainWindow.xaml (XAML views) +
  App.xaml resource styles
- Backend/MessageBridge.cs (JS<->C# router) -> direct method calls in
  MainWindow.xaml.cs code-behind

RecRoomPicker now uses Microsoft.Win32.OpenFolderDialog (added in .NET 8)
instead of WinForms FolderBrowserDialog — keeps the project WPF-only.
Backend files needed explicit 'using System.IO;' since the WPF SDK
doesn't include it in implicit usings.
2026-05-27 00:44:28 +03:00
Alexa df9dac053d launcher: v0.1 — PhotinoNET shell + multi-version orchestration
Single-download .NET 9 self-contained launcher for hosting / joining
DorkNet servers. Reads versions.json from main to discover supported
client builds, fetches the matching server binary + client patcher
from GitHub Releases per branch, manages the server subprocess +
Cloudflare quick-tunnel, and wraps it all in a PhotinoNET window.

Code layout:
- Program.cs           PhotinoNET window + message bridge bootstrap
- Backend/             AppPaths, AppState, VersionsManifest,
                       ReleaseDownloader, ServerProcess, ClientPatcher,
                       Tunnel, RecRoomPicker, JoinCode, MessageBridge
- ui/                  index.html + style.css + app.js (no build step)
- RELEASES.md          GitHub Releases artifact contract per-version
                       branches must publish to

Per-version branches need to ship two release assets per tag:
- dorknet-server-{version_key}-win-x64.zip
- dorknet-clientpatch-{version_key}.zip
(see launcher/RELEASES.md for the full naming + arg contract)

Photon AppIds, JWT secret, and the picked Rec Room path stay
per-machine; the launcher never bundles them.
2026-05-26 20:54:16 +03:00
Alexa ce0714ed1a main: mark march-2020-03-10 active in manifest + chart 2026-05-26 01:19:03 +03:00
Alexa d1cc50622b main: branch-aware README + BRANCHES.md + versions.json
Rewrites main as a docs-only landing branch. Adds:
- BRANCHES.md — per-branch chart (client build, status, notes)
- versions.json — machine-readable manifest the Easy launcher reads
- Branch-picking step + Easy-app/Docker URLs updated in README +
  docs/advanced-setup.md

Drops the per-folder README stubs (server/, admin-ui/, client-mod/,
docker/, easy-app/, tools/) — the actual code lives on per-version
branches now, not on main.
2026-05-26 01:17:14 +03:00
Alexa dc1e5436b5 main: fix 2020.12.18 typo; broaden gitignore (certs + scrub tool) 2026-05-26 01:05:46 +03:00
Alexa bcc5fe1bcb Add initial documentation and structure for DorkNet project
- Create README.md for project overview and setup instructions
- Add admin-ui/README.md for admin UI details and development setup
- Introduce client-mod/README.md explaining the MelonLoader mod functionality
- Establish docker/README.md for Docker setup and configuration
- Create docs/README.md to index project documentation
- Implement advanced-setup.md for Docker stack configuration
- Document architecture in architecture.md
- Provide easy-setup.md for hosting instructions
- Add FAQ.md for common questions and legal considerations
- Create joining-a-server.md for player connection instructions
- Establish troubleshooting.md for common issues and resolutions
- Introduce easy-app/README.md for the unified desktop application
- Add server/README.md for ASP.NET Core backend details
- Document tools/README.md for CLI helper scripts
2026-05-21 13:26:46 +03:00
468 changed files with 10399 additions and 106573 deletions
-72
View File
@@ -1,72 +0,0 @@
# Keep the build context small + reproducible. Anything not strictly
# needed for `dotnet restore && dotnet publish` is excluded.
# Build artefacts
**/bin/
**/obj/
**/*.user
**/*.suo
# Dev-only configs (Local layer must NEVER ship into a container —
# secrets are passed by env vars on Coolify).
**/appsettings.Local.json
**/appsettings.Development.json
# Local runtime data
**/data/
**/server.log
**/*.log
# IDE noise
.vs/
.vscode/
.idea/
# Git
.git/
.gitignore
.gitattributes
# Reverse-engineering scratch (not needed for production server)
Cpp2ILOutput/
Cpp2IL_CS/
Cpp2IL_ISIL/
Il2CppDump/
RR_protos/
RR_ProtobufDefinitions.zip
GameAssembly.dll
global-metadata.dat
il2cpp.h
dump.cs
script.json
RoomPersistenceManager_decompiled.txt
# Local TLS certs (mkcert) — never copy into the image
*.pem
hosts.dorknet.txt
# Tools that aren't part of the server runtime
tools/
Tools/
# Vite SPA build leftovers — node_modules is huge and the dist is
# rebuilt fresh by the npm-build stage in the Dockerfile, so neither
# should ship into the docker context. The source under admin-ui/src
# and admin-ui/{package.json,vite.config.ts,...} is what we copy in.
DorkNet.Server/admin-ui/node_modules/
DorkNet.Server/admin-ui/dist/
DorkNet.Server/admin-ui/.vite/
DorkNet.Server/wwwroot/admin/assets/
DorkNet.Server/wwwroot/admin/index.html
DorkNet.Server/wwwroot/admin/*.map
# Top-level docs / planning
README.md
LICENSE
NOTICE
BUILD_PROPER_PLAN.md
nginx.conf
# Other stages of the workflow
*.md
docs/
+82
View File
@@ -0,0 +1,82 @@
# Push / PR CI on `main` — fast feedback that the launcher + CLI
# both still build, and that the CLI publishes for the cross-platform
# RIDs the release workflow will package. Does not produce release
# artifacts — only proves things compile.
#
# Runs on:
# - every push to main
# - every pull request targeting main
# - manual workflow_dispatch (so you can re-run after a flake without
# pushing an empty commit)
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
env:
DOTNET_VERSION: "9.0.x"
DOTNET_NOLOGO: "true"
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
jobs:
# ── Launcher needs Windows for the WPF target framework ────────────
build-launcher:
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore launcher/DorkNet.Launcher.csproj
- name: Build
run: dotnet build launcher/DorkNet.Launcher.csproj -c Release --no-restore
# A self-contained publish is the closest thing to "what we ship"
# without invoking Inno Setup, which would require an extra
# toolchain install for every CI run.
- name: Publish (smoke)
run: dotnet publish launcher/DorkNet.Launcher.csproj -c Release -r win-x64 --self-contained true --no-restore
# ── CLI builds on Ubuntu — proves the shared Backend/*.cs files
# truly don't have Windows-only dependencies. The release pipeline
# later publishes the matrix; here we just confirm restore + build
# on the canonical Linux runner.
build-cli:
strategy:
fail-fast: false
matrix:
rid: [win-x64, linux-x64, linux-arm64, osx-x64, osx-arm64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj
- name: Publish ${{ matrix.rid }}
run: |
dotnet publish cli/DorkNet.ServerCli/DorkNet.ServerCli.csproj \
-c Release \
-r ${{ matrix.rid }} \
--self-contained true \
--no-restore
- name: Smoke-test --help (host RID only)
if: matrix.rid == 'linux-x64'
run: |
out=cli/DorkNet.ServerCli/bin/Release/net9.0/${{ matrix.rid }}/publish/dorknet-server
chmod +x "$out"
"$out" --help
+221
View File
@@ -0,0 +1,221 @@
# Tagged release pipeline for the host-side tooling on `main` —
# the Windows GUI launcher (installer + portable) and the
# cross-platform dorknet-server CLI. Triggers on a SemVer tag like
# v0.2.0 (or v0.2.0-rc1 for pre-releases).
#
# Produces, on each release:
# * dorknet-setup-<version>.exe Inno Setup installer
# * dorknet.exe Portable single-file Windows build
# * dorknet-server-win-x64.zip Cross-platform server CLI, per RID
# * dorknet-server-linux-x64.tar.gz
# * dorknet-server-linux-arm64.tar.gz
# * dorknet-server-osx-x64.tar.gz
# * dorknet-server-osx-arm64.tar.gz
# * SHA256SUMS Checksums of all of the above
#
# All of these get attached to the GitHub release the tag creates.
name: release-launcher
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
workflow_dispatch:
inputs:
version:
description: "Version to release (e.g. 0.2.0). Leave blank to use the workflow's git tag."
required: false
permissions:
contents: write # required to create / update GitHub releases
env:
DOTNET_VERSION: "9.0.x"
DOTNET_NOLOGO: "true"
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
jobs:
# ── Resolve the version once and pass it to all downstream jobs ─────
resolve-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- id: v
shell: bash
run: |
if [[ -n "${{ inputs.version }}" ]]; then
v="${{ inputs.version }}"
else
v="${GITHUB_REF_NAME#v}"
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "Releasing version: $v"
# ── Windows: GUI launcher (installer + portable) ─────────────────────
build-launcher-windows:
needs: resolve-version
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Install Inno Setup
run: choco install innosetup --no-progress -y
- name: Build installer + portable exe
shell: pwsh
run: |
./installer/build-installer.ps1 -Version "${{ needs.resolve-version.outputs.version }}"
- name: Stage Windows artifacts
shell: pwsh
run: |
$stage = "artifacts"
New-Item -ItemType Directory -Force -Path $stage | Out-Null
# Installer
Copy-Item "installer/out/dorknet-setup-${{ needs.resolve-version.outputs.version }}.exe" $stage
# Portable single-file build (same publish output the installer wraps)
Copy-Item "launcher/bin/Release/net9.0-windows/win-x64/publish/dorknet.exe" $stage
- uses: actions/upload-artifact@v4
with:
name: launcher-windows
path: artifacts/*
# ── Cross-platform server CLI, one RID per matrix entry ─────────────
build-server-cli:
needs: resolve-version
strategy:
fail-fast: false
matrix:
include:
- rid: win-x64
os: windows-2022
ext: zip
- rid: linux-x64
os: ubuntu-latest
ext: tar.gz
- rid: linux-arm64
os: ubuntu-latest
ext: tar.gz
- rid: osx-x64
os: macos-latest
ext: tar.gz
- rid: osx-arm64
os: macos-latest
ext: tar.gz
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Publish dorknet-server for ${{ matrix.rid }}
shell: bash
run: |
v="${{ needs.resolve-version.outputs.version }}"
dotnet publish cli/DorkNet.ServerCli \
-c Release \
-r "${{ matrix.rid }}" \
--self-contained true \
/p:Version="$v" \
/p:AssemblyVersion="${v}.0" \
/p:FileVersion="${v}.0" \
/p:InformationalVersion="$v"
- name: Package
shell: bash
run: |
src="cli/DorkNet.ServerCli/bin/Release/net9.0/${{ matrix.rid }}/publish"
out="artifacts"
mkdir -p "$out"
case "${{ matrix.ext }}" in
zip)
# Use PowerShell on Windows so the archive is consumable
# by Windows Explorer + curl without weird zip flags.
pwsh -Command "Compress-Archive -Path '$src/*' -DestinationPath '$out/dorknet-server-${{ matrix.rid }}.zip' -Force"
;;
tar.gz)
# chmod +x first — single-file publish drops a binary
# without the executable bit on macOS in some scenarios.
chmod +x "$src"/dorknet-server || true
tar -czf "$out/dorknet-server-${{ matrix.rid }}.tar.gz" -C "$src" .
;;
esac
- uses: actions/upload-artifact@v4
with:
name: server-cli-${{ matrix.rid }}
path: artifacts/*
# ── Aggregate, checksum, attach to the release ──────────────────────
release:
needs: [resolve-version, build-launcher-windows, build-server-cli]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: List artifacts + compute SHA256SUMS
run: |
cd dist
ls -la
sha256sum * > SHA256SUMS
cat SHA256SUMS
- name: Build release notes
id: notes
run: |
v="${{ needs.resolve-version.outputs.version }}"
{
echo "## DorkNet launcher \`v$v\`"
echo ""
echo "Host-side release. For the matching server + client patcher per Rec Room build, see the per-version branch tags."
echo ""
echo "### Downloads"
echo ""
echo "| Platform | Use | Asset |"
echo "| --- | --- | --- |"
echo "| Windows | One-click installer (Start menu + auto-updates) | \`dorknet-setup-$v.exe\` |"
echo "| Windows | Portable single-file launcher | \`dorknet.exe\` |"
echo "| Linux x64 | Headless host CLI | \`dorknet-server-linux-x64.tar.gz\` |"
echo "| Linux arm64 | Headless host CLI | \`dorknet-server-linux-arm64.tar.gz\` |"
echo "| macOS Intel | Headless host CLI | \`dorknet-server-osx-x64.tar.gz\` |"
echo "| macOS Apple Silicon | Headless host CLI | \`dorknet-server-osx-arm64.tar.gz\` |"
echo "| Windows | Headless host CLI (same as GUI) | \`dorknet-server-win-x64.zip\` |"
echo ""
echo "Checksums in \`SHA256SUMS\`."
echo ""
echo "### Setup"
echo ""
echo "- Windows GUI: [easy-setup.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/easy-setup.md)"
echo "- Linux / macOS: [server-cli.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/server-cli.md)"
echo "- Photon setup: [photon-setup.md](https://github.com/DorkSquadRR/DorkNet/blob/main/docs/photon-setup.md)"
} > notes.md
- name: Create / update GitHub release
uses: softprops/action-gh-release@v2
with:
# Prerelease if the tag has a -suffix (e.g. v0.2.0-rc1).
prerelease: ${{ contains(github.ref_name, '-') }}
body_path: notes.md
generate_release_notes: false
files: |
dist/*
# The installer registers HKCU\Software\DorkNet\Version on
# install — that means the auto-updater sees this version
# only after the tag actually exists on GitHub, so we let
# the action create the release on first push of the tag.
fail_on_unmatched_files: true
-55
View File
@@ -1,55 +0,0 @@
name: Release (march-2020-03-10)
# Auto-publishes dorknet-server + dorknet-clientpatch zips to a
# GitHub Release on tag push. The launcher (on the public main
# branch) fetches the most-recent tag matching its prefix and
# downloads these assets, so the tag format MUST start with the
# branch's release_tag_prefix declared in versions.json.
on:
push:
tags:
- 'v1-march-*'
jobs:
release:
runs-on: windows-latest
env:
VERSION_KEY: march_2020_03_10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
# ── Server self-contained publish ────────────────────────────────
- name: Publish DorkNet.Server (win-x64, self-contained)
run: |
dotnet publish DorkNet.Server/DorkNet.Server.csproj `
-c Release -r win-x64 --self-contained `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true `
-o publish/server
- name: Zip server publish
run: |
Compress-Archive -Path publish/server/* `
-DestinationPath dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
# ── Client patcher zip (built via tools/build-patcher-zip.ps1) ───
- name: Build client patcher zip
run: |
.\tools\build-patcher-zip.ps1 -VersionKey ${{ env.VERSION_KEY }}
# ── Publish both as Release assets ───────────────────────────────
- name: Upload Release assets
uses: softprops/action-gh-release@v2
with:
files: |
dorknet-server-${{ env.VERSION_KEY }}-win-x64.zip
dist/dorknet-clientpatch-${{ env.VERSION_KEY }}.zip
fail_on_unmatched_files: true
+65 -62
View File
@@ -1,67 +1,70 @@
## .NET
bin/
obj/
build/
*.user
*.suo
# Build outputs
**/bin/
**/obj/
**/node_modules/
**/dist/
**/.vite/
**/build/
local-releases/
**/installer/out/
# IDE
.vs/
.vscode/
*.csproj.bak
dist/
local-releases/
## Data / secrets
# Local SQLite + uploaded blobs only — DON'T match the source-tree
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
# case-insensitive filesystems. Use the precise runtime data dirs.
DorkNet.Server/bin/Debug/net9.0/data/
DorkNet.Server/bin/Release/net9.0/data/
# appsettings.Production.json is intentionally versioned — contains
# only the Postgres provider switch + Kestrel bind address. All real
# secrets come from Coolify env vars at runtime.
tmp/
## OS
*.user
*.suo
*.csproj.user
# Local config (NEVER commit)
.claude/
appsettings.json
appsettings.Local.json
appsettings.Development.json
.env
.env.local
.env.*.local
.backups
# Runtime data
data/*.db
data/*.db-shm
data/*.db-wal
data/*.db.bak.*
data/images/
keys/
**/MelonLoader/UserData/dorknet-clientmod.json
**/MelonLoader/Latest.log
**/MelonLoader/Logs/
**/output_log.txt
**/crash_reports/
# Decompilation dumps — never ship Rec Room IP derivative works
Cpp2IL_ISIL/
Cpp2IL_CS/
**/Recroom_Release_Data/
**/Recroom_Release.exe
**/GameAssembly.dll
**/global-metadata.dat
**/version.dll
# TLS certs / private keys — never ship under any name
*.pem
*.key
*.crt
*.cer
*.pfx
*.p12
*.jks
*.asc
*.gpg
*.kdbx
# Maintainer-side scrub tool — operates ON this repo, must not be IN it
tools/scrub-for-public.ps1
# OS
.DS_Store
Thumbs.db
## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit)
dump.cs
il2cpp.h
script.json
stringliteral.json
Il2CppDump/
Cpp2ILOutput/
Cpp2IL_CS/
Cpp2IL_ISIL/
RoomPersistenceManager_decompiled.txt
## Per-machine editor / agent settings
.claude/
global-metadata.dat
GameAssembly.dll
_wildcard.rec.net+1.pem
_wildcard.rec.net+1-key.pem
dist$ZIPNAME.zip
## Vite admin SPA build output — derived from DorkNet.Server/admin-ui/
## via `npm run build` (local) or the admin-ui Dockerfile stage (deploy).
## Each build emits a new hash (assets/index-<hash>.js) so committing
## these files leaves a stale index.html → JS hash mismatch the next
## time someone rebuilds. Keep the source committed, regenerate output.
DorkNet.Server/wwwroot/admin/index.html
DorkNet.Server/wwwroot/admin/assets/
DorkNet.Server/wwwroot/admin/*.map
DorkNet.Server/admin-ui/node_modules/
DorkNet.Server/admin-ui/dist/
DorkNet.Server/admin-ui/.vite/
DorkNet.Server/admin-ui/tsconfig.tsbuildinfo
## Public-facing site (Vite + React, DorkNet.Server/site/)
## Same reasoning as admin-ui above: hashed output, regenerate per build.
DorkNet.Server/wwwroot/site/index.html
DorkNet.Server/wwwroot/site/assets/
DorkNet.Server/wwwroot/site/*.map
DorkNet.Server/site/node_modules/
DorkNet.Server/site/dist/
DorkNet.Server/site/.vite/
DorkNet.Server/site/tsconfig.tsbuildinfo
local-releases.zip
# Test outputs
TestResults/
*.coverage
+63
View File
@@ -0,0 +1,63 @@
# Branches
DorkNet keeps one long-lived branch for each supported Rec Room client
build. Each branch carries the server and client patcher for that
build's wire protocol. Pick the branch that matches your install.
## Active branches
### `december-2020-12-18`
- **Client build:** Rec Room 2020.12.18
- **Status:** active
- **Notes:**
- Single EF Initial migration; SQLite + Postgres both work out of the
box.
- Includes the chat-threads, clubs, playlists, weekly-challenge, and
room-keys subsystems added late-2020.
- Server's `Versions/Late2020VersionPlugin` is the active version
plugin; client header `X-DorkNet-Version: december_2020_12_18`.
### `march-2020-03-10`
- **Client build:** Rec Room 2020.03.10 (also serves 2020.03.06)
- **Status:** active
- **Notes:**
- Earlier wire shape — no clubs, no playlists, simpler store catalog.
- Diverges from `december-2020-12-18` by ~170 files; cannot be
unified with the plugin abstraction alone.
Setup starts the same way for both branches: pick the branch, then
follow that branch's own deploy notes. See
[docs/advanced-setup.md](docs/advanced-setup.md).
## How to pick
If you don't know which Rec Room build you have, check:
- **Steam**: right-click *Rec Room* → Properties → Local files →
Browse → look at the `Recroom_Release_Data/StreamingAssets/version.txt`
if present, or note the install timestamp.
- **Manual install** (the typical DorkNet setup path): the build is in
the folder name (`RecRoom-2020.12.18`, `RecRoom-2020.03.10`, etc.).
- **Easy launcher**: auto-detects the build and picks the branch.
## Machine-readable manifest
[`versions.json`](versions.json) carries the same information in a
format the Easy launcher and install scripts can read directly. When a
new version branch lands, add it there too.
## Adding a new version branch
1. Land the server code on a new orphan branch named `<month>-<build>`
(e.g. `august-2021-08-04`).
2. Add a row to the active-branches list above.
3. Add the matching entry to `versions.json`.
4. Add a `docs/<month>-setup.md` setup guide.
5. Tag a release on the new branch so the Easy launcher's
release-fetcher picks it up.
Branches are intentionally long-lived — once a build is supported, its
branch keeps getting patches against the same wire protocol. Don't
rebase across branches; cherry-pick when a fix applies to multiple.
+57
View File
@@ -0,0 +1,57 @@
# Contributing to DorkNet
Thanks for your interest. A few ground rules before you open a PR.
## Code style
- **C#**: nullable enabled. Match the file you're editing. No regions, no
multi-paragraph XML docs, no async-without-await.
- **TypeScript / React**: existing patterns — function components, named
exports, tailwind classes, no CSS modules. On a version branch, lint
with `npm run lint` from `DorkNet.Server/admin-ui/`.
- **No AI-co-author trailers in commit messages.** Use a real attribution
line or none at all.
## Branches and commits
- `main` is protected; PR from a feature branch.
- Conventional commits welcome but not required. One-line summary should
describe the *why*, not the *what* — the diff already shows what.
## Scope of contributions we welcome
- Bug fixes against documented behavior
- Feature parity with the 2020 client (anything reverse-engineered cleanly)
- Performance work
- Better docs, screenshots, video walkthroughs
- New translations of the admin UI
## Scope of contributions we don't accept
- **Rec Room game assets, decompiled source, or modified game binaries.**
We never ship Rec Room IP. See [DISCLAIMER.md](DISCLAIMER.md). PRs that
add these files will be closed.
- **Cheats, exploits, or features that would harm other public Rec Room
servers.** This project exists to let people self-host the 2020 backend
for themselves; it isn't a vehicle for griefing live games.
- **Crypto / NFT / monetization plugins.** Hard no.
## Reporting security issues
Don't open a public issue. Email the maintainer at the address in the repo
metadata, or use GitHub's private vulnerability reporting.
## Setting up a dev environment
See [docs/advanced-setup.md](docs/advanced-setup.md) for the full server
stack. The server and admin UI live on the version branches, not on
`main`. For just hacking on the admin UI after you check out a version
branch:
```bash
cd DorkNet.Server/admin-ui
npm install
npm run dev
```
Points at `http://localhost:5000` by default — start the server first.
+51
View File
@@ -0,0 +1,51 @@
# Disclaimer
DorkNet is an independent, fan-made, clean-room reimplementation of
backend protocols used by old **2020 Rec Room** builds. The supported
client builds live on separate branches, currently March 2020 and
December 2020.
It is **not** affiliated with, endorsed by, or sponsored by:
- Rec Room Inc.
- Against Gravity (the original developer)
- Photon (Exit Games GmbH)
- Steam / Valve Corporation
- Meta / Oculus
**Rec Room™**, the Rec Room logo, and all in-game art, audio, models,
locations, characters, and brand assets are the intellectual property of
Rec Room Inc. and are used here only for descriptive interoperability.
## What DorkNet does and doesn't ship
DorkNet's repository contains:
- Original server code (ASP.NET Core)
- Original admin tooling (React)
- Original MelonLoader mod (C#)
- Patcher scripts that operate on the user's own local Rec Room install
DorkNet's repository **does not** contain:
- The Rec Room game executable, assets, or any redistributable Rec Room IP
- Decompiled Rec Room source or IL2CPP dumps
- Patched copies of `Recroom_Release.exe`, `global-metadata.dat`, or any
Rec Room game file
You must acquire the matching 2020 Rec Room client yourself through
whatever means are legal in your jurisdiction, for example SteamDB depot
tools against an account that owns Rec Room. The patcher runs on your
machine, modifies files locally, and never uploads the game anywhere.
## Use at your own risk
This is hobby software. There are no warranties of any kind. If running
DorkNet violates a license you agreed to (e.g. Steam Subscriber Agreement,
Rec Room Terms of Service), that's between you and the rights holder.
DorkNet's authors take no responsibility for consequences arising from
your use of the software.
## Takedown requests
If you represent Rec Room Inc. or another rights holder and wish to discuss
this project, please open an issue on the public repository and we will
respond promptly.
-108
View File
@@ -1,108 +0,0 @@
# DorkNet.Server container image — Coolify deploy target.
# ────────────────────────────────────────────────────────────────────
# Multi-stage: SDK image builds + publishes, runtime image is the
# slimmer aspnet:10.0 with only the published output.
#
# Build context: repo root (so Tools/ and DorkNet.Models/ are visible
# to ProjectReference + dotnet restore). The .dockerignore at the
# repo root keeps the bin/, obj/, and other dev junk out of the
# context.
ARG DOTNET_VERSION=10.0
ARG NODE_VERSION=20
# ── admin SPA build stage ──────────────────────────────────────────
# The Vite + React admin UI under DorkNet.Server/admin-ui builds in a
# dedicated node image because the .NET SDK image doesn't ship npm.
# Output is dropped into /wwwroot/admin and copied into the .NET build
# stage just before `dotnet publish`, which is configured to skip its
# own inline npm step via -p:SkipAdminUIBuild=true.
FROM node:${NODE_VERSION}-alpine AS admin-ui
WORKDIR /ui
# Lockfile first for layer caching: when only application source
# changes, the npm install step stays cached.
COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./
RUN npm install --no-fund --no-audit --loglevel=error
# Now the source. vite.config.ts writes to ../wwwroot/admin/, which
# from /ui is /wwwroot/admin/ — pre-create the parent so Vite's
# emptyOutDir won't refuse to create above its cwd.
COPY DorkNet.Server/admin-ui/ ./
RUN mkdir -p /wwwroot/admin \
&& npm run build \
&& ls -la /wwwroot/admin \
&& ls -la /wwwroot/admin/assets
# ── public-facing site build stage ─────────────────────────────────
# Mirrors the admin-ui stage. Source under DorkNet.Server/site,
# output emitted to /wwwroot/site for the .NET stage to copy.
FROM node:${NODE_VERSION}-alpine AS site
WORKDIR /ui
COPY DorkNet.Server/site/package.json DorkNet.Server/site/package-lock.json* ./
RUN npm install --no-fund --no-audit --loglevel=error
COPY DorkNet.Server/site/ ./
RUN mkdir -p /wwwroot/site \
&& npm run build \
&& ls -la /wwwroot/site
# ── build stage ────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
WORKDIR /src
# Copy csproj files first so `dotnet restore` is cached when only
# source files change. Web SDK pulls in transitive packages from the
# DorkNet.Models project ref, so its csproj needs to be present too.
COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/
COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/
RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj
# Now copy the rest of the .NET source, then drop the prebuilt admin
# SPA into wwwroot/admin so the publish step ships it as static content.
COPY DorkNet.Server/ DorkNet.Server/
COPY DorkNet.Models/ DorkNet.Models/
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
# Publish. SkipAdminUIBuild=true bypasses the csproj target that would
# otherwise try to invoke npm (which this image doesn't have).
RUN dotnet publish DorkNet.Server/DorkNet.Server.csproj \
-c Release \
--no-restore \
-p:SkipAdminUIBuild=true \
-o /app/publish
# ── runtime stage ──────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
WORKDIR /app
# curl + wget for healthcheck probes — Coolify v4 defaults to wget for
# its own probe regardless of what the image's HEALTHCHECK uses, so
# both have to be present. The aspnet:10.0 base image strips both by
# default to keep the surface area small.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl wget \
&& rm -rf /var/lib/apt/lists/*
# Run as a non-root user — aspnet:10.0 ships with the `app` UID 1654.
# Coolify mounts persistent volumes with write permission for this UID.
USER app
COPY --from=build --chown=app:app /app/publish .
# Postgres provider is selected by appsettings.Production.json.
# ASPNETCORE_ENVIRONMENT defaults to Production in the aspnet image
# but we set it explicitly for clarity.
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_URLS=http://0.0.0.0:8080 \
DOTNET_RUNNING_IN_CONTAINER=true \
DOTNET_PRINT_TELEMETRY_MESSAGE=false
EXPOSE 8080
# /healthz returns 503 until the migration + seed pass complete on
# the singleton holding the pg_advisory_xact_lock; other replicas
# block on the lock during the same window. Coolify's default 30s
# probe interval is fine.
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=3 \
CMD curl --fail --silent http://localhost:8080/healthz || exit 1
ENTRYPOINT ["dotnet", "DorkNet.Server.dll"]
@@ -1,67 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>DorkNet.ClientMod</RootNamespace>
<AssemblyName>DorkNet.ClientMod</AssemblyName>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup>
<!--
MelonLoader 0.6.x build of the DorkNet client patches. Resolves
every game-side type at runtime via AccessTools.TypeByName, so
Rec Room's IL2CPP-derived interop assemblies (Assembly-CSharp,
Photon3Unity3D, UnityEngine.*) are NOT needed at compile time —
they would be derivative works of the game anyway, so we never
reference them. Only MelonLoader's own DLLs are needed.
By default the project downloads the pinned MelonLoader release
into DorkNet.ClientMod/build/ and references those DLLs. Pass
-p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
deliberately want to use a local override.
-->
<PropertyGroup>
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
</PropertyGroup>
<Target Name="EnsureMelonLoaderReferences"
BeforeTargets="ResolveAssemblyReferences"
Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
<MakeDir Directories="$(MelonLoaderCacheRoot)" />
<DownloadFile
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
DestinationFolder="$(MelonLoaderCacheRoot)"
DestinationFileName="MelonLoader.x64.$(MelonLoaderVersion).zip"
SkipUnchangedFiles="true" />
<Unzip SourceFiles="$(MelonLoaderZip)"
DestinationFolder="$(MelonLoaderCacheRoot)"
OverwriteReadOnlyFiles="true" />
</Target>
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Runtime">
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Il2CppInterop.Common">
<HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
-1396
View File
@@ -1,1396 +0,0 @@
// DorkNet client mod (MelonLoader IL2CPP). Applies the minimum set of
// Harmony patches needed for the 2020 watch to connect to a DorkNet
// server:
//
// 1. URI rewrite — `.rec.net` → user-configured host
// 2. Photon AppId — override the baked-in Photon Cloud AppId
// 3. AuthValues — inject `userid` + `LoginLock` for /photon/customauth
// 4. TLS trust bypass — make BouncyCastle never reject server certs
//
// Config lives in <MelonLoader>\UserData\dorknet-clientmod.json. The
// install-melon.ps1 wrapper writes that file on install; you can also
// edit it by hand and relaunch the client.
//
// Why string-based AccessTools lookups instead of typeof(Il2Cpp...):
// MelonLoader's Il2CppInterop adds an `Il2Cpp` prefix to game-side
// namespaces (so `RecNet.Core` becomes `Il2CppRecNet.Core`) but the
// exact policy for globally-namespaced types like `PhotonNetwork` and
// for nested types varies across MelonLoader minor versions. Looking
// types up by their original game-side name via AccessTools.TypeByName
// (which searches every loaded assembly) keeps this file working
// regardless of the prefix scheme, and avoids needing a verified
// Il2CppAssemblies directory just to write the source.
using HarmonyLib;
using MelonLoader;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text.Json;
[assembly: MelonInfo(typeof(DorkNet.ClientMod.Mod), "DorkNet ClientMod", "1.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
namespace DorkNet.ClientMod;
public class Mod : MelonMod
{
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
internal static Mod? Instance;
internal static string? DiagnosticsLogPath;
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
private readonly HashSet<string> _diagnosticPatchLabels = new();
private bool _networkPatchesRegistered;
private bool _diagnosticCoreRegistered;
private bool _diagnosticGameComplete;
private int _diagnosticRetryFrame;
private int _photonOverridePollFrame;
// Config backed by a plain JSON file under MelonLoader/UserData.
// The defaults below match the recommended install, so dropping this
// mod in without writing a config file still gives working behaviour.
public static class Cfg
{
public static string ServerHost = "localhost";
public static string SingleOriginBaseUrl = "";
public static string PhotonAppId = "";
public static string PhotonVoiceAppId = "";
// Forced PhotonServerSettings.PreferredRegion (with HostType
// switched to PhotonCloud) so two watches don't auto-ping
// themselves into different "best" regions and end up in
// parallel Photon rooms sharing only a name. PhotonPatches
// .ApplyForcedRegion prefers the server-told region from
// Matchmaking.LocalRoomInstance.PhotonRegionId when available
// and falls back to this config value on initial boot before
// any /goto has returned. Default matches the server's
// appsettings.json Photon:CloudRegion ("eu").
public static string PhotonCloudRegion = "eu";
public static bool EnableTlsTrustBypass = true;
// Photon Custom Auth injector was parked 2026-05-28; see
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole
// (normally dev-gated) and silence CheatManager's tamper detectors
// so movement/time commands (SetTimeScale, Fly, Teleport, …) don't
// drop you to the dorm. Default OFF — purely opt-in. The toggle key
// is a UnityEngine.KeyCode name; BackQuote is the `~` key.
public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote";
}
public override void OnInitializeMelon()
{
Instance = this;
Log.Msg("=== DorkNet ClientMod loading ===");
LoadConfig();
RegisterNetworkPatches();
DiagnosticPatches.Write("[lifecycle] OnInitializeMelon");
}
public override void OnLateInitializeMelon()
{
DiagnosticPatches.Write("[lifecycle] OnLateInitializeMelon");
Log.Msg("=== Registering client patches ===");
RegisterNetworkPatches();
TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
// NetworkingPeer.CallAuthenticate hook (Photon Custom Auth
// injector) parked — see attic/AuthValuesInjector.cs.attic.
TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
"NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
"NotifyServerCertificate",
prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
// Post-save blob-name refresh. The 2020 watch's
// RoomPersistenceManager.RoomDataBlobName is only ever set by
// /goto-driven flows — neither the SubscriptionUpdateRoom push
// nor SubscriptionUpdatePresence pushes the server fires after
// save propagate into this field. So the PostSaveReloading
// sub-state's MasterReloadRoomAsync deserializes the cached
// bytes from the OLD blob name (the one /goto returned at boot),
// wiping any MakerPen object the user just placed even though
// the new blob is already on S3.
//
// Verified live: after a dorm save with object placement, server
// pushes SubscriptionUpdateRoom + PresenceUpdate both carrying
// dataBlob="dorm_p<id>_v(N+1).dat", but the watch's
// set_RoomDataBlobName trace immediately after the save shows
// old="v(N)" new="v(N)" — no propagation, no fresh download,
// no respawn. Leave+return triggers /goto which DOES update
// RoomDataBlobName, so the fix is to do the same write
// ourselves: when entering the SavingRoomSuccess state on the
// RoomPersistenceManager, read the just-pushed
// Rooms.LocalRoomScene.DataBlobName and force-set
// __instance.RoomDataBlobName to it. That fires the real
// setter → OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync
// for the new blob → PostSaveReloading picks up the fresh
// bytes and respawns the MakerPen objects.
TryPatchByName("RecRoom.Persistence.RoomPersistenceManager",
"set_RoomDataBlobName",
args: new[] { typeof(string) },
prefix: nameof(SavePatches.SetRoomDataBlobName_Prefix));
// Intercept the SubscriptionUpdateRoom push BEFORE the watch's
// (cachedRoomId, cachedSubRoomId) early-out gate drops it. We
// stash the just-arrived Scenes[0].DataBlobName into a static
// so the set_RoomDataBlobName hijack can use it — otherwise
// LocalRoomDetails / LocalRoomScene never get the new blob
// name on the second-or-later push for the same (room, sub)
// tuple (= every post-save push in a dorm).
TryPatchByName("RecNet.Rooms",
"OnSubscriptionUpdateRoom",
prefix: nameof(SavePatches.OnSubscriptionUpdateRoom_Prefix));
// Force AccountExtensions.CanLocalPlayerChat to return true. The
// 2020 watch's room-chat send path (PlayerEmotes.SendChatEmote
// Coroutine d__53 offset 040) gates on this — if false, the
// coroutine bails before raising the Photon RPC, so the sender
// doesn't even see their own message. The check ANDs three
// signals: LocalAccount.TreatAsJunior (ObscuredBool at offset
// 0x40), a PlatformManager flag, and a ModerationManager
// singleton field. At least one returns the wrong value against
// our server. Force-allow — single-tenant private server, text
// moderation isn't a concern.
TryPatchByName("RecNet.AccountExtensions",
"CanLocalPlayerChat",
args: Type.EmptyTypes,
postfix: nameof(ChatPatches.CanLocalPlayerChat_Postfix));
// PlayerEmotes.RemoveInvalidCharactersFromMessage walks the chat
// font asset's character table via TMP_FontAsset.HasCharacters
// to strip glyphs the font can't render. On 2020 Rec Room the
// chat font field can be null at the moment this fires (font
// asset bundle hasn't loaded yet?) and HasCharacters NREs deep
// inside Unity's TMP — which propagates out through the chat
// send callback and aborts the RPC. The watch's own ISIL has
// "skipping invalid character check" log paths for missing
// assets but doesn't actually short-circuit on the null field
// we hit. Skip the method entirely; chat lines will just keep
// any glyphs the font can't render (most common: emoji).
TryPatchByName("RecRoom.Players.PlayerEmotes",
"RemoveInvalidCharactersFromMessage",
prefix: nameof(ChatPatches.RemoveInvalidCharactersFromMessage_Prefix));
// Trace hooks so we can see exactly where chat dies. Send path:
// KeyboardInputField.submit → RoomChatMenu.SendEmoteMessage(string)
// → PlayerEmotes.SendChatEmote(msg, true)
// → SendChatEmoteCoroutine d__53 → PurifyString HTTP call
// → b__0 callback (error, cleanVersion) — fires Photon RPC
// AND local-echoes via ProcessNewChatMessageReceived
// → on each receiver (including self): RpcChatEmote(msg) →
// ProcessNewChatMessageReceived → ReceiveRoomChat appends to
// the chat log buffer.
TryPatchByName("RecRoom.Players.PlayerEmotes",
"SendChatEmote",
args: new[] { typeof(string), typeof(bool) },
prefix: nameof(ChatPatches.SendChatEmote_Prefix));
TryPatchByName("RecRoom.Players.PlayerEmotes",
"ProcessNewChatMessageReceived",
args: new[] { typeof(string), typeof(bool) },
prefix: nameof(ChatPatches.ProcessNewChatMessageReceived_Prefix));
TryPatchByName("RecRoom.Players.PlayerEmotes",
"RpcChatEmote",
args: new[] { typeof(string) },
prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
// Debug console: skip CheatManager's tamper detectors so the
// console's movement/time commands don't trip a dorm-drop. The
// console UI toggle itself is driven from OnUpdate (hotkey) and
// resolved lazily — only the anti-cheat skip needs a patch.
if (Cfg.EnableDebugConsole)
{
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
Log.Msg($"[debugconsole] enabled — CheatManager detectors silenced; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
}
RegisterDiagnostics();
Log.Msg("=== Client patches registered ===");
}
private void RegisterNetworkPatches()
{
if (_networkPatchesRegistered) return;
var complete = true;
complete &= TryPatch("Uri ctor (string)", typeof(Uri), "ctor",
new[] { typeof(string) },
prefix: nameof(UriPatches.UriStringCtor_Prefix));
complete &= TryPatch("Uri ctor (string, UriKind)", typeof(Uri), "ctor",
new[] { typeof(string), typeof(UriKind) },
prefix: nameof(UriPatches.UriStringCtor_Prefix));
complete &= TryPatchByName("BestHTTP.HTTPRequest", "Send",
prefix: nameof(UriPatches.HttpRequestSend_Prefix));
complete &= TryPatchHttpManagerStringSendRequestOverloads();
complete &= TryPatchByName("BestHTTP.HTTPManager", "SendRequest",
args: new[] { ResolveType("BestHTTP.HTTPRequest") ?? typeof(object) },
prefix: nameof(UriPatches.HttpManagerSendRequestObject_Prefix));
_networkPatchesRegistered = complete;
}
private bool TryPatchHttpManagerStringSendRequestOverloads()
{
var type = ResolveType("BestHTTP.HTTPManager");
if (type is null)
{
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string): type not found");
return false;
}
var prefix = GetPatchMethod(nameof(UriPatches.HttpManagerSendRequestString_Prefix));
var patched = 0;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.Name != "SendRequest") continue;
var parameters = method.GetParameters();
if (parameters.Length == 0 || parameters[0].ParameterType != typeof(string)) continue;
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefix));
patched++;
}
if (patched == 0)
{
Log.Warning("[patch-miss] BestHTTP.HTTPManager.SendRequest(string)");
return false;
}
Log.Msg($"[patch-ok] BestHTTP.HTTPManager.SendRequest(string) overloads x{patched}");
return true;
}
public override void OnUpdate()
{
// Photon AppId override poller. The harmony hook on
// PhotonNetwork.ConnectUsingSettings registers in IL2CPP but
// doesn't actually fire in this build (verified in the
// MelonLoader log — [patch-ok] appears but [photon-appid]
// never does), so we poll until PhotonServerSettings is loaded
// and write the AppId then. Stops calling once the override
// succeeds. Throttled to every 6th frame (~10 Hz at 60 fps) so
// the failure log doesn't drown the file when PhotonServerSettings
// isn't loaded yet.
if (!PhotonPatches.OverrideApplied && (_photonOverridePollFrame++ % 6) == 0)
PhotonPatches.TryApplyOverride(reason: "update-poll");
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
if (_diagnosticGameComplete) return;
if (_diagnosticRetryFrame++ > 3600) return;
if ((_diagnosticRetryFrame % 60) != 0) return;
var logMisses = (_diagnosticRetryFrame % 300) == 0;
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses);
if (_diagnosticGameComplete)
DiagnosticPatches.Write("[diagnostics] all game-side diagnostic hooks registered");
}
public override void OnApplicationQuit()
{
DiagnosticPatches.Write("[lifecycle] OnApplicationQuit");
}
// ── Config ────────────────────────────────────────────────────────
private static void LoadConfig()
{
try
{
// Compute the UserData path ourselves rather than calling
// MelonLoader.MelonUtils.UserDataDirectory (which is renamed
// to MelonEnvironment.UserDataDirectory in newer 0.6.x builds
// and shifts again in 0.7.x). Anchoring off the mod DLL's
// location keeps this stable across versions: the DLL lives
// in <game>/Mods/, so two dirname()'s reach the game root,
// then MelonLoader/UserData/ is right next to it.
var modDll = Assembly.GetExecutingAssembly().Location;
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
var gameDir = Path.GetDirectoryName(modsDir) ?? string.Empty;
var userData = Path.Combine(gameDir, "MelonLoader", "UserData");
Directory.CreateDirectory(userData);
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
var path = Path.Combine(userData, "dorknet-clientmod.json");
if (!File.Exists(path))
{
Log.Msg($"[config] no file at {path}; using defaults (ServerHost={Cfg.ServerHost})");
return;
}
var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement;
if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
if (r.TryGetProperty("SingleOriginBaseUrl", out v)) Cfg.SingleOriginBaseUrl = (v.GetString() ?? "").TrimEnd('/');
if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
// "InjectAuthValues" key in the template is now ignored —
// see attic/AuthValuesInjector.cs.attic.
Log.Msg($"[config] loaded: ServerHost={Cfg.ServerHost}, SingleOrigin={(string.IsNullOrEmpty(Cfg.SingleOriginBaseUrl) ? "<off>" : Cfg.SingleOriginBaseUrl)}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " +
$"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
}
catch (Exception ex)
{
Log.Warning($"[config] load failed, using defaults: {ex.Message}");
}
}
// ── Patch dispatch helpers ────────────────────────────────────────
private bool TryPatch(string label, Type target, string methodName,
Type[]? args, string? prefix = null, string? postfix = null)
{
try
{
var method = methodName == "ctor"
? (MethodBase)target.GetConstructor(args ?? Type.EmptyTypes)!
: (args is null ? AccessTools.Method(target, methodName)
: AccessTools.Method(target, methodName, args));
if (method is null)
{
Log.Warning($"[patch-miss] {label}");
return false;
}
HarmonyInstance.Patch(method,
prefix: prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix)),
postfix: postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix)));
Log.Msg($"[patch-ok] {label}");
return true;
}
catch (Exception ex)
{
Log.Error($"[patch-fail] {label}: {ex.Message}");
return false;
}
}
// Resolve the game-side type by its ORIGINAL name (no Il2Cpp prefix)
// by walking every loaded assembly. Tries the exact name first, then
// the same name with an `Il2Cpp` prefix, then a substring scan as a
// last resort — covers all the IL2CPP interop naming schemes the user
// might end up with depending on the MelonLoader / Il2CppInterop
// version installed.
private bool TryPatchByName(string typeName, string methodName,
Type[]? args = null, string? prefix = null, string? postfix = null,
bool logMiss = true)
{
var label = FormatPatchLabel(typeName, methodName, args);
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return false;
}
return TryPatch(label, type, methodName, args, prefix, postfix);
}
internal static Type? ResolveType(string name)
{
if (ResolvedTypeCache.TryGetValue(name, out var cached))
return cached;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
var t = asm.GetType(name, throwOnError: false);
if (t is not null) return CacheResolvedType(name, t);
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
if (prefixed is not null) return CacheResolvedType(name, prefixed);
// For namespaced types like "RecNet.Core", also try with
// the Il2Cpp prefix on the namespace (i.e. "Il2CppRecNet.Core").
var dot = name.IndexOf('.');
if (dot > 0)
{
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
if (nsPrefixed is not null) return CacheResolvedType(name, nsPrefixed);
}
}
catch { /* unloaded / dynamic asm — skip */ }
}
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var t in GetLoadableTypes(asm))
{
if (MatchesRequestedType(t, name))
return CacheResolvedType(name, t);
}
}
return null;
}
private static Type CacheResolvedType(string name, Type type)
{
ResolvedTypeCache[name] = type;
return type;
}
private static IEnumerable<Type> GetLoadableTypes(Assembly asm)
{
try
{
return asm.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
var types = new List<Type>();
foreach (var t in ex.Types)
{
if (t is not null) types.Add(t);
}
return types;
}
catch
{
return Array.Empty<Type>();
}
}
private static bool MatchesRequestedType(Type type, string requested)
{
var prefixed = "Il2Cpp" + requested;
if (type.FullName == requested || type.FullName == prefixed) return true;
if (type.Name == requested || type.Name == prefixed) return true;
return type.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
}
private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
{
if (args is null) return $"{typeName}.{methodName}";
var names = new List<string>();
foreach (var arg in args)
names.Add(arg.Name);
return $"{typeName}.{methodName}({string.Join(",", names)})";
}
private MethodInfo GetPatchMethod(string name)
{
// Look in all three patch holder classes — small enough that a
// linear scan is cheaper than per-class lookups.
foreach (var holder in new[] { typeof(UriPatches), typeof(PhotonPatches), typeof(TlsPatches), typeof(DiagnosticPatches), typeof(SavePatches), typeof(ChatPatches), typeof(DebugConsolePatches) })
{
var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m;
}
throw new MissingMethodException($"patch method '{name}' not found on any holder");
}
private void RegisterDiagnostics()
{
Log.Msg("=== Registering diagnostics ===");
if (!_diagnosticCoreRegistered)
{
TryPatchByName("UnityEngine.Application", "Quit", Type.EmptyTypes,
prefix: nameof(DiagnosticPatches.ApplicationQuit_Prefix));
TryPatchByName("UnityEngine.Application", "Quit", new[] { typeof(int) },
prefix: nameof(DiagnosticPatches.ApplicationQuitInt_Prefix));
_diagnosticCoreRegistered = true;
}
_diagnosticGameComplete = RegisterGameDiagnostics(logMisses: true);
Log.Msg("=== Diagnostics registered ===");
}
private bool RegisterGameDiagnostics(bool logMisses)
{
var complete = true;
complete &= TryPatchDiagnosticByName("BootSequence", "RegisterError", new[] { typeof(string) },
prefix: nameof(DiagnosticPatches.BootSequenceRegisterError_Prefix),
logMiss: logMisses);
foreach (var method in new[]
{
"UnityTimeCheat",
"ObscuredTypeCheat",
"DeveloperFlagCheat",
"HeightChangeCheat",
"AdvancedMotionCheat",
"StreamingAssetCheat"
})
{
complete &= TryPatchDiagnosticByName("AnalyticsHelper", method,
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
logMiss: logMisses);
}
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("SteamManager", "Awake",
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PreLoginInitialize",
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
logMiss: logMisses);
complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PostLoginInitialize",
prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
logMiss: logMisses);
return complete;
}
private bool TryPatchDiagnosticByName(string typeName, string methodName,
Type[]? args = null, string? prefix = null, string? postfix = null,
bool logMiss = true)
{
var label = FormatPatchLabel(typeName, methodName, args);
if (_diagnosticPatchLabels.Contains(label))
return true;
if (!TryPatchByName(typeName, methodName, args, prefix, postfix, logMiss))
return false;
_diagnosticPatchLabels.Add(label);
return true;
}
}
// ── Patch holders ─────────────────────────────────────────────────────
// Each holder is a static class with public static Prefix methods that
// Harmony invokes. Kept separate from Mod for readability and so the
// reflection lookup in GetPatchMethod stays simple.
// ── Debug console access ───────────────────────────────────────────────
// The 2020 client ships RecRoom.Debugging.DebugConsole — a real in-game
// dev console (text input + a DebugConsoleCommandConfig command table:
// SetTimeScale, Fly, Teleport, GoToRoom, KillAllEnemies, DebugCompleteDaily,
// …). Retail gates it behind a developer flag. This holder force-toggles
// its UI on a hotkey and (via the OnLateInitializeMelon patch above) skips
// CheatManager's tamper detectors so movement/time commands don't drop you
// to the dorm.
//
// Everything is resolved by reflection at runtime so the SAME code works on
// both client builds despite their differing obfuscation:
// * The show/hide toggle is the one non-compiler-generated private
// instance void(bool) on DebugConsole — ShowInputField on 2020.03,
// MCNBJFECHLJ on 2020.12. We try the readable name, then the obfuscated
// one, then fall back to that structural match.
// * The live component is found via UnityEngine.Object.FindObjectOfType
// (name-stable) rather than the SingletonMonoBehaviour<T> accessor.
// * Execute(string) keeps its readable name on both builds (kept here as
// a scripted-command entry point even though the hotkey drives the UI).
internal static class DebugConsolePatches
{
private static bool _consoleVisible;
private static bool _pollErrorLogged;
private static int _probeFrame;
private static bool _probeDone;
private static bool _inputResolved;
private static MethodInfo? _getKeyDown; // UnityEngine.Input.GetKeyDown(KeyCode)
private static object? _toggleKeyValue; // boxed KeyCode enum value
private static bool _consoleResolved;
private static Type? _consoleType;
private static MethodInfo? _toggleMethod; // ShowInputField(bool) / MCNBJFECHLJ(bool)
private static MethodInfo? _findObjectOfType; // UnityEngine.Object.FindObjectOfType(Type)
// Harmony prefix returning false → skips the original CheatManager
// detector, so a tripped heuristic never runs its punish/drop path.
public static bool SuppressCheatDetected_Prefix() => false;
// Polled every frame from Mod.OnUpdate while EnableDebugConsole is set.
public static void PollToggleKey()
{
try
{
if (!_inputResolved) ResolveInput();
// One-shot probe ~3s in (≈180 frames @60fps): log whether the
// console object actually exists, so we learn that even if the
// hotkey never fires.
if (!_probeDone && _probeFrame++ > 180)
{
_probeDone = true;
ProbeConsole();
}
if (_getKeyDown is null || _toggleKeyValue is null) return;
if (_getKeyDown.Invoke(null, new[] { _toggleKeyValue }) is true)
{
Mod.Log.Msg($"[debugconsole] toggle key '{Mod.Cfg.DebugConsoleToggleKey}' detected");
Toggle();
}
}
catch (Exception ex)
{
if (!_pollErrorLogged) { _pollErrorLogged = true; Mod.Log.Warning($"[debugconsole] poll failed (logged once): {ex}"); }
}
}
// Diagnostic: reports whether a live DebugConsole component exists and
// whether the show/hide method resolved — without needing a keypress.
private static void ProbeConsole()
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] probe: DebugConsole type NOT resolved"); return; }
try
{
var active = _findObjectOfType?.Invoke(null, new object[] { _consoleType });
Mod.Log.Msg($"[debugconsole] probe: type={_consoleType.FullName}, toggleMethod={(_toggleMethod?.Name ?? "<none>")}, activeInstance={(active is null ? "null" : "found")}");
}
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] probe failed: {ex.Message}"); }
}
// Runs DebugConsole.Execute(command) directly — bypasses the UI/gate
// entirely. Handy for scripted one-shots; the hotkey path uses the UI.
public static void ExecuteCommand(string command)
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null) return;
try
{
var execute = AccessTools.Method(_consoleType, "Execute", new[] { typeof(string) });
if (execute is null) { Mod.Log.Warning("[debugconsole] Execute(string) not found"); return; }
execute.Invoke(null, new object[] { command });
Mod.Log.Msg($"[debugconsole] executed: {command}");
}
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] execute '{command}' failed: {ex.Message}"); }
}
private static void ResolveInput()
{
_inputResolved = true;
var keyCodeType = Mod.ResolveType("UnityEngine.KeyCode");
var inputType = Mod.ResolveType("UnityEngine.Input");
if (keyCodeType is null || inputType is null)
{
Mod.Log.Warning("[debugconsole] UnityEngine.Input/KeyCode not found — hotkey disabled");
return;
}
try { _toggleKeyValue = Enum.Parse(keyCodeType, Mod.Cfg.DebugConsoleToggleKey, ignoreCase: true); }
catch
{
Mod.Log.Warning($"[debugconsole] '{Mod.Cfg.DebugConsoleToggleKey}' is not a KeyCode; defaulting to BackQuote");
_toggleKeyValue = Enum.Parse(keyCodeType, "BackQuote");
}
_getKeyDown = AccessTools.Method(inputType, "GetKeyDown", new[] { keyCodeType });
if (_getKeyDown is null) Mod.Log.Warning("[debugconsole] Input.GetKeyDown(KeyCode) not found");
else Mod.Log.Msg($"[debugconsole] hotkey armed: '{Mod.Cfg.DebugConsoleToggleKey}' (poll live)");
}
private static void ResolveConsole()
{
_consoleResolved = true;
_consoleType = Mod.ResolveType("RecRoom.Debugging.DebugConsole");
if (_consoleType is null) { Mod.Log.Warning("[debugconsole] DebugConsole type not found"); return; }
// Prefer the readable 2020.03 name, then the 2020.12 obfuscated
// name, then a structural fallback that fits both builds.
_toggleMethod = AccessTools.Method(_consoleType, "ShowInputField", new[] { typeof(bool) })
?? AccessTools.Method(_consoleType, "MCNBJFECHLJ", new[] { typeof(bool) })
?? FindToggleByShape(_consoleType);
if (_toggleMethod is null) Mod.Log.Warning("[debugconsole] show/hide method not found");
var objType = Mod.ResolveType("UnityEngine.Object");
_findObjectOfType = objType is null ? null
: AccessTools.Method(objType, "FindObjectOfType", new[] { typeof(Type) });
if (_findObjectOfType is null) Mod.Log.Warning("[debugconsole] Object.FindObjectOfType(Type) not found");
}
// The console's show/hide is the single private, instance, void method
// taking exactly one bool, excluding compiler-generated members (the
// property backing setter on 2020.12 is also void(bool) but is marked
// [CompilerGenerated]).
private static MethodInfo? FindToggleByShape(Type t)
{
MethodInfo? found = null;
foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (m.ReturnType != typeof(void)) continue;
var ps = m.GetParameters();
if (ps.Length != 1 || ps[0].ParameterType != typeof(bool)) continue;
if (m.Name.StartsWith("set_", StringComparison.Ordinal)) continue;
if (m.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), false)) continue;
if (found is not null) return null; // ambiguous — don't guess
found = m;
}
return found;
}
public static void Toggle()
{
if (!_consoleResolved) ResolveConsole();
if (_consoleType is null || _toggleMethod is null || _findObjectOfType is null) return;
try
{
var instance = _findObjectOfType.Invoke(null, new object[] { _consoleType });
if (instance is null)
{
Mod.Log.Warning("[debugconsole] no live DebugConsole in scene — it may not be instantiated on this build");
return;
}
// Best-effort: flip the static "show button" enable so the
// game's own Update keeps the console available instead of
// re-hiding it. Failures are ignored (obfuscated builds).
EnableShowButton();
_consoleVisible = !_consoleVisible;
_toggleMethod.Invoke(instance, new object[] { _consoleVisible });
Mod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
}
catch (Exception ex) { Mod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}"); }
}
private static void EnableShowButton()
{
if (_consoleType is null) return;
try
{
var prop = _consoleType.GetProperty("ShowButton", BindingFlags.Public | BindingFlags.Static);
if (prop is not null && prop.CanWrite) { prop.SetValue(null, true); return; }
foreach (var name in new[] { "showButton", "IEGCNLHPKFH" })
{
var f = _consoleType.GetField(name, BindingFlags.NonPublic | BindingFlags.Static);
if (f is not null && f.FieldType == typeof(bool)) { f.SetValue(null, true); return; }
}
}
catch { /* best-effort — the toggle still works without it */ }
}
}
internal static class UriPatches
{
public static void HttpManagerSendRequestString_Prefix(ref string url)
{
RewriteString(ref url, "httpmanager-rewrite");
}
public static void HttpManagerSendRequestObject_Prefix(object request)
{
RewriteRequestUri(request, "httpmanager-request-rewrite");
}
public static void UriStringCtor_Prefix(ref string uriString)
{
RewriteString(ref uriString, "uri-rewrite");
}
public static void HttpRequestSend_Prefix(object __instance)
{
RewriteRequestUri(__instance, "http-rewrite");
}
private static void RewriteRequestUri(object request, string label)
{
try
{
var uriProp = request.GetType().GetProperty("Uri", BindingFlags.Public | BindingFlags.Instance);
if (uriProp?.GetValue(request) is not Uri uri) return;
var rewritten = RewriteUri(uri);
if (rewritten is null) return;
uriProp.SetValue(request, rewritten);
Mod.Log.Msg($"[{label}] {uri} → {rewritten}");
}
catch (Exception ex)
{
Mod.Log.Warning($"[{label}] failed: {ex.Message}");
}
}
private static void RewriteString(ref string uriString, string label)
{
if (string.IsNullOrEmpty(uriString)) return;
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
if (!string.IsNullOrEmpty(Mod.Cfg.SingleOriginBaseUrl) &&
Uri.TryCreate(uriString, UriKind.Absolute, out var uri))
{
var rewrittenUri = RewriteUri(uri);
if (rewrittenUri is null) return;
var rewrittenText = rewrittenUri.ToString();
if (rewrittenText == uriString) return;
Mod.Log.Msg($"[{label}] {uriString} → {rewrittenText}");
uriString = rewrittenText;
return;
}
var host = Mod.Cfg.ServerHost;
if (string.IsNullOrEmpty(host)) return;
var rewritten = uriString.Replace(".rec.net", "." + host);
if (rewritten == uriString) return;
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
uriString = rewritten;
}
private static Uri? RewriteUri(Uri uri)
{
var host = Mod.Cfg.ServerHost;
if (string.IsNullOrEmpty(host)) return null;
if (!uri.Host.EndsWith(".rec.net", StringComparison.OrdinalIgnoreCase)) return null;
var service = uri.Host[..^".rec.net".Length];
if (string.IsNullOrWhiteSpace(service)) return null;
if (!string.IsNullOrEmpty(Mod.Cfg.SingleOriginBaseUrl) &&
Uri.TryCreate(Mod.Cfg.SingleOriginBaseUrl, UriKind.Absolute, out var baseUri))
{
var path = CombinePath(baseUri.AbsolutePath, "__dn/" + service, uri.AbsolutePath);
return new UriBuilder(baseUri)
{
Path = path,
Query = uri.Query.TrimStart('?'),
}.Uri;
}
return new UriBuilder(uri)
{
Host = service + "." + host,
}.Uri;
}
private static string CombinePath(params string[] parts)
{
var cleaned = new List<string>();
foreach (var part in parts)
{
var p = (part ?? "").Trim('/');
if (p.Length > 0) cleaned.Add(p);
}
return "/" + string.Join("/", cleaned);
}
}
internal static class PhotonPatches
{
/// <summary>True once <see cref="TryApplyOverride"/> has successfully
/// written the user's AppId into <c>PhotonServerSettings</c>. The
/// per-frame poller in <see cref="Mod.OnUpdate"/> stops calling once
/// this flips true.</summary>
public static bool OverrideApplied;
public static void PhotonAppIdOverride_Prefix()
{
// Kept as a belt-and-braces hook in case the watch's actual
// connect path does happen to flow through ConnectUsingSettings
// (it doesn't, in March's IL2CPP build — verified via log). The
// poller in OnUpdate is what actually gets the override in
// before the wire connect.
TryApplyOverride(reason: "prefix");
}
/// <summary>Tracks how many polling attempts we've logged so the
/// "type not found yet" line doesn't drown the log file. Cleared
/// once the override succeeds.</summary>
private static int _missLogCount;
/// <summary>Mutates <see cref="PhotonServerSettings.AppID"/> +
/// <c>VoiceAppID</c> + region with the values from
/// <see cref="Mod.Cfg"/>. Returns true once the override has been
/// written successfully; subsequent calls become no-ops. Returns
/// false (and logs nothing on most retries) when PhotonServerSettings
/// hasn't been loaded by the watch yet — the poller retries every
/// frame until the ScriptableObject shows up.</summary>
public static bool TryApplyOverride(string reason)
{
if (OverrideApplied) return true;
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return false;
try
{
// Use Mod.ResolveType (the same resolver TryPatchByName uses)
// — it falls back to a full assembly scan after the prefix
// attempts, which is how it finds the IL2CPP-mangled types
// that the local FindGameType helper misses.
var photonNetwork = Mod.ResolveType("PhotonNetwork");
if (photonNetwork is null)
{
// Log the miss only on the first call + once every ~10s
// after that, to avoid the log spam previous build hit.
if (_missLogCount == 0 || _missLogCount % 300 == 0)
Mod.Log.Msg($"[photon-appid] PhotonNetwork type not found yet (#{_missLogCount}, {reason})");
_missLogCount++;
return false;
}
var settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static);
var settings = settingsProp?.GetValue(null);
if (settings is null) return false; // ScriptableObject not loaded yet; quietly retry next frame.
var settingsType = settings.GetType();
var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId");
var voiceProp = settingsType.GetProperty("VoiceAppID") ?? settingsType.GetProperty("VoiceAppId");
var newId = Mod.Cfg.PhotonAppId;
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId;
appIdProp?.SetValue(settings, newId);
voiceProp?.SetValue(settings, newVoice);
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice} ({reason})");
// Force a deterministic Photon region so two watches running
// this mod end up on the same Photon master and "JoinByName"
// produces ONE shared room (not two parallel ones in eu/us
// because each watch auto-picked a different best region).
//
// The 2020 watch's default HostType is BestRegion(4) — every
// launch pings all Photon Cloud regions and picks the lowest
// latency. Two players geographically apart hit different
// "best" regions; same room name, two regions = two rooms.
// Server-side our matchmaking response carries
// `photonRegionId` but the watch's Photon code never reads
// it; the only consumer is Matchmaking.SetPhotonRegionPings
// which only reports pings *back* to the server.
//
// Strategy: switch HostType to PhotonCloud(1) and set
// PreferredRegion. Prefer the server-told region (the most
// recent Matchmaking.LocalRoomInstance.PhotonRegionId, if
// any) over the config default — that way a server-side
// region change propagates without redeploying the mod.
ApplyForcedRegion(settings, settingsType);
OverrideApplied = true;
return true;
}
catch (Exception ex)
{
Mod.Log.Warning($"[photon-appid] override failed ({reason}): {ex.Message}");
return false;
}
}
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
/// and writes <c>PreferredRegion</c>. Honors any region the server
/// has handed us in <c>Matchmaking.LocalRoomInstance.PhotonRegionId</c>
/// first; falls back to <c>Cfg.PhotonCloudRegion</c> when no room
/// instance has been received yet (initial boot).</summary>
private static void ApplyForcedRegion(object settings, Type settingsType)
{
try
{
var hostTypeProp = settingsType.GetField("HostType")
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("HostType");
var preferredProp = settingsType.GetField("PreferredRegion")
?? (System.Reflection.MemberInfo?)settingsType.GetProperty("PreferredRegion");
if (hostTypeProp is null || preferredProp is null)
{
Mod.Log.Warning("[photon-region] PhotonServerSettings.HostType/PreferredRegion not found");
return;
}
// ServerSettings.HostingOption.PhotonCloud = 1 (verified in
// Cpp2IL_CS/.../ServerSettings.cs:8). Resolve the actual enum
// type from the field/property so we don't depend on a
// specific Il2Cpp proxy namespace.
Type? hostTypeEnum = (hostTypeProp as FieldInfo)?.FieldType
?? ((PropertyInfo)hostTypeProp).PropertyType;
Type? regionEnum = (preferredProp as FieldInfo)?.FieldType
?? ((PropertyInfo)preferredProp).PropertyType;
// Prefer server-provided region over config. We read it via
// the same reflection style as the existing patches so the
// Il2Cpp type-name suffix doesn't bite us.
string region = ReadServerProvidedRegion() ?? Mod.Cfg.PhotonCloudRegion ?? "us";
object hostValue = Enum.ToObject(hostTypeEnum!, 1); // PhotonCloud
object regionValue;
try { regionValue = Enum.Parse(regionEnum!, region.ToLowerInvariant(), ignoreCase: true); }
catch
{
Mod.Log.Warning($"[photon-region] '{region}' is not a CloudRegionCode enum value; defaulting to us");
regionValue = Enum.ToObject(regionEnum!, 1); // CloudRegionCode.us = 1
region = "us";
}
if (hostTypeProp is FieldInfo hostField) hostField.SetValue(settings, hostValue);
else ((PropertyInfo)hostTypeProp).SetValue(settings, hostValue);
if (preferredProp is FieldInfo prefField) prefField.SetValue(settings, regionValue);
else ((PropertyInfo)preferredProp).SetValue(settings, regionValue);
Mod.Log.Msg($"[photon-region] forced HostType=PhotonCloud PreferredRegion={region}");
}
catch (Exception ex) { Mod.Log.Warning($"[photon-region] apply failed: {ex.Message}"); }
}
/// <summary>Reads
/// <c>RecNet.Matchmaking.LocalRoomInstance.PhotonRegionId</c> as a
/// lowercase enum name (eu, us, asia, …). Returns null if no room
/// instance has been received yet, or if any link in the chain is
/// null. Server-told region wins over the static config default
/// because the server is authoritative about which Photon region
/// hosts the room the watch is about to join.</summary>
private static string? ReadServerProvidedRegion()
{
try
{
var matchmaking = FindGameType("RecNet.Matchmaking");
var localRoom = matchmaking?.GetProperty("LocalRoomInstance", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localRoom is null) return null;
var regionObj = localRoom.GetType().GetProperty("PhotonRegionId")?.GetValue(localRoom);
if (regionObj is null) return null;
// CloudRegionCode enum.ToString() gives the lowercase region
// name (eu, us, asia, …) — see CloudRegionCode.cs.
return regionObj.ToString();
}
catch { return null; }
}
// AuthValuesInjector_Prefix + GetCurrentLockToken parked 2026-05-28
// — see attic/AuthValuesInjector.cs.attic. Photon Custom Auth is
// now disabled at the AppId level in the Photon dashboard, so the
// watch no longer needs to attach userid+LoginLock to Photon
// Authenticate ops.
private static Type? FindGameType(string name) => Mod.ResolveType(name);
}
internal static class SavePatches
{
// Last DataBlobName parsed off a SubscriptionUpdateRoom push for
// our currently-loaded room. Captured BEFORE the watch's caching
// gate (Rooms.OnSubscriptionUpdateRoom early-outs on a repeat
// (roomId, subRoomId) tuple — which is every post-save push for a
// dorm). SetRoomDataBlobName_Prefix reads this to hijack the
// would-be no-op setter call into a real transition.
private static volatile string? s_lastPushedBlobName;
private static long s_lastPushedRoomId;
private static long s_lastPushedSubRoomId;
// Hook on Notifications.OnNotificationReceived(string json). Fires for
// every SignalR message before any handler dispatch. Parses the JSON
// and, when the Id is SubscriptionUpdateRoom, stashes the new blob.
public static void OnNotificationReceived_Prefix(string message)
{
try
{
if (string.IsNullOrEmpty(message)) return;
// Fast reject: only parse JSON if it could plausibly be a
// SubscriptionUpdateRoom payload. Saves cycles on the (very
// frequent) RoomInstanceUpdate / PresenceUpdate / chat pushes.
if (message.IndexOf("\"SubscriptionUpdateRoom\"", StringComparison.Ordinal) < 0)
return;
using var doc = System.Text.Json.JsonDocument.Parse(message);
var root = doc.RootElement;
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return;
if (!root.TryGetProperty("Id", out var idEl) ||
idEl.ValueKind != System.Text.Json.JsonValueKind.String ||
!string.Equals(idEl.GetString(), "SubscriptionUpdateRoom", StringComparison.Ordinal))
return;
if (!root.TryGetProperty("Msg", out var msg) ||
msg.ValueKind != System.Text.Json.JsonValueKind.Object) return;
if (!msg.TryGetProperty("Room", out var room) ||
room.ValueKind != System.Text.Json.JsonValueKind.Object) return;
long roomId = 0;
if (room.TryGetProperty("RoomId", out var ridEl) &&
ridEl.ValueKind == System.Text.Json.JsonValueKind.Number)
roomId = ridEl.GetInt64();
if (!msg.TryGetProperty("Scenes", out var scenes) ||
scenes.ValueKind != System.Text.Json.JsonValueKind.Array ||
scenes.GetArrayLength() == 0) return;
var first = scenes[0];
long subRoomId = 0;
if (first.TryGetProperty("RoomSceneId", out var sidEl) &&
sidEl.ValueKind == System.Text.Json.JsonValueKind.Number)
subRoomId = sidEl.GetInt64();
if (!first.TryGetProperty("DataBlobName", out var blobEl) ||
blobEl.ValueKind != System.Text.Json.JsonValueKind.String) return;
var blob = blobEl.GetString();
if (string.IsNullOrEmpty(blob)) return;
s_lastPushedRoomId = roomId;
s_lastPushedSubRoomId = subRoomId;
s_lastPushedBlobName = blob;
Mod.Log.Msg($"[save-reload-fix] captured push via OnNotificationReceived: roomId={roomId} subRoomId={subRoomId} blob={blob}");
}
catch (Exception ex)
{
Mod.Log.Warning($"[save-reload-fix] OnNotificationReceived prefix failed: {ex.Message}");
}
}
public static void OnSubscriptionUpdateRoom_Prefix(object message)
{
try
{
if (message is null) return;
var dict = message;
// The watch parses this dict as Dictionary<string, object>
// via Util.GetKey. We need three keys: RoomId, SubRoomId,
// and Scenes[0].DataBlobName. The Il2Cpp dict supports
// get_Item(key) — call it via reflection.
var getItem = dict.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (getItem is null) return;
// The push wire shape from server (BuildRoomDetails) is
// {"Room":{...}, "Scenes":[{...}], ...}. The Room id lives
// on the Room sub-object; the scene's blob on Scenes[0].
object? roomObj = TryGet(dict, getItem, "Room");
object? scenesObj = TryGet(dict, getItem, "Scenes");
if (roomObj is null || scenesObj is null) return;
long roomId = 0;
long subRoomId = 0;
try
{
var roomItem = roomObj.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (roomItem is not null)
{
var rid = roomItem.Invoke(roomObj, new object[] { "RoomId" });
if (rid is not null) roomId = Convert.ToInt64(rid.ToString());
}
}
catch { /* roomId stays 0 — we'll log it and bail later */ }
// Scenes is an IL2CPP List<RoomScene>. Take the first
// (current dorm has one scene; multi-scene rooms target
// Scenes[currentSubRoomId] specifically, but the post-save
// push always carries the scene that was just saved).
object? firstScene = null;
try
{
var scenesIndexer = scenesObj.GetType().GetMethod("get_Item", new[] { typeof(int) });
firstScene = scenesIndexer?.Invoke(scenesObj, new object[] { 0 });
}
catch { /* leave firstScene null */ }
if (firstScene is null) return;
string? newBlob = null;
try
{
var sceneItem = firstScene.GetType().GetMethod("get_Item", new[] { typeof(string) });
if (sceneItem is not null)
{
var blob = sceneItem.Invoke(firstScene, new object[] { "DataBlobName" });
newBlob = blob?.ToString();
var sid = sceneItem.Invoke(firstScene, new object[] { "RoomSceneId" });
if (sid is not null) subRoomId = Convert.ToInt64(sid.ToString());
}
}
catch { /* leave newBlob null */ }
if (string.IsNullOrEmpty(newBlob)) return;
s_lastPushedRoomId = roomId;
s_lastPushedSubRoomId = subRoomId;
s_lastPushedBlobName = newBlob;
Mod.Log.Msg($"[save-reload-fix] captured SubscriptionUpdateRoom push: roomId={roomId} subRoomId={subRoomId} blob={newBlob}");
}
catch (Exception ex)
{
Mod.Log.Warning($"[save-reload-fix] OnSubscriptionUpdateRoom prefix failed: {ex.Message}");
}
}
private static object? TryGet(object dict, MethodInfo getItem, string key)
{
try { return getItem.Invoke(dict, new object[] { key }); }
catch { return null; }
}
// RoomPersistenceManager.set_RoomDataBlobName runs the change-event
// path only when the new value differs from the cached field. The
// 2020 watch's post-save propagation reads from RoomInstance.DataBlob
// (set by /goto, never updated by SubscriptionUpdateRoom), so on a
// save it calls the setter with the OLD blob — a no-op that skips
// OnRoomDataBlobNameChanged. The cached PersistedRoomData stays
// pointed at the boot-time bytes, and PostSaveReloading deserializes
// those over the live world — wiping any MakerPen object the user
// just placed.
//
// Live trace evidence (Latest.log, dorm save at 17:14): the watch's
// own set_RoomDataBlobName fires with old="v10" new="v10" ~900ms
// after b__4 returns True, even though the SubscriptionUpdateRoom
// push already carried Scenes[0].DataBlobName="v11".
//
// Strategy: intercept the setter. If the incoming value matches the
// current value (no-op) AND Rooms.LocalRoomScene.DataBlobName has a
// newer version (the freshly-pushed one), substitute that into the
// value parameter so the real setter sees a transition and fires
// OnRoomDataBlobNameChanged → DownloadRoomDataBlobAsync(newBlob).
// Falls through silently when no override is needed.
public static void SetRoomDataBlobName_Prefix(object __instance, ref string value)
{
string currentForLog = "<unread>";
string sceneForLog = "<unread>";
string scenes0ForLog = "<unread>";
try
{
if (__instance is null)
{
Mod.Log.Msg("[save-reload-fix] prefix: __instance null — skipping");
return;
}
var rpmType = __instance.GetType();
var blobNameProp = rpmType.GetProperty("RoomDataBlobName",
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (blobNameProp is null)
{
Mod.Log.Msg($"[save-reload-fix] prefix: RoomDataBlobName property not found on {rpmType.FullName}");
return;
}
currentForLog = (blobNameProp.GetValue(__instance) as string) ?? "<null>";
var roomsType = FindGameType("RecNet.Rooms");
if (roomsType is null)
{
Mod.Log.Msg("[save-reload-fix] prefix: RecNet.Rooms type not found");
return;
}
var localScene = roomsType.GetProperty("LocalRoomScene",
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localScene is not null)
sceneForLog = (localScene.GetType().GetProperty("DataBlobName")?.GetValue(localScene) as string) ?? "<null>";
var localDetails = roomsType.GetProperty("LocalRoomDetails",
BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (localDetails is not null)
{
var scenes = localDetails.GetType().GetProperty("Scenes")?.GetValue(localDetails);
if (scenes is not null)
{
var indexer = scenes.GetType().GetMethod("get_Item", new[] { typeof(int) });
var first = indexer?.Invoke(scenes, new object[] { 0 });
if (first is not null)
scenes0ForLog = (first.GetType().GetProperty("DataBlobName")?.GetValue(first) as string) ?? "<null>";
}
}
// Unconditional trace of EVERYTHING we read so we can see in
// the log exactly why we did or didn't override.
Mod.Log.Msg($"[save-reload-fix] prefix fired: value=\"{value}\" current=\"{currentForLog}\" " +
$"LocalRoomScene.DataBlobName=\"{sceneForLog}\" Scenes[0].DataBlobName=\"{scenes0ForLog}\" " +
$"lastPushedBlob=\"{s_lastPushedBlobName ?? "<unset>"}\"");
// Pick a candidate "newer" blob name. Priority order:
// 1. Last-pushed-from-SubscriptionUpdateRoom (captured by
// OnSubscriptionUpdateRoom_Prefix BEFORE the watch's
// cache gate drops the push).
// 2. LocalRoomScene.DataBlobName.
// 3. Scenes[0].DataBlobName.
string? pushedBlob = s_lastPushedBlobName;
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
pushedBlob = sceneForLog is "<unread>" or "<null>" ? null : sceneForLog;
if (string.IsNullOrEmpty(pushedBlob) || string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal))
pushedBlob = scenes0ForLog is "<unread>" or "<null>" ? null : scenes0ForLog;
if (string.IsNullOrEmpty(pushedBlob)) return;
if (string.Equals(pushedBlob, value, StringComparison.Ordinal)) return;
if (string.Equals(pushedBlob, currentForLog, StringComparison.Ordinal)) return;
Mod.Log.Msg($"[save-reload-fix] hijacking set_RoomDataBlobName({value}); substituting newer blob {pushedBlob}");
value = pushedBlob;
}
catch (Exception ex)
{
Mod.Log.Warning($"[save-reload-fix] set_RoomDataBlobName prefix failed: {ex.Message} " +
$"(value=\"{value}\" current=\"{currentForLog}\" scene=\"{sceneForLog}\" scenes0=\"{scenes0ForLog}\")");
}
}
private static Type? FindGameType(string name) => Mod.ResolveType(name);
}
internal static class TlsPatches
{
// Both BouncyCastle TLS authenticators have the same `void
// NotifyServerCertificate(...)` signature whose body throws when the
// chain doesn't validate. Returning false from a Harmony prefix on a
// void method skips the original — the certificate is accepted by
// virtue of NotifyServerCertificate never throwing.
public static bool NotifyServerCertificate_Prefix()
{
return !Mod.Cfg.EnableTlsTrustBypass; // false skips original; true (bypass off) lets it run
}
}
internal static class DiagnosticPatches
{
public static void Write(string message)
{
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [diagnostic] {message}";
try { Mod.Log.Msg(line); } catch { }
try
{
if (!string.IsNullOrEmpty(Mod.DiagnosticsLogPath))
File.AppendAllText(Mod.DiagnosticsLogPath, line + Environment.NewLine);
}
catch { }
}
public static void ApplicationQuit_Prefix()
{
Write("[quit] UnityEngine.Application.Quit()");
WriteStack();
}
public static void ApplicationQuitInt_Prefix(int exitCode)
{
Write($"[quit] UnityEngine.Application.Quit({exitCode})");
WriteStack();
}
public static void BootSequenceRegisterError_Prefix(string error)
{
Write($"[boot-error] BootSequence.RegisterError: {error}");
WriteStack();
}
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
{
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
}
public static void NamedMethod_Prefix(MethodBase __originalMethod)
{
Write($"[call] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
}
public static bool SessionManagerTryApplicationQuit_Prefix(MethodBase __originalMethod)
{
Write($"[quit-blocked] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
WriteStack();
return false;
}
private static void WriteStack()
{
try
{
Write("[stack] " + new StackTrace(skipFrames: 2, fNeedFileInfo: false));
}
catch (Exception ex)
{
Write("[stack] failed: " + ex.Message);
}
}
}
internal static class ChatPatches
{
private static bool s_logged;
// Force RecNet.AccountExtensions.CanLocalPlayerChat to return true.
// PlayerEmotes.SendChatEmoteCoroutine d__53 offset 040 calls this
// before raising the in-room Photon chat RPC; if it returns false
// the coroutine bails silently and the sender doesn't even see
// their own message echo.
public static void CanLocalPlayerChat_Postfix(ref bool __result)
{
if (!__result && !s_logged)
{
s_logged = true;
Mod.Log.Msg("[chat-fix] CanLocalPlayerChat returned false; forcing true to unblock room chat");
}
__result = true;
}
// Skip the original. Returning false from a Harmony prefix stops
// the underlying call.
public static bool RemoveInvalidCharactersFromMessage_Prefix()
{
Mod.Log.Msg("[chat-trace] RemoveInvalidCharactersFromMessage skipped");
return false;
}
public static void SendChatEmote_Prefix(string message)
{
Mod.Log.Msg($"[chat-trace] SendChatEmote msg=\"{message}\"");
}
public static void ProcessNewChatMessageReceived_Prefix(string message, bool playAudioForLocalPlayer)
{
Mod.Log.Msg($"[chat-trace] ProcessNewChatMessageReceived msg=\"{message}\" audio={playAudioForLocalPlayer}");
}
public static void RpcChatEmote_Prefix(string message)
{
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
}
}
@@ -1,164 +0,0 @@
// ─────────────────────────────────────────────────────────────────────
// AuthValuesInjector — PARKED 2026-05-28
// ─────────────────────────────────────────────────────────────────────
//
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber
// doesn't pick it up. Don't rename to .cs unless you're putting the
// feature back.
//
// What this used to do
// --------------------
// Prefix-patched NetworkingPeer.CallAuthenticate so the 2020 watch
// would attach a Photon Custom-Auth payload (`userid` +
// `LoginLock`) to every Photon Authenticate op. The server's
// /photon/customauth controller (PhotonCustomAuthController.cs) read
// those parameters and validated them against the player's
// /player/login lock.
//
// Why it's parked
// ---------------
// Photon Cloud's Custom Auth probe was getting 403'd at the WAF in
// front of `auth.<apex>/photon/customauth` for hosts behind
// Cloudflare or Tunnelto's proxy. The Photon NameServer then returned
// `CustomAuthenticationFailed (ReturnCode 32755)` and the watch
// bounced back to the dorm before a session could start.
//
// The cleaner fix turned out to be:
// 1. Turn off Custom Authentication on the Photon AppId in the
// dashboard (or flip "Anonymous connect" to Allow), so Photon
// never calls our URL.
// 2. Stop sending custom-auth params from the client — that's the
// InjectAuthValues=false flag we shipped first. With the
// dashboard side off too, the injector code below has nothing
// to gate on, so it's removed from the live patch.
//
// How to put it back
// ------------------
// 1. Rename this file to AuthValuesInjector.cs (drop the .attic
// suffix). It compiles as part of DorkNet.ClientMod.
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the
// config-load line in LoadConfig.
// 3. Re-add the harmony patch hook in OnLateInitializeMelon:
// TryPatchByName("NetworkingPeer", "CallAuthenticate",
// prefix: nameof(PhotonPatches.AuthValuesInjector_Prefix));
// 4. Flip `"InjectAuthValues": true` in
// DorkNet.ClientMod/dorknet-clientmod.json.template (and the
// copy under local-releases/).
// 5. If you also want the server to enforce the lock, set
// Photon:RequireCustomAuth=true on the server (env
// Photon__RequireCustomAuth=true).
//
// The body below is verbatim what lived in Mod.cs's PhotonPatches
// class. `FindGameType` is the same helper still in Mod.cs.
using System.Reflection;
namespace DorkNet.ClientMod.Attic;
internal static class AuthValuesInjector
{
public static void AuthValuesInjector_Prefix(object __instance)
{
if (!Mod.Cfg.InjectAuthValues) return;
try
{
// Photon's AuthValues live on PhotonNetwork.AuthValues. The 2020
// watch never sets them itself, so we set them right before
// NetworkingPeer.CallAuthenticate forwards to the wire op. The
// userid is RecNet.Core.LocalAccountId, the LoginLock comes
// from PlayerPrefs key LoginLockTokenV2.
// RecNet.Core.LocalAccountId is declared `int` in the game
// build (dump.cs:573622 → 0x30 backing field). Convert via
// Convert.ToInt64 so boxing as int still gives us the long we
// need below; a direct (long) cast on boxed int would throw.
var coreType = FindGameType("RecNet.Core");
var accountIdObj = coreType?.GetProperty("LocalAccountId", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
var accountId = accountIdObj is null ? 0L : Convert.ToInt64(accountIdObj);
if (accountId <= 0)
{
Mod.Log.Msg("[auth-injector] skip — no LocalAccountId yet (pre-login Photon ping)");
return;
}
var photonNetwork = FindGameType("PhotonNetwork");
var avProp = photonNetwork?.GetProperty("AuthValues", BindingFlags.Public | BindingFlags.Static);
var existing = avProp?.GetValue(null);
if (existing is not null)
{
var userIdProp = existing.GetType().GetProperty("UserId");
var existingUserId = userIdProp?.GetValue(existing) as string;
if (!string.IsNullOrEmpty(existingUserId)) return;
}
var token = GetCurrentLockToken();
// Build a new AuthenticationValues. The type lives in the
// ExitGames.Client.Photon.LoadBalancing namespace (game-side).
// CustomAuthenticationType.Custom = 0 in this build.
var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues");
if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; }
var av = Activator.CreateInstance(authValuesType);
var authTypeProp = authValuesType.GetProperty("AuthType");
if (authTypeProp is not null)
{
var customAuthValue = authTypeProp.PropertyType.IsEnum
? Enum.ToObject(authTypeProp.PropertyType, 0)
: Convert.ChangeType(0, authTypeProp.PropertyType);
authTypeProp.SetValue(av, customAuthValue);
}
authValuesType.GetProperty("UserId")?.SetValue(av, accountId.ToString());
var addParam = authValuesType.GetMethod("AddAuthParameter", new[] { typeof(string), typeof(string) });
addParam?.Invoke(av, new object[] { "userid", accountId.ToString() });
addParam?.Invoke(av, new object[] { "LoginLock", token ?? string.Empty });
avProp?.SetValue(null, av);
Mod.Log.Msg($"[auth-injector] set AuthValues userid={accountId} LoginLock={(string.IsNullOrEmpty(token) ? "<missing>" : "<set>")}");
}
catch (Exception ex) { Mod.Log.Warning($"[auth-injector] failed: {ex.Message}"); }
}
private static string? GetCurrentLockToken()
{
try
{
var playerPrefs = FindGameType("UnityEngine.PlayerPrefs");
var getString = playerPrefs?.GetMethod("GetString", new[] { typeof(string), typeof(string) });
var raw = (string?)getString?.Invoke(null, new object[] { "LoginLockTokenV2", string.Empty });
if (string.IsNullOrEmpty(raw)) return null;
const string key = "\"LockToken\":\"";
var ix = raw.IndexOf(key, StringComparison.Ordinal);
if (ix < 0) return null;
ix += key.Length;
var end = raw.IndexOf('"', ix);
return end < 0 ? null : raw[ix..end];
}
catch { return null; }
}
// FindGameType is already defined inside Mod.cs's PhotonPatches
// class (private static) — when re-instating, either drop this
// private copy and inline the lookup, or move the helper to a
// shared internal class. The current shape preserves the original
// behaviour for an isolated paste-back.
private static Type? FindGameType(string name)
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
var t = asm.GetType(name, throwOnError: false);
if (t is not null) return t;
var prefixed = asm.GetType("Il2Cpp" + name, throwOnError: false);
if (prefixed is not null) return prefixed;
var dot = name.IndexOf('.');
if (dot > 0)
{
var nsPrefixed = asm.GetType("Il2Cpp" + name.Substring(0, dot) + name.Substring(dot), throwOnError: false);
if (nsPrefixed is not null) return nsPrefixed;
}
}
catch { }
}
return null;
}
}
@@ -1,8 +0,0 @@
{
"ServerHost": "{HOST}",
"SingleOriginBaseUrl": "{SINGLE_ORIGIN_BASE_URL}",
"PhotonAppId": "{PHOTON_APPID}",
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
"PhotonCloudRegion": "{PHOTON_REGION}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS}
}
Binary file not shown.
@@ -1 +0,0 @@
471710
-36
View File
@@ -1,36 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Auth;
/// <summary>
/// Login.CachedLogin — dump.cs:578388, Deserialize at RVA 0x1447BD0.
///
/// Verified by disassembly: reads platform, platformId, accountId,
/// lastLoginTime (DateTime), requirePassword (default false). The Account
/// field on the C# class is NOT read from the JSON dict — it is set via
/// SetAccount(Account) after the fact, so the JSON shape doesn't need it.
///
/// Returned by:
/// GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
/// GET api.rec.net/api/platformlogin/cached
/// GET accounts.rec.net/account/v1/savedlogins
/// as a List&lt;CachedLogin&gt;. Empty list = no remembered accounts.
/// </summary>
public class CachedLogin
{
// PlatformManager.PlatformType (int)
[JsonPropertyName("platform")]
public int Platform { get; set; }
[JsonPropertyName("platformId")]
public string PlatformId { get; set; } = string.Empty;
[JsonPropertyName("accountId")]
public int AccountId { get; set; }
[JsonPropertyName("lastLoginTime")]
public DateTime LastLoginTime { get; set; } = DateTime.UtcNow;
[JsonPropertyName("requirePassword")]
public bool RequirePassword { get; set; } = false;
}
-143
View File
@@ -1,143 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Auth;
// ── June-2018 (build 20180621_EA) login wire shapes ──────────────────────────
//
// The 2018 client speaks a DIFFERENT login protocol than the 2019/2020 OAuth
// flow the rest of DorkNet implements:
// • POST api/platformlogin/v1/{loginaccount,logincached,createaccount}
// • request body is a JSON object (Unity JsonUtility / LitJson), NOT form
// • response is the "login envelope" below, read by RecNet.cs:40014-40021
// via case-sensitive LitJson keys.
//
// Keys verified from the assembly's REAL (name-preserved) Deserialize methods
// via dnlib — the obfuscated-name sibling methods are Beebyte decoys with
// scrambled literals (see project_2018_mono_obfuscation memory).
/// <summary>
/// Login response envelope. RecNet login-response Deserialize (RecNet.cs:40016-40020)
/// reads: Error (req string), Player (optional object), Token (req string),
/// FirstLoginOfTheDay (req bool), AnalyticsSessionId (req long). Empty Error
/// means success; a non-empty Error is shown to the user and aborts login.
/// </summary>
public class Login2018Response
{
[JsonPropertyName("Error")]
public string Error { get; set; } = string.Empty;
[JsonPropertyName("Player")]
public Player2018? Player { get; set; }
[JsonPropertyName("Token")]
public string Token { get; set; } = string.Empty;
[JsonPropertyName("FirstLoginOfTheDay")]
public bool FirstLoginOfTheDay { get; set; }
[JsonPropertyName("AnalyticsSessionId")]
public long AnalyticsSessionId { get; set; }
}
/// <summary>
/// 2018 player object embedded in the login envelope and returned bare from
/// getcachedlogins. Keys + required/optional verified from RecNet.cs:64632-64667
/// (RecNet.EEBPLECPEGD.Deserialize):
/// REQUIRED (Util.GetKey throws if missing): Id, Username, DisplayName, XP,
/// Level, RegistrationStatus, Developer, CanReceiveInvites, ProfileImageName,
/// JuniorProfile, ForceJuniorImages, PendingJunior, HasBirthday.
/// OPTIONAL: Bio (TryGetKey), AvoidJuniors (ContainsKey-guarded),
/// PlayerReputation/PlatformId (GetObjectKey -> null ok; omitted here),
/// GroupMemberships (ContainsKey-guarded; omitted).
/// NOTE the 2018 client uses "Id" (not "AccountId") and "Developer" (not
/// "IsDeveloper") — distinct from the 2020 PlayerProfile DTO.
/// </summary>
public class Player2018
{
[JsonPropertyName("Id")]
public long Id { get; set; }
[JsonPropertyName("Username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("DisplayName")]
public string DisplayName { get; set; } = string.Empty;
[JsonPropertyName("Bio")]
public string Bio { get; set; } = string.Empty;
[JsonPropertyName("XP")]
public int XP { get; set; }
[JsonPropertyName("Level")]
public int Level { get; set; } = 1;
// Registration step enum (ProfileSelectionManager.NFCBDCFLBOC):
// invalid=-1, video=0, birthday=1, email=2, COMPLETE=10. Anything < 10
// makes the client run the signup step flow (it got "stuck on birthday").
// 10 = fully registered → client goes straight in-game.
[JsonPropertyName("RegistrationStatus")]
public int RegistrationStatus { get; set; } = 10;
[JsonPropertyName("Developer")]
public bool Developer { get; set; }
[JsonPropertyName("CanReceiveInvites")]
public bool CanReceiveInvites { get; set; } = true;
[JsonPropertyName("ProfileImageName")]
public string ProfileImageName { get; set; } = string.Empty;
[JsonPropertyName("JuniorProfile")]
public bool JuniorProfile { get; set; }
[JsonPropertyName("ForceJuniorImages")]
public bool ForceJuniorImages { get; set; }
[JsonPropertyName("PendingJunior")]
public bool PendingJunior { get; set; }
[JsonPropertyName("HasBirthday")]
public bool HasBirthday { get; set; }
[JsonPropertyName("AvoidJuniors")]
public bool AvoidJuniors { get; set; }
// REQUIRED to be non-null: the post-login analytics Identify dereferences
// localPlayer.PlayerReputation.<field> (RecNet.cs ~NECCOFKHCAA) — a null
// here NREs the boot coroutine and the client hangs on splash_screen.
[JsonPropertyName("PlayerReputation")]
public PlayerReputation2018 PlayerReputation { get; set; } = new();
}
/// <summary>
/// 2018 PlayerReputation (RecNet.HOLNFPNKNGB.Deserialize) — all int. Keys exact
/// incl. the "Noteriety" misspelling. Must be present (non-null) on the player.
/// </summary>
public class PlayerReputation2018
{
[JsonPropertyName("Noteriety")] public int Noteriety { get; set; }
[JsonPropertyName("CheerGeneral")] public int CheerGeneral { get; set; }
[JsonPropertyName("CheerHelpful")] public int CheerHelpful { get; set; }
[JsonPropertyName("CheerGreatHost")] public int CheerGreatHost { get; set; }
[JsonPropertyName("CheerSportsman")] public int CheerSportsman { get; set; }
[JsonPropertyName("CheerCreative")] public int CheerCreative { get; set; }
[JsonPropertyName("CheerCredit")] public int CheerCredit { get; set; }
[JsonPropertyName("SubscriberCount")] public int SubscriberCount { get; set; }
[JsonPropertyName("SubscribedCount")] public int SubscribedCount { get; set; }
[JsonPropertyName("SelectedCheer")] public int SelectedCheer { get; set; }
}
/// <summary>
/// Generic 2018 success envelope {Success, Message} (RecNet EFDOILDKBFK) used by
/// registeraccount (email attach) and other simple POSTs. Note the key is
/// "Message" (not "error").
/// </summary>
public class SuccessMessage2018
{
[JsonPropertyName("Success")]
public bool Success { get; set; }
[JsonPropertyName("Message")]
public string Message { get; set; } = string.Empty;
}
-9
View File
@@ -1,9 +0,0 @@
namespace DorkNet.Models.Auth;
public class LoginRequest
{
public string? Name { get; set; }
public long BuildTimestamp { get; set; }
public int Platform { get; set; }
public string? DeviceId { get; set; }
}
-39
View File
@@ -1,39 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Auth;
/// <summary>
/// Login.LoginResponse — dump.cs:578473 (private nested class),
/// Deserialize at RVA 0x1447D10.
///
/// Verified by disassembly: this is a standard OAuth 2.0 RFC 6749
/// password-grant token response. The Deserialize body has two branches:
///
/// error = dict["error"];
/// if (string.IsNullOrEmpty(error)) {
/// this.AccessToken = dict["access_token"];
/// this.RefreshToken = dict["refresh_token"];
/// } else {
/// this.ErrorDescription = dict["error_description"];
/// }
///
/// Returned by api/platformlogin/v5 (the 2020 build's login endpoint)
/// and api/platformlogin/refresh.
///
/// All four keys are snake_case, NOT PascalCase. This is the standard
/// OAuth response format — Rec Room conforms to RFC 6749 here.
/// </summary>
public class LoginResponse
{
[JsonPropertyName("error")]
public string? Error { get; set; }
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; set; }
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = string.Empty;
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; } = string.Empty;
}
-122
View File
@@ -1,122 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Auth;
// =====================================================================
// JSON keys verified by disassembling each .Deserialize method body
// directly from GameAssembly.dll. Each key here is the literal string
// passed to Util.GetKey / Util.GetKeyOrDefault inside the client. Util.GetKey
// uses Dictionary<string,object>.TryGetValue, which is case-sensitive — so
// the keys MUST match exactly.
//
// Convention summary (it's mixed):
// RecNetResult lowercase "success", "error"
// Account camelCase "accountId", "displayName", "isJunior"…
// SelfAccount camelCase "email", "birthday", "juniorState"…
// LoginResponse snake_case (OAuth) "access_token", "error_description"…
// RefreshLoginResp. lowercase "token"
// CachedLogin camelCase "platformId", "lastLoginTime"…
// CreateAccountResp uses "value" as the wrapper key for the Account dict
//
// NOTE: TreatAsJunior on the C# side maps to JSON key "isJunior" — they
// don't match. Account.Deserialize reads dict["isJunior"], not dict["treatAsJunior"].
// =====================================================================
/// <summary>
/// RecNet.RecNetResult — dump.cs:584325, Deserialize at RVA 0x1146340.
/// Reads "success" (required) and "error" (optional).
/// </summary>
public class RecNetResult
{
[JsonPropertyName("success")]
public bool Success { get; set; } = true;
[JsonPropertyName("error")]
public string? Error { get; set; }
}
/// <summary>
/// RecNet.Account — dump.cs:567684, Deserialize at RVA 0xF9DE50.
/// JSON keys verified in the current 2020 IL2CPP build. Account.Deserialize
/// reads profileImage.
/// Other keys: accountId, username, displayName, isJunior, platforms. The C# Account class also has
/// RawUsername and HasBirthday properties but Deserialize does not read
/// them from JSON (they're set via MakeNameAdhereToPlatformRequirements
/// or other flows). We expose them with reasonable camelCase keys for
/// other endpoints that may consume them.
/// </summary>
public class RecNetAccount
{
private string profileImage = string.Empty;
[JsonPropertyName("accountId")]
public int AccountId { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("displayName")]
public string DisplayName { get; set; } = string.Empty;
[JsonPropertyName("profileImage")]
public string ProfileImage
{
get => profileImage;
set => profileImage = value ?? string.Empty;
}
// Account.Deserialize reads "isJunior" — NOT "treatAsJunior".
// The C# property name and the JSON key name differ.
[JsonPropertyName("isJunior")]
public bool TreatAsJunior { get; set; } = false;
// PlatformManager.PlatformMask: Steam=1, Oculus=2, etc.
[JsonPropertyName("platforms")]
public int Platforms { get; set; } = 1;
// Not in Account.Deserialize body but kept for forward-compat.
[JsonPropertyName("rawUsername")]
public string RawUsername { get; set; } = string.Empty;
// Likewise.
[JsonPropertyName("hasBirthday")]
public bool HasBirthday { get; set; } = true;
}
/// <summary>
/// RecNet.SelfAccount : Account — dump.cs:567797, Deserialize at RVA 0xAF4FB0.
/// Calls base Account.Deserialize, then reads:
/// email, phone, birthday, juniorState, parentAccountId.
/// </summary>
public class RecNetSelfAccount : RecNetAccount
{
[JsonPropertyName("email")]
public string Email { get; set; } = string.Empty;
[JsonPropertyName("phone")]
public string Phone { get; set; } = string.Empty;
[JsonPropertyName("birthday")]
public DateTime? Birthday { get; set; } = new DateTime(2000, 1, 1);
// RecNet.JuniorState enum (int)
[JsonPropertyName("juniorState")]
public int JuniorState { get; set; } = 0;
[JsonPropertyName("parentAccountId")]
public int? ParentAccountId { get; set; } = null;
}
/// <summary>
/// Accounts.CreateAccountResponse : RecNetResult — dump.cs:568167,
/// Deserialize at RVA 0xFAA200.
/// Calls base RecNetResult.Deserialize (reads "success", "error"),
/// then reads the Account from dict["value"] — NOT dict["account"].
/// Verified by disassembly: the only string literal after the base
/// call is "value", passed to Util.GetObjectKey&lt;object&gt;.
/// </summary>
public class CreateAccountResponse : RecNetResult
{
[JsonPropertyName("value")]
public RecNetAccount Account { get; set; } = new();
}
@@ -1,17 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Auth;
/// <summary>
/// Login.RefreshLoginResponse — dump.cs:578533 (private nested class),
/// Deserialize at RVA 0x145B1E0.
///
/// Verified by disassembly: reads exactly one key — lowercase "token" —
/// via Util.GetKey&lt;string&gt;. Single-field response from
/// POST api/platformlogin/refresh: the new (rotated) bearer access token.
/// </summary>
public class RefreshLoginResponse
{
[JsonPropertyName("token")]
public string Token { get; set; } = string.Empty;
}
-24
View File
@@ -1,24 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Avatar;
public class AvatarData
{
[JsonPropertyName("AccountId")]
public long AccountId { get; set; }
[JsonPropertyName("AvatarItemInstances")]
public List<AvatarItemInstance> AvatarItemInstances { get; set; } = [];
}
public class AvatarItemInstance
{
[JsonPropertyName("Id")]
public long Id { get; set; }
[JsonPropertyName("ItemId")]
public long ItemId { get; set; }
[JsonPropertyName("IsEquipped")]
public bool IsEquipped { get; set; }
}
-15
View File
@@ -1,15 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Avatar;
public class AvatarItem
{
[JsonPropertyName("ItemId")]
public long ItemId { get; set; }
[JsonPropertyName("Name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("Equipped")]
public bool Equipped { get; set; }
}
-201
View File
@@ -1,201 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Config;
// Required keys per RecNet.RecRoomConfig.Deserialize disassembly (RVA 0x1146FA0).
// Required-throw keys: ShareBaseUrl, LevelProgressionMaps[].Level, [].RequiredXp.
// Required-object keys (GetObjectKey, accept null): ServerMaintenance,
// AutoMicMutingConfig.
//
// NOTE: the level entry uses "RequiredXp" (lowercase 'p') per the IL string
// literal — NOT "RequiredXP". A capital P here would throw KeyNotFoundException.
public class RecRoomConfig
{
[JsonPropertyName("MessageOfTheDay")]
public string MessageOfTheDay { get; set; } = "Welcome to the private server!";
[JsonPropertyName("CdnBaseUri")]
public string CdnBaseUri { get; set; } = string.Empty;
// Required by client (string, GetKey). Used for share-link generation.
[JsonPropertyName("ShareBaseUrl")]
public string ShareBaseUrl { get; set; } = "https://rec.net/";
// GetObjectKey<ServerMaintenanceDTO>. Required key. The deserializer
// accepts null without throwing, but the C# property getter on the
// client doesn't null-check before use during boot. Send a real DTO
// with StartsInMinutes far in the future = "no upcoming maintenance".
[JsonPropertyName("ServerMaintenance")]
public ServerMaintenanceDTO? ServerMaintenance { get; set; } = new();
// GetObjectKey<AutoMicMutingConfig>. Required by the client AND
// dereferenced by AudioManager.Initialize during PostLogin boot —
// null causes:
// NullReferenceException at RecNet.Config.get_AutoMicMutingConfig
// at AudioManager.Initialize
// which kills the boot sequence with "Error initializing Core Systems".
// Must be a real object with all 8 float fields populated.
[JsonPropertyName("AutoMicMutingConfig")]
public AutoMicMutingConfig AutoMicMutingConfig { get; set; } = new();
[JsonPropertyName("PhotonConfig")]
public PhotonConfig PhotonConfig { get; set; } = new();
[JsonPropertyName("MatchmakingParams")]
public MatchmakingParams MatchmakingParams { get; set; } = new();
[JsonPropertyName("LevelProgressionMaps")]
public List<LevelProgressionEntry> LevelProgressionMaps { get; set; } = LevelProgressionEntry.Default();
[JsonPropertyName("DailyObjectives")]
public List<List<DailyObjective>> DailyObjectives { get; set; } = DailyObjective.DefaultSets();
// 2018.06 client (RecNet.OPOHNGAOCCD.Deserialize, RecNet.cs:9062-9068) reads
// ConfigTable as an ARRAY of {Key,Value} objects, NOT a JSON object map:
// foreach elem in dict["ConfigTable"]: table[elem["Key"]] = elem["Value"]
// The 2020 client read it as an object — but this branch targets 2018, so we
// emit the array form. Keep the settable Dictionary for existing call sites
// (ConfigService.GetConfig populates it) and project to the wire array.
[JsonIgnore]
public Dictionary<string, string> ConfigTable { get; set; } = [];
[JsonPropertyName("ConfigTable")]
public List<ConfigTableEntry> ConfigTableWire =>
ConfigTable.Select(kv => new ConfigTableEntry { Key = kv.Key, Value = kv.Value }).ToList();
[JsonPropertyName("ServiceUrls")]
public Dictionary<string, string> ServiceUrls { get; set; } = [];
}
// 2018 ConfigTable element shape: {"Key": "...", "Value": "..."} (PascalCase,
// LitJson Util.GetKey<string>("Key"/"Value")).
public class ConfigTableEntry
{
[JsonPropertyName("Key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
}
public class PhotonConfig
{
[JsonPropertyName("AppId")]
public string AppId { get; set; } = string.Empty;
[JsonPropertyName("VoiceAppId")]
public string VoiceAppId { get; set; } = string.Empty;
[JsonPropertyName("CloudRegion")]
public string CloudRegion { get; set; } = "us";
[JsonPropertyName("CrcCheckEnabled")]
public bool CrcCheckEnabled { get; set; } = false;
[JsonPropertyName("EnableServerTracingAfterDisconnect")]
public bool EnableServerTracingAfterDisconnect { get; set; } = false;
}
public class MatchmakingParams
{
// 2018 RecNet.LOIBFONMHMF.Deserialize reads exactly TWO required float keys
// via Util.GetKey<Single> (verified via dnlib). It's fetched as an optional
// object (GetObjectKey) inside config/v2, but once PRESENT the client
// deserializes it — wrong/missing keys throw KeyNotFoundException →
// "Received malformed RecNet response" → "Failed to connect to RecNet" at
// boot. The 2020 MaxPlayersPerRoom/MinPlayersToStart keys are NOT read here.
[JsonPropertyName("PreferFullRoomsFrequency")]
public float PreferFullRoomsFrequency { get; set; } = 0.5f;
[JsonPropertyName("PreferEmptyRoomsFrequency")]
public float PreferEmptyRoomsFrequency { get; set; } = 0.5f;
}
public class LevelProgressionEntry
{
[JsonPropertyName("Level")]
public int Level { get; set; }
// Note the lowercase 'p' — the client's RecRoomConfig.Deserialize calls
// Util.GetKey<int>("RequiredXp", ...). PascalCase XP would miss.
[JsonPropertyName("RequiredXp")]
public int RequiredXp { get; set; }
public static List<LevelProgressionEntry> Default()
{
var entries = new List<LevelProgressionEntry>();
for (int i = 0; i <= 30; i++)
entries.Add(new LevelProgressionEntry { Level = i, RequiredXp = i * 500 });
return entries;
}
}
// Inner item shape for RecRoomConfig.DailyObjectives, verified by the
// disassembled iteration body — only "type" and "score" are read. Other
// fields the client doesn't read get ignored, so we only send these two.
public class DailyObjective
{
[JsonPropertyName("type")]
public int Type { get; set; }
[JsonPropertyName("score")]
public int Score { get; set; }
public static List<List<DailyObjective>> DefaultSets()
{
var sets = new List<List<DailyObjective>>();
for (int day = 0; day < 7; day++)
{
sets.Add(
[
new DailyObjective { Type = 1, Score = 50 },
new DailyObjective { Type = 2, Score = 30 },
new DailyObjective { Type = 3, Score = 20 },
]);
}
return sets;
}
}
/// <summary>
/// RecNet.AutoMicMutingConfig — dump.cs class, Deserialize at RVA 0xFA3AE0.
/// All 8 float fields are required (Util.GetKey&lt;float&gt;). Defaults are
/// "feature effectively disabled" — high thresholds, no force-mute.
/// </summary>
public class AutoMicMutingConfig
{
[JsonPropertyName("MicSpamVolumeThreshold")]
public float MicSpamVolumeThreshold { get; set; } = 1.0f;
[JsonPropertyName("MicVolumeSampleInterval")]
public float MicVolumeSampleInterval { get; set; } = 0.1f;
[JsonPropertyName("MicVolumeSampleRollingWindowLength")]
public float MicVolumeSampleRollingWindowLength { get; set; } = 5.0f;
[JsonPropertyName("MicSpamSamplePercentageForWarning")]
public float MicSpamSamplePercentageForWarning { get; set; } = 1.0f;
[JsonPropertyName("MicSpamSamplePercentageForWarningToEnd")]
public float MicSpamSamplePercentageForWarningToEnd { get; set; } = 0.5f;
[JsonPropertyName("MicSpamSamplePercentageForForceMute")]
public float MicSpamSamplePercentageForForceMute { get; set; } = 1.0f;
[JsonPropertyName("MicSpamSamplePercentageForForceMuteToEnd")]
public float MicSpamSamplePercentageForForceMuteToEnd { get; set; } = 0.5f;
[JsonPropertyName("MicSpamWarningStateVolumeMultiplier")]
public float MicSpamWarningStateVolumeMultiplier { get; set; } = 1.0f;
}
/// <summary>
/// RecNet.ServerMaintenanceDTO — dump.cs, Deserialize at RVA 0xAF5450.
/// Single required key: StartsInMinutes (int). A large positive value
/// means "maintenance window is far away" = effectively never.
/// </summary>
public class ServerMaintenanceDTO
{
[JsonPropertyName("StartsInMinutes")]
public int StartsInMinutes { get; set; } = int.MaxValue;
}
-9
View File
@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -1,261 +0,0 @@
version=1
# This file caches language service data to improve the performance of C# Dev Kit.
# It is not intended for manual editing. It can safely be deleted and will be
# regenerated automatically. For more information, see https://aka.ms/lscache
#
# To control where cache files are stored, use the following VS Code setting:
# "dotnet.projectsystem.cacheInProjectFolder": true
[project]
language=C#
primary
lastDtbSucceeded
[properties]
AssemblyName=DorkNet.Models
CommandLineArgsForDesignTimeEvaluation=-langversion:13.0 -define:TRACE
CompilerGeneratedFilesOutputPath=
MaxSupportedLangVersion=13.0
ProjectAssetsFile=<PATH>obj/project.assets.json
RootNamespace=DorkNet.Models
RunAnalyzers=
RunAnalyzersDuringLiveAnalysis=
SolutionPath=<PATH>../DorkNet.sln
TargetFrameworkIdentifier=.NETCoreApp
TargetPath=<PATH>bin/Debug/net9.0/DorkNet.Models.dll
TargetRefPath=<PATH>obj/Debug/net9.0/ref/DorkNet.Models.dll
TemporaryDependencyNodeTargetIdentifier=net9.0
[commandLineArguments]
/noconfig
/unsafe-
/checked-
/nowarn:1701,1702,1701,1702
/fullpaths
/nostdlib+
/errorreport:prompt
/warn:9
/define:TRACE;DEBUG;NET;NET9_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER
/highentropyva+
/nullable:enable
/debug+
/debug:portable
/filealign:512
/optimize-
/out:obj\Debug\net9.0\DorkNet.Models.dll
/refout:obj\Debug\net9.0\refint\DorkNet.Models.dll
/target:library
/warnaserror-
/utf8output
/deterministic+
/langversion:13.0
/warnaserror+:NU1605,SYSLIB0011
[sourceFiles]
Auth/
CachedLogin.cs
LoginRequest.cs
LoginResponse.cs
RecNetResult.cs
RefreshLoginResponse.cs
Avatar/
AvatarData.cs
AvatarItem.cs
Config/RecRoomConfig.cs
GameSessions/GameSession.cs
Notification/PushNotification.cs
obj/Debug/net9.0/
.NETCoreApp,Version=v9.0.AssemblyAttributes.cs
DorkNet.Models.AssemblyInfo.cs
DorkNet.Models.GlobalUsings.g.cs
Players/
ModerationBlockDetail.cs
PlayerProfile.cs
Progression.cs
Reputation.cs
UpdateProfileRequest.cs
Relationships/Relationship.cs
[metadataReferences]
<DOTNET>/packs/Microsoft.NETCore.App.Ref/9.0.16/ref/net9.0/
Microsoft.CSharp.dll
Microsoft.VisualBasic.Core.dll
Microsoft.VisualBasic.dll
Microsoft.Win32.Primitives.dll
Microsoft.Win32.Registry.dll
mscorlib.dll
netstandard.dll
System.AppContext.dll
System.Buffers.dll
System.Collections.Concurrent.dll
System.Collections.dll
System.Collections.Immutable.dll
System.Collections.NonGeneric.dll
System.Collections.Specialized.dll
System.ComponentModel.Annotations.dll
System.ComponentModel.DataAnnotations.dll
System.ComponentModel.dll
System.ComponentModel.EventBasedAsync.dll
System.ComponentModel.Primitives.dll
System.ComponentModel.TypeConverter.dll
System.Configuration.dll
System.Console.dll
System.Core.dll
System.Data.Common.dll
System.Data.DataSetExtensions.dll
System.Data.dll
System.Diagnostics.Contracts.dll
System.Diagnostics.Debug.dll
System.Diagnostics.DiagnosticSource.dll
System.Diagnostics.FileVersionInfo.dll
System.Diagnostics.Process.dll
System.Diagnostics.StackTrace.dll
System.Diagnostics.TextWriterTraceListener.dll
System.Diagnostics.Tools.dll
System.Diagnostics.TraceSource.dll
System.Diagnostics.Tracing.dll
System.dll
System.Drawing.dll
System.Drawing.Primitives.dll
System.Dynamic.Runtime.dll
System.Formats.Asn1.dll
System.Formats.Tar.dll
System.Globalization.Calendars.dll
System.Globalization.dll
System.Globalization.Extensions.dll
System.IO.Compression.Brotli.dll
System.IO.Compression.dll
System.IO.Compression.FileSystem.dll
System.IO.Compression.ZipFile.dll
System.IO.dll
System.IO.FileSystem.AccessControl.dll
System.IO.FileSystem.dll
System.IO.FileSystem.DriveInfo.dll
System.IO.FileSystem.Primitives.dll
System.IO.FileSystem.Watcher.dll
System.IO.IsolatedStorage.dll
System.IO.MemoryMappedFiles.dll
System.IO.Pipelines.dll
System.IO.Pipes.AccessControl.dll
System.IO.Pipes.dll
System.IO.UnmanagedMemoryStream.dll
System.Linq.dll
System.Linq.Expressions.dll
System.Linq.Parallel.dll
System.Linq.Queryable.dll
System.Memory.dll
System.Net.dll
System.Net.Http.dll
System.Net.Http.Json.dll
System.Net.HttpListener.dll
System.Net.Mail.dll
System.Net.NameResolution.dll
System.Net.NetworkInformation.dll
System.Net.Ping.dll
System.Net.Primitives.dll
System.Net.Quic.dll
System.Net.Requests.dll
System.Net.Security.dll
System.Net.ServicePoint.dll
System.Net.Sockets.dll
System.Net.WebClient.dll
System.Net.WebHeaderCollection.dll
System.Net.WebProxy.dll
System.Net.WebSockets.Client.dll
System.Net.WebSockets.dll
System.Numerics.dll
System.Numerics.Vectors.dll
System.ObjectModel.dll
System.Reflection.DispatchProxy.dll
System.Reflection.dll
System.Reflection.Emit.dll
System.Reflection.Emit.ILGeneration.dll
System.Reflection.Emit.Lightweight.dll
System.Reflection.Extensions.dll
System.Reflection.Metadata.dll
System.Reflection.Primitives.dll
System.Reflection.TypeExtensions.dll
System.Resources.Reader.dll
System.Resources.ResourceManager.dll
System.Resources.Writer.dll
System.Runtime.CompilerServices.Unsafe.dll
System.Runtime.CompilerServices.VisualC.dll
System.Runtime.dll
System.Runtime.Extensions.dll
System.Runtime.Handles.dll
System.Runtime.InteropServices.dll
System.Runtime.InteropServices.JavaScript.dll
System.Runtime.InteropServices.RuntimeInformation.dll
System.Runtime.Intrinsics.dll
System.Runtime.Loader.dll
System.Runtime.Numerics.dll
System.Runtime.Serialization.dll
System.Runtime.Serialization.Formatters.dll
System.Runtime.Serialization.Json.dll
System.Runtime.Serialization.Primitives.dll
System.Runtime.Serialization.Xml.dll
System.Security.AccessControl.dll
System.Security.Claims.dll
System.Security.Cryptography.Algorithms.dll
System.Security.Cryptography.Cng.dll
System.Security.Cryptography.Csp.dll
System.Security.Cryptography.dll
System.Security.Cryptography.Encoding.dll
System.Security.Cryptography.OpenSsl.dll
System.Security.Cryptography.Primitives.dll
System.Security.Cryptography.X509Certificates.dll
System.Security.dll
System.Security.Principal.dll
System.Security.Principal.Windows.dll
System.Security.SecureString.dll
System.ServiceModel.Web.dll
System.ServiceProcess.dll
System.Text.Encoding.CodePages.dll
System.Text.Encoding.dll
System.Text.Encoding.Extensions.dll
System.Text.Encodings.Web.dll
System.Text.Json.dll
System.Text.RegularExpressions.dll
System.Threading.Channels.dll
System.Threading.dll
System.Threading.Overlapped.dll
System.Threading.Tasks.Dataflow.dll
System.Threading.Tasks.dll
System.Threading.Tasks.Extensions.dll
System.Threading.Tasks.Parallel.dll
System.Threading.Thread.dll
System.Threading.ThreadPool.dll
System.Threading.Timer.dll
System.Transactions.dll
System.Transactions.Local.dll
System.ValueTuple.dll
System.Web.dll
System.Web.HttpUtility.dll
System.Windows.dll
System.Xml.dll
System.Xml.Linq.dll
System.Xml.ReaderWriter.dll
System.Xml.Serialization.dll
System.Xml.XDocument.dll
System.Xml.XmlDocument.dll
System.Xml.XmlSerializer.dll
System.Xml.XPath.dll
System.Xml.XPath.XDocument.dll
WindowsBase.dll
[analyzerReferences]
<DOTNET>/packs/Microsoft.NETCore.App.Ref/9.0.16/analyzers/dotnet/cs/
Microsoft.Interop.ComInterfaceGenerator.dll
Microsoft.Interop.JavaScript.JSImportGenerator.dll
Microsoft.Interop.LibraryImportGenerator.dll
Microsoft.Interop.SourceGeneration.dll
System.Text.Json.SourceGeneration.dll
System.Text.RegularExpressions.Generator.dll
<DOTNET>/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/
Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll
Microsoft.CodeAnalysis.NetAnalyzers.dll
[analyzerConfigFiles]
<DOTNET>/sdk/9.0.314/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_9_default.globalconfig
obj/Debug/net9.0/DorkNet.Models.GeneratedMSBuildEditorConfig.editorconfig
@@ -1,73 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.GameSessions;
public class GameSession
{
[JsonPropertyName("GameSessionId")]
public long GameSessionId { get; set; }
[JsonPropertyName("RegionId")]
public string RegionId { get; set; } = "us";
[JsonPropertyName("RoomId")]
public string RoomId { get; set; } = string.Empty;
[JsonPropertyName("RoomName")]
public string RoomName { get; set; } = string.Empty;
[JsonPropertyName("ActivityLevelId")]
public string ActivityLevelId { get; set; } = string.Empty;
[JsonPropertyName("EventId")]
public long? EventId { get; set; }
[JsonPropertyName("Private")]
public bool Private { get; set; }
[JsonPropertyName("GameInProgress")]
public bool GameInProgress { get; set; }
[JsonPropertyName("MaxCapacity")]
public int MaxCapacity { get; set; } = 8;
[JsonPropertyName("IsFull")]
public bool IsFull { get; set; }
[JsonPropertyName("PlayerCount")]
public int PlayerCount { get; set; }
[JsonPropertyName("PhotonRoomName")]
public string PhotonRoomName { get; set; } = string.Empty;
}
public class JoinRandomGameSessionRequest
{
[JsonPropertyName("RoomId")]
public string? RoomId { get; set; }
[JsonPropertyName("ActivityLevelId")]
public string? ActivityLevelId { get; set; }
[JsonPropertyName("RegionId")]
public string RegionId { get; set; } = "us";
}
public class JoinGameSessionResponse
{
[JsonPropertyName("Result")]
public JoinGameErrorCode Result { get; set; } = JoinGameErrorCode.Success;
[JsonPropertyName("GameSession")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public GameSession? GameSession { get; set; }
}
public enum JoinGameErrorCode
{
Success = 0,
Full = 1,
NotFound = 2,
Banned = 3,
Error = 4,
}
@@ -1,65 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Notification;
public class PushNotification
{
[JsonPropertyName("Id")]
public PushNotificationId Id { get; set; }
[JsonPropertyName("Msg")]
public object? Msg { get; set; }
}
/// <summary>
/// Mirrors the client's <c>RecNet.Notifications+PushNotificationId</c>
/// enum exactly (decompiled at
/// <c>Cpp2IL_CS/.../RecNet/Notifications.cs:52-89</c>). Values are
/// non-contiguous because the client allocates them in groups (1–6
/// core; 11–15 subscriptions; 20–25 moderation; 30–31 gifts;
/// 60–62 storefronts; 70–71 consumables; 80–85 events; 90 chat;
/// 95–96 community board; 100 inventions).
///
/// Server <c>NotifyAsync(playerId, id)</c> pushes raise an event with
/// this int value over the SignalR hub. If the value doesn't match
/// what the client expects for a given concept, the push is silently
/// dropped — match the client byte-for-byte.
/// </summary>
public enum PushNotificationId
{
RelationshipChanged = 1,
MessageReceived = 2,
MessageDeleted = 3,
PresenceHeartbeatResponse = 4,
RefreshLogin = 5,
Logout = 6,
SubscriptionUpdateProfile = 11,
SubscriptionUpdatePresence = 12,
SubscriptionUpdateGameSession = 13,
SubscriptionUpdateRoom = 15,
ModerationQuitGame = 20,
ModerationUpdateRequired = 21,
ModerationKick = 22,
ModerationKickAttemptFailed = 23,
ModerationRoomBan = 24,
ServerMaintenance = 25,
GiftPackageReceived = 30,
GiftPackageReceivedImmediate = 31,
ProfileJuniorStatusUpdate = 40,
RelationshipsInvalid = 50,
StorefrontBalanceAdd = 60,
StorefrontBalanceUpdate = 61,
StorefrontBalancePurchase = 62,
ConsumableMappingAdded = 70,
ConsumableMappingRemoved = 71,
PlayerEventCreated = 80,
PlayerEventUpdated = 81,
PlayerEventDeleted = 82,
PlayerEventResponseChanged = 83,
PlayerEventResponseDeleted = 84,
PlayerEventStateChanged = 85,
ChatMessageReceived = 90,
CommunityBoardUpdate = 95,
CommunityBoardAnnouncementUpdate = 96,
InventionModerationStateChanged = 100,
}
@@ -1,46 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Players;
/// <summary>
/// RecNet.ModerationBlockDetail — dump.cs:583626, Deserialize at RVA 0x1450830.
///
/// Verified by disassembly. Required keys (Util.GetKey, throws if missing):
/// ReportCategory (int), Duration (int), GameSessionId (long), Message (string).
/// Optional keys (Util.GetKeyOrDefault):
/// IsHostKick (bool, default false), IsBan (bool, default false),
/// PlayerIdReporter (Nullable&lt;int&gt;, default null).
///
/// Returned by:
/// GET api.rec.net/api/PlayerReporting/v1/moderationBlockDetails
/// (Single object, NOT a list — the client method GetModerationBlockDetails
/// returns IPromise&lt;ModerationBlockDetail&gt;.)
///
/// To indicate "user is not blocked": ReportCategory=0, Duration=0,
/// GameSessionId=0, Message="", IsBan=false, IsHostKick=false,
/// PlayerIdReporter=null.
/// </summary>
public class ModerationBlockDetail
{
// PlayerReporting.ReportCategory enum (int)
[JsonPropertyName("ReportCategory")]
public int ReportCategory { get; set; } = 0;
[JsonPropertyName("Duration")]
public int Duration { get; set; } = 0;
[JsonPropertyName("GameSessionId")]
public long GameSessionId { get; set; } = 0;
[JsonPropertyName("Message")]
public string Message { get; set; } = string.Empty;
[JsonPropertyName("IsHostKick")]
public bool IsHostKick { get; set; } = false;
[JsonPropertyName("PlayerIdReporter")]
public int? PlayerIdReporter { get; set; } = null;
[JsonPropertyName("IsBan")]
public bool IsBan { get; set; } = false;
}
-64
View File
@@ -1,64 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Players;
public class PlayerProfile
{
private string? profileImage;
[JsonPropertyName("AccountId")]
public long AccountId { get; set; }
[JsonPropertyName("Username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("DisplayName")]
public string DisplayName { get; set; } = string.Empty;
[JsonPropertyName("ProfileImage")]
public string? ProfileImage
{
get => profileImage;
set => profileImage = value;
}
[JsonPropertyName("ProfileImageName")]
public string? ProfileImageName
{
get => profileImage;
set => profileImage = value;
}
[JsonPropertyName("BannerImage")]
public string? BannerImage { get; set; }
[JsonPropertyName("IsJunior")]
public bool IsJunior { get; set; } = false;
[JsonPropertyName("Level")]
public int Level { get; set; } = 1;
[JsonPropertyName("XP")]
public int XP { get; set; } = 0;
[JsonPropertyName("Reputation")]
public int Reputation { get; set; } = 0;
[JsonPropertyName("Bio")]
public string Bio { get; set; } = string.Empty;
[JsonPropertyName("IsVerified")]
public bool IsVerified { get; set; } = false;
[JsonPropertyName("IsDeveloper")]
public bool IsDeveloper { get; set; } = false;
[JsonPropertyName("IsCommunityTeam")]
public bool IsCommunityTeam { get; set; } = false;
[JsonPropertyName("CanReceiveInvites")]
public bool CanReceiveInvites { get; set; } = true;
[JsonPropertyName("CreatedAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
-21
View File
@@ -1,21 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Players;
/// <summary>
/// June-2018 presence record returned from POST api/presence/v2/list.
/// Keys verified from RecNet.DBHHFILLFLC.Deserialize: PlayerId (long),
/// IsOnline (bool), GameSession (optional object — null when the player is
/// online but not in a session; the client null-checks it).
/// </summary>
public class Presence2018
{
[JsonPropertyName("PlayerId")]
public long PlayerId { get; set; }
[JsonPropertyName("IsOnline")]
public bool IsOnline { get; set; }
[JsonPropertyName("GameSession")]
public object? GameSession { get; set; }
}
-26
View File
@@ -1,26 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Players;
/// <summary>
/// RecNet.Progression — dump.cs:582273, Deserialize at RVA 0x1142BB0.
///
/// Verified by disassembly: reads "PlayerId", "Level", "XP" via
/// Util.GetKey&lt;int&gt;. All three keys are required (non-default) — missing
/// any of them throws KeyNotFoundException at the client.
///
/// Note: the JSON uses "PlayerId" not "AccountId" even though the C# property
/// is AccountId on most other types. Returned by:
/// GET api.rec.net/api/players/v1/progression/{accountId}
/// </summary>
public class Progression
{
[JsonPropertyName("PlayerId")]
public int PlayerId { get; set; }
[JsonPropertyName("Level")]
public int Level { get; set; } = 1;
[JsonPropertyName("XP")]
public int XP { get; set; } = 0;
}
-56
View File
@@ -1,56 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Players;
/// <summary>
/// RecNet.Reputation — dump.cs:584087, Deserialize at RVA 0x114A830.
///
/// Verified by disassembly. ALL Cheer*/Subscriber* keys are required
/// (Util.GetKey&lt;int&gt;), missing any throws KeyNotFoundException.
/// "SelectedCheer" is the only optional one (Util.GetKeyOrDefault&lt;Nullable&lt;int&gt;&gt;).
///
/// CRITICAL TYPO: the JSON key is "Noteriety" — the dev mistyped "Notoriety".
/// The C# property is named Notoriety in dump.cs (line 584117) but the dict
/// lookup uses "Noteriety". Server response MUST match the typo.
///
/// Returned by:
/// GET api.rec.net/api/playerReputation/v1/{accountId}
/// </summary>
public class Reputation
{
[JsonPropertyName("AccountId")]
public int AccountId { get; set; }
// sic: see class XML doc — typo in the binary's Util.GetKey call.
[JsonPropertyName("Noteriety")]
public float Notoriety { get; set; } = 0f;
[JsonPropertyName("CheerGeneral")]
public int CheerGeneral { get; set; }
[JsonPropertyName("CheerHelpful")]
public int CheerHelpful { get; set; }
[JsonPropertyName("CheerGreatHost")]
public int CheerGreatHost { get; set; }
[JsonPropertyName("CheerSportsman")]
public int CheerSportsman { get; set; }
[JsonPropertyName("CheerCreative")]
public int CheerCreative { get; set; }
[JsonPropertyName("CheerCredit")]
public int CheerCredit { get; set; }
[JsonPropertyName("SubscriberCount")]
public int SubscriberCount { get; set; }
[JsonPropertyName("SubscribedCount")]
public int SubscribedCount { get; set; }
// PlayerCheering.CheerCategory enum (int) — Nullable; null means "no
// active cheer set". This is the only optional key.
[JsonPropertyName("SelectedCheer")]
public int? SelectedCheer { get; set; } = null;
}
@@ -1,12 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Players;
public class UpdateProfileRequest
{
[JsonPropertyName("DisplayName")]
public string? DisplayName { get; set; }
[JsonPropertyName("Bio")]
public string? Bio { get; set; }
}
@@ -1,27 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Relationships;
public class RelationshipData
{
[JsonPropertyName("AccountId")]
public long AccountId { get; set; }
[JsonPropertyName("OtherAccountId")]
public long OtherAccountId { get; set; }
[JsonPropertyName("Status")]
public RelationshipStatus Status { get; set; }
[JsonPropertyName("CreatedAt")]
public DateTime CreatedAt { get; set; }
}
public enum RelationshipStatus
{
None = 0,
Friend = 1,
PendingSent = 2,
PendingReceived = 3,
Blocked = 4,
}
-48
View File
@@ -1,48 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.Models.Rooms;
/// <summary>
/// Flat June-2018 room object. The 2018 client (RecNet Room.Deserialize,
/// RecNet.cs:78244) reads these keys directly off the room JSON — there is NO
/// 2020-style RoomDetails/Scenes wrapper; DataBlobName + PersonalDetails are
/// embedded right here. Required keys throw (Util.GetKey) if missing, so every
/// field below must be present except the genuinely-optional ones noted.
///
/// Notes:
/// • CreatorPlayerId is read as an int client-side; all account ids fit int.
/// • Date keys are ISO-8601 (client DateTimeParse.ParseISO8601). Use real
/// dates, never year-9999 (overflow — see WireDates).
/// • ActivityLevelId parse failure is a non-fatal warning client-side, so ""
/// is safe for non-activity rooms.
/// • CoOwners/Hosts are int arrays (empty ok); PersonalDetails is an optional
/// object (null ok).
/// </summary>
public class Room2018
{
[JsonPropertyName("RoomId")] public long RoomId { get; set; }
[JsonPropertyName("Name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("Description")] public string Description { get; set; } = string.Empty;
[JsonPropertyName("CreatorPlayerId")] public long CreatorPlayerId { get; set; }
[JsonPropertyName("ImageName")] public string ImageName { get; set; } = string.Empty;
[JsonPropertyName("DataBlobName")] public string DataBlobName { get; set; } = string.Empty;
[JsonPropertyName("ActivityLevelId")] public string ActivityLevelId { get; set; } = string.Empty;
[JsonPropertyName("IsSandbox")] public bool IsSandbox { get; set; }
[JsonPropertyName("Instanced")] public bool Instanced { get; set; } = true;
[JsonPropertyName("MaxPlayers")] public int MaxPlayers { get; set; } = 20;
[JsonPropertyName("Accessibility")] public int Accessibility { get; set; } = 1;
[JsonPropertyName("AccessibilityLocked")] public bool AccessibilityLocked { get; set; }
[JsonPropertyName("VisitorCount")] public int VisitorCount { get; set; }
[JsonPropertyName("VisitCount")] public int VisitCount { get; set; }
[JsonPropertyName("CheerCount")] public int CheerCount { get; set; }
[JsonPropertyName("ReportCount")] public int ReportCount { get; set; }
[JsonPropertyName("State")] public int State { get; set; }
[JsonPropertyName("StateModifiedAt")] public DateTime StateModifiedAt { get; set; }
[JsonPropertyName("CreatedAt")] public DateTime CreatedAt { get; set; }
[JsonPropertyName("ModifiedAt")] public DateTime ModifiedAt { get; set; }
[JsonPropertyName("LastVisitedAt")] public DateTime LastVisitedAt { get; set; }
[JsonPropertyName("DataModifiedAt")] public DateTime DataModifiedAt { get; set; }
[JsonPropertyName("CoOwners")] public List<int> CoOwners { get; set; } = [];
[JsonPropertyName("Hosts")] public List<int> Hosts { get; set; } = [];
[JsonPropertyName("PersonalDetails")] public object? PersonalDetails { get; set; }
}
-49
View File
@@ -1,49 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Auth;
/// <summary>
/// Action / controller filter that 403s any request whose
/// JWT-resolved player doesn't have <c>IsAdmin == true</c>. Does not
/// itself authenticate — pair it with <c>[Authorize]</c> to ensure a
/// valid bearer is present before this runs.
///
/// Usage:
/// <code>
/// [Authorize]
/// [AdminOnly]
/// [Route("api/admin/v1")]
/// public class AdminController { ... }
/// </code>
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
public class AdminOnlyAttribute : Attribute, IAsyncAuthorizationFilter
{
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
// AuthorizationFilterContext.HttpContext is always populated and
// exposes the same ClaimsPrincipal a controller would see, so
// there's no reason to round-trip through IHttpContextAccessor
// (which isn't even registered in this app's DI container — the
// earlier lookup crashed every admin request with
// "No service for type IHttpContextAccessor has been registered").
if (ControllerBaseExtensions.CurrentPlayerId(context.HttpContext.User) is not long id)
{
// No JWT → defer to [Authorize]'s 401.
context.Result = new UnauthorizedResult();
return;
}
var db = context.HttpContext.RequestServices
.GetRequiredService<DorkNetDbContext>();
var isAdmin = await db.Players
.Where(p => p.Id == id)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
if (!isAdmin)
context.Result = new ForbidResult();
}
}
-55
View File
@@ -1,55 +0,0 @@
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using System.Security.Claims;
using System.Text.Json;
namespace DorkNet.Server.Auth;
/// <summary>
/// Short-circuits any authenticated request whose JWT-resolved player has
/// a non-null <c>BannedUntil</c> in the future. Sits AFTER
/// <c>UseAuthentication</c>/<c>UseAuthorization</c> in the pipeline so the
/// claims principal is populated before we look at it.
///
/// Anonymous requests (no/invalid bearer) pass through unchanged — this
/// middleware never short-circuits them. Whether they're allowed is the
/// per-endpoint <c>[Authorize]</c> decision, not ours.
///
/// Lookup is one query per request against the Players table. Cached for
/// the request lifetime via the scoped DbContext, so repeated bearer
/// checks within one request hit the EF first-level cache.
/// </summary>
public class BanCheckMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext ctx, DorkNetDbContext db)
{
// Cheap early-out for anonymous traffic — no DB hit.
var sub = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(sub, out var playerId))
{
await next(ctx);
return;
}
// Project just the BannedUntil column — no need to materialise the
// full player entity for every request.
var bannedUntil = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.BannedUntil)
.FirstOrDefaultAsync();
if (bannedUntil.HasValue && bannedUntil.Value > DateTime.UtcNow)
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
ctx.Response.ContentType = "application/json";
await ctx.Response.WriteAsync(JsonSerializer.Serialize(new
{
Error = "banned",
Until = bannedUntil.Value.ToString("o"),
}));
return;
}
await next(ctx);
}
}
@@ -1,45 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace DorkNet.Server.Auth;
/// <summary>
/// Single canonical place for "who is the bearer-resolved player on this
/// request." Replaces the per-controller copy-paste of:
/// <code>
/// private long CurrentPlayerId =>
/// long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
/// </code>
/// which historically existed in 10 controllers and silently NPE-bombed
/// the action when the bearer was missing instead of returning 401.
/// </summary>
public static class ControllerBaseExtensions
{
/// <summary>
/// Returns the bearer-resolved player id, or null when the caller is
/// anonymous / the bearer is malformed. Use on optional-auth endpoints.
/// </summary>
public static long? CurrentPlayerId(this ControllerBase controller) =>
CurrentPlayerId(controller.User);
/// <summary>Overload for non-controller callers (filters,
/// middleware) that hold a <see cref="ClaimsPrincipal"/> directly.</summary>
public static long? CurrentPlayerId(ClaimsPrincipal user)
{
var claim = user.FindFirstValue(ClaimTypes.NameIdentifier);
return long.TryParse(claim, out var id) ? id : null;
}
/// <summary>
/// Returns the bearer-resolved player id; throws
/// <see cref="UnauthorizedAccessException"/> when missing. Use on
/// <c>[Authorize]</c>-decorated endpoints where a missing id is a
/// programming error (the framework should have already 401'd).
/// </summary>
public static long RequireCurrentPlayerId(this ControllerBase controller) =>
controller.CurrentPlayerId()
?? throw new UnauthorizedAccessException(
"Bearer token claim 'NameIdentifier' is missing or non-numeric. " +
"If this fired on an [Authorize] endpoint, JWT validation upstream " +
"is misconfigured.");
}
@@ -1,590 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Open.IdentityServer;
using Open.IdentityServer.Models;
using Open.IdentityServer.Services;
using Open.IdentityServer.Validation;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Auth;
public sealed class IdentityServerSigningKeyProvider
{
private readonly string _legacySecret;
public IdentityServerSigningKeyProvider(IConfiguration config)
{
_legacySecret =
Environment.GetEnvironmentVariable("DORKNET_JWT_SECRET")
?? Environment.GetEnvironmentVariable("RECNET_JWT_SECRET")
?? config["Jwt:Secret"]
?? throw new InvalidOperationException(
"JWT secret not configured. Set DORKNET_JWT_SECRET env var or Jwt:Secret in appsettings.Local.json.");
CertificatePath = ResolveCertificatePath(config);
var password = config["IdentityServer:SigningCertificatePassword"] ?? _legacySecret;
var certificateBase64 = config["IdentityServer:SigningCertificateBase64"];
if (!string.IsNullOrWhiteSpace(certificateBase64))
{
LoadedFromBase64 = true;
Certificate = LoadCertificateFromBase64(certificateBase64, password);
}
else
{
Certificate = LoadOrCreateCertificate(CertificatePath, password);
}
SigningKey = new X509SecurityKey(Certificate);
LegacyJwtKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_legacySecret));
}
public string CertificatePath { get; }
public bool LoadedFromBase64 { get; }
public string CertificateSource => LoadedFromBase64
? "IdentityServer:SigningCertificateBase64"
: CertificatePath;
public X509Certificate2 Certificate { get; }
public SecurityKey SigningKey { get; }
public SecurityKey LegacyJwtKey { get; }
public SecurityKey[] ValidationKeys => new[] { SigningKey, LegacyJwtKey };
private static string ResolveCertificatePath(IConfiguration config)
{
var path = config["IdentityServer:SigningCertificatePath"];
if (string.IsNullOrWhiteSpace(path))
path = Path.Combine(AppContext.BaseDirectory, "data", "identityserver-signing.pfx");
return path;
}
private static X509Certificate2 LoadCertificateFromBase64(string certificateBase64, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
var compact = NormalizeCertificateBase64(certificateBase64);
byte[] bytes;
try
{
bytes = Convert.FromBase64String(compact);
}
catch (FormatException ex)
{
throw new InvalidOperationException(
"IdentityServer signing certificate base64 is invalid. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64, " +
"without a file path or shell assignment text.",
ex);
}
try
{
return new X509Certificate2(bytes, password, flags);
}
catch (CryptographicException ex)
{
if (TryLoadPemCertificate(bytes, password, flags, out var certificate))
return certificate;
throw new InvalidOperationException(
"IdentityServer signing certificate base64 decoded successfully, but it is not a valid PFX/PKCS#12 certificate. " +
"Set IdentityServer__SigningCertificateBase64 to the PFX bytes encoded as base64 and make sure " +
"IdentityServer__SigningCertificatePassword matches the PFX password.",
ex);
}
}
private static bool TryLoadPemCertificate(
byte[] bytes,
string password,
X509KeyStorageFlags flags,
out X509Certificate2 certificate)
{
certificate = null!;
string pem;
try
{
pem = Encoding.UTF8.GetString(bytes);
}
catch (DecoderFallbackException)
{
return false;
}
if (!pem.Contains("-----BEGIN", StringComparison.Ordinal))
return false;
try
{
using var pemCertificate = X509Certificate2.CreateFromPem(pem, pem);
if (!pemCertificate.HasPrivateKey)
return false;
var pfxBytes = pemCertificate.Export(X509ContentType.Pfx, password);
certificate = new X509Certificate2(pfxBytes, password, flags);
return true;
}
catch (CryptographicException)
{
return false;
}
}
private static string NormalizeCertificateBase64(string value)
{
var text = value.Trim().Trim('"', '\'');
var equals = text.IndexOf('=');
if (equals > 0)
{
var key = text[..equals].Trim();
if (key.Equals("IdentityServer__SigningCertificateBase64", StringComparison.OrdinalIgnoreCase) ||
key.Equals("IdentityServer:SigningCertificateBase64", StringComparison.OrdinalIgnoreCase))
text = text[(equals + 1)..].Trim().Trim('"', '\'');
}
if (text.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = text.IndexOf(',');
if (comma >= 0)
text = text[(comma + 1)..];
}
if (text.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
text = text["base64:".Length..];
var compact = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray())
.Replace('-', '+')
.Replace('_', '/');
return (compact.Length % 4) switch
{
2 => compact + "==",
3 => compact + "=",
_ => compact,
};
}
private static X509Certificate2 LoadOrCreateCertificate(string path, string password)
{
var flags = X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (File.Exists(path))
return new X509Certificate2(path, password, flags);
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
"CN=DorkNet IdentityServer Signing",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: false));
using var created = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddYears(10));
var bytes = created.Export(X509ContentType.Pfx, password);
File.WriteAllBytes(path, bytes);
return new X509Certificate2(bytes, password, flags);
}
}
public static class RecRoomIdentityServerRegistration
{
public static void AddRecRoomIdentityServer(
this IServiceCollection services,
IConfiguration config,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeyProvider)
{
services
.AddIdentityServer(options =>
{
options.IssuerUri = domain.AuthIssuer.TrimEnd('/');
})
.AddInMemoryIdentityResources(RecRoomIdentityServerConfig.IdentityResources)
.AddInMemoryApiScopes(RecRoomIdentityServerConfig.ApiScopes)
.AddInMemoryClients(RecRoomIdentityServerConfig.Clients)
.AddSigningCredential(signingKeyProvider.SigningKey, SecurityAlgorithms.RsaSha256)
.AddResourceOwnerValidator<RecRoomPasswordValidator>()
.AddExtensionGrantValidator<CachedLoginGrantValidator>()
.AddProfileService<RecRoomProfileService>();
}
}
internal static class RecRoomIdentityServerConfig
{
public const string ClientId = "recroom";
public const string ClientSecret = "VxZ53kgbbEaRoZAeMe00MagtgD12GLL2";
public const string GameClientScope = "gameClient";
public const string CachedLoginGrantType = "cached_login";
public static readonly IdentityResource[] IdentityResources =
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
};
public static readonly ApiScope[] ApiScopes =
{
new(GameClientScope, "Game client")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
new("api", "DorkNet API")
{
UserClaims = RecRoomIdentityClaims.UserClaimTypes,
},
};
public static readonly Client[] Clients =
{
new()
{
ClientId = ClientId,
ClientName = "Rec Room game client",
RequireClientSecret = true,
ClientSecrets = { new Secret(ClientSecret.Sha256()) },
AllowedGrantTypes = { GrantType.ResourceOwnerPassword, "refresh_token", CachedLoginGrantType },
AllowedScopes = RecRoomScopes(),
AllowOfflineAccess = true,
AccessTokenLifetime = 60 * 60 * 12,
AbsoluteRefreshTokenLifetime = 60 * 60 * 24 * 30,
SlidingRefreshTokenLifetime = 60 * 60 * 24 * 30,
RefreshTokenUsage = TokenUsage.ReUse,
RefreshTokenExpiration = TokenExpiration.Absolute,
UpdateAccessTokenClaimsOnRefresh = true,
AlwaysIncludeUserClaimsInIdToken = true,
},
};
private static ICollection<string> RecRoomScopes() =>
[
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.OfflineAccess,
GameClientScope,
"api",
];
}
public sealed class IdentityServerGameTokenRequestMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
if (!IsTokenRequest(context))
{
await next(context);
return;
}
var form = await context.Request.ReadFormAsync();
var values = form.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value,
StringComparer.OrdinalIgnoreCase);
var changed = false;
if (IsDevicePasswordGrant(values))
{
if (!values.ContainsKey("username") || string.IsNullOrWhiteSpace(values["username"].ToString()))
{
values["username"] = First(
values.TryGetValue("deviceId", out var deviceId) ? deviceId.ToString() : null,
values.TryGetValue("device_id", out var deviceIdSnake) ? deviceIdSnake.ToString() : null,
values.TryGetValue("platformId", out var platformId) ? platformId.ToString() : null,
values.TryGetValue("platform_id", out var platformIdSnake) ? platformIdSnake.ToString() : null,
"anonymous");
changed = true;
}
if (!values.ContainsKey("password") || string.IsNullOrWhiteSpace(values["password"].ToString()))
{
values["password"] = "__dorknet_device_login__";
values["dorknet_device_login"] = "true";
changed = true;
}
}
var scopes = values.TryGetValue("scope", out var rawScope)
? rawScope.ToString()
: string.Empty;
var mergedScopes = MergeScopes(scopes);
if (!string.Equals(scopes, mergedScopes, StringComparison.Ordinal))
{
values["scope"] = mergedScopes;
changed = true;
}
if (changed)
context.Features.Set<IFormFeature>(new FormFeature(new FormCollection(values)));
await next(context);
}
private static bool IsTokenRequest(HttpContext context) =>
HttpMethods.IsPost(context.Request.Method)
&& context.Request.Path.Equals("/connect/token", StringComparison.OrdinalIgnoreCase)
&& context.Request.HasFormContentType;
private static bool IsDevicePasswordGrant(Dictionary<string, StringValues> values)
{
if (!values.TryGetValue("grant_type", out var grantType) ||
!string.Equals(grantType.ToString(), GrantType.ResourceOwnerPassword, StringComparison.OrdinalIgnoreCase))
return false;
return !values.TryGetValue("password", out var password) ||
string.IsNullOrWhiteSpace(password.ToString());
}
private static string First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "anonymous";
private static string MergeScopes(string existing)
{
var scopes = existing
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
scopes.Add(IdentityServerConstants.StandardScopes.OpenId);
scopes.Add(IdentityServerConstants.StandardScopes.Profile);
scopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess);
scopes.Add(RecRoomIdentityServerConfig.GameClientScope);
return string.Join(' ', scopes);
}
}
public sealed class RecRoomPasswordValidator(
PlayerService playerService,
LevelService level,
ServerSettingsService settings,
DorkNetDbContext db,
ILogger<RecRoomPasswordValidator> logger) : IResourceOwnerPasswordValidator
{
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
{
var raw = context.Request.Raw;
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var deviceLogin = string.Equals(raw.Get("dorknet_device_login"), "true", StringComparison.OrdinalIgnoreCase);
if (!deviceLogin && !string.IsNullOrEmpty(context.Password) && !string.IsNullOrEmpty(context.UserName))
{
var byName = await playerService.GetByUsernameAsync(context.UserName);
if (byName is null || byName.PasswordHash is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown_user_or_no_password");
return;
}
if (!BCrypt.Net.BCrypt.Verify(context.Password, byName.PasswordHash))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"password_mismatch");
return;
}
await playerService.TagPlatformAsync(byName.Id, platform, platformId);
await TryAwardLoginXpAsync(byName.Id);
context.Result = await CreateSuccessAsync(byName, "password");
return;
}
var effectiveDeviceId = !string.IsNullOrWhiteSpace(deviceId)
? deviceId
: $"legacy-{(context.UserName ?? "anonymous").ToLowerInvariant()}";
if (await settings.AreSignupsDisabledAsync())
{
var existing = await playerService.GetByDeviceAsync(effectiveDeviceId);
if (existing is null)
{
logger.LogInformation(
"[auth] IdentityServer password grant device fallback refused - signups disabled (device={Device})",
effectiveDeviceId);
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"signups_disabled");
return;
}
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: effectiveDeviceId,
platform: platform,
platformId: platformId,
displayName: context.UserName);
await TryAwardLoginXpAsync(player.Id);
context.Result = await CreateSuccessAsync(player, "device_password");
}
private async Task<GrantValidationResult> CreateSuccessAsync(PlayerEntity player, string authMethod) =>
new(
subject: player.Id.ToString(),
authenticationMethod: authMethod,
claims: await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
private static string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class CachedLoginGrantValidator(
PlayerService playerService,
LevelService level,
OrphanAccountTracker orphans,
DorkNetDbContext db,
ILogger<CachedLoginGrantValidator> logger) : IExtensionGrantValidator
{
public string GrantType => RecRoomIdentityServerConfig.CachedLoginGrantType;
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
var raw = context.Request.Raw;
if (!long.TryParse(raw.Get("account_id"), out var pickedId))
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"missing account_id");
return;
}
var picked = await playerService.GetByIdAsync(pickedId);
if (picked is null)
{
context.Result = new GrantValidationResult(
TokenRequestErrors.InvalidGrant,
"unknown account_id");
return;
}
var deviceId = First(raw.Get("deviceId"), raw.Get("device_id"));
var platformId = First(raw.Get("platformId"), raw.Get("platform_id"));
var platform = int.TryParse(raw.Get("platform"), out var p) ? p : 0;
var pendingId = orphans.PeekPending(deviceId, platformId);
if (pendingId is long orphanId && orphanId != pickedId)
{
await playerService.DeleteOrphanAsync(orphanId);
logger.LogInformation(
"[orphan-cleanup] cached_login picked {PickedId}; deleted just-created {OrphanId}",
pickedId,
orphanId);
}
orphans.Clear(deviceId, platformId);
await TryAwardLoginXpAsync(picked.Id);
await playerService.TagPlatformAsync(picked.Id, platform, platformId);
context.Result = new GrantValidationResult(
subject: picked.Id.ToString(),
authenticationMethod: GrantType,
claims: await RecRoomIdentityClaims.CreateAsync(db, picked.Id, picked.Username));
}
private async Task TryAwardLoginXpAsync(long playerId)
{
var lastSeen = await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.LastSeenAt)
.FirstOrDefaultAsync();
if (lastSeen.Date < DateTime.UtcNow.Date)
await level.AwardXpAsync(playerId, LevelService.FirstLoginXp, "first_login_today");
}
private static string? First(params string?[] values) =>
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}
public sealed class RecRoomProfileService(PlayerService playerService, DorkNetDbContext db) : IProfileService
{
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
if (!TryGetSubjectId(context.Subject, out var playerId))
return;
var player = await playerService.GetByIdAsync(playerId);
if (player is null)
return;
context.IssuedClaims.AddRange(
await RecRoomIdentityClaims.CreateAsync(db, player.Id, player.Username));
}
public async Task IsActiveAsync(IsActiveContext context)
{
context.IsActive =
TryGetSubjectId(context.Subject, out var playerId)
&& await playerService.GetByIdAsync(playerId) is not null;
}
private static bool TryGetSubjectId(ClaimsPrincipal subject, out long playerId)
{
var value = subject.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? subject.FindFirstValue(ClaimTypes.NameIdentifier);
return long.TryParse(value, out playerId);
}
}
public static class RecRoomIdentityClaims
{
public static readonly List<string> UserClaimTypes =
[
JwtRegisteredClaimNames.Sub,
ClaimTypes.NameIdentifier,
"name",
"role",
ClaimTypes.Role,
"roles",
"accountId",
"account_id",
"accountid",
];
public static async Task<List<Claim>> CreateAsync(DorkNetDbContext db, long playerId, string? username = null)
{
var playerInfo = await db.Players
.Where(p => p.Id == playerId)
.Select(p => new { p.Username, IsDev = p.IsDeveloper || p.IsAdmin })
.FirstOrDefaultAsync();
var name = username ?? playerInfo?.Username ?? playerId.ToString();
var isDev = playerInfo?.IsDev ?? false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, playerId.ToString()),
new(JwtRegisteredClaimNames.Sub, playerId.ToString()),
new("name", name),
new("accountId", playerId.ToString()),
new("account_id", playerId.ToString()),
new("accountid", playerId.ToString()),
new(ClaimTypes.Role, "gameClient"),
new("role", "gameClient"),
new("roles", "gameClient"),
};
if (isDev)
{
claims.Add(new Claim(ClaimTypes.Role, "developer"));
claims.Add(new Claim("role", "developer"));
claims.Add(new Claim("roles", "developer"));
}
return claims;
}
}
@@ -1,92 +0,0 @@
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using System.Net;
using System.Net.Sockets;
namespace DorkNet.Server.Auth;
/// <summary>
/// Short-circuits any incoming request whose remote IP falls under an
/// active <see cref="DorkNet.Server.Data.Entities.IpBanEntity"/> row
/// with 403. Sits BEFORE authentication so banned IPs can't spend a
/// round-trip negotiating a JWT — the connection terminates as early
/// as possible.
///
/// Lookup is one query against IpBans per request. The table is small
/// (a few rows in practice) and the simple "Cidr matches?" filter is
/// done in C# rather than SQL because CIDR maths in SQLite is awkward.
/// </summary>
public class IpBanCheckMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext ctx, DorkNetDbContext db)
{
var remote = ctx.Connection.RemoteIpAddress;
if (remote is null)
{
await next(ctx);
return;
}
// Materialise the active-ban list once per request. Could be
// cached for ~30s with a hosted service if it ever becomes a
// hot path, but right now this is unmeasurable.
var bans = await db.IpBans
.Where(b => b.Until == null || b.Until > DateTime.UtcNow)
.Select(b => b.Cidr)
.ToListAsync();
foreach (var cidr in bans)
{
if (CidrMatches(cidr, remote))
{
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
await ctx.Response.WriteAsync("ip_banned");
return;
}
}
await next(ctx);
}
/// <summary>Match a single IP against a CIDR-or-bare-IP string.
/// Bare IPs match exactly; CIDRs match if the prefix bits agree.
/// Falls back to false on any parse error.</summary>
private static bool CidrMatches(string cidr, IPAddress remote)
{
try
{
var slash = cidr.IndexOf('/');
if (slash < 0)
{
return IPAddress.TryParse(cidr, out var single) &&
single.Equals(remote);
}
var addrPart = cidr[..slash];
var prefixPart = cidr[(slash + 1)..];
if (!IPAddress.TryParse(addrPart, out var net) ||
!int.TryParse(prefixPart, out var prefix))
return false;
// Only IPv4 networks for now — IPv6 CIDR matching is
// straightforward but we'd need to mask 128-bit values
// and the user's setup is single-host IPv4 anyway.
if (net.AddressFamily != AddressFamily.InterNetwork ||
remote.AddressFamily != AddressFamily.InterNetwork)
return false;
var netBytes = net.GetAddressBytes();
var remoteBytes = remote.GetAddressBytes();
int fullBytes = prefix / 8;
int extraBits = prefix % 8;
for (int i = 0; i < fullBytes; i++)
if (netBytes[i] != remoteBytes[i]) return false;
if (extraBits == 0) return true;
int mask = 0xFF << (8 - extraBits) & 0xFF;
return (netBytes[fullBytes] & mask) == (remoteBytes[fullBytes] & mask);
}
catch { return false; }
}
}
@@ -1,31 +0,0 @@
using DorkNet.Models.Auth;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Compat2018;
/// <summary>
/// Maps a <see cref="PlayerEntity"/> to the June-2018 wire player object
/// (<see cref="Player2018"/>). Lives in the server (not DorkNet.Models) because
/// the DTO project can't reference the data-layer entity. Shared by the
/// platformlogin/v1, players/v1, and presence controllers so the 2018 player
/// shape is produced in exactly one place.
/// Keys verified against RecNet.EEBPLECPEGD.Deserialize (RecNet.cs:64632).
/// </summary>
public static class Player2018Mapper
{
public static Player2018 From(PlayerEntity p) => new()
{
Id = p.Id,
Username = p.Username,
DisplayName = string.IsNullOrEmpty(p.DisplayName) ? p.Username : p.DisplayName,
Bio = p.Bio ?? string.Empty,
XP = p.XP,
Level = p.Level,
Developer = p.IsDeveloper,
CanReceiveInvites = p.CanReceiveInvites,
ProfileImageName = p.ProfileImageName ?? string.Empty,
JuniorProfile = p.IsJunior,
HasBirthday = p.Birthday.HasValue,
PlayerReputation = new PlayerReputation2018 { Noteriety = p.Reputation },
};
}
@@ -1,43 +0,0 @@
using DorkNet.Models.Rooms;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Compat2018;
/// <summary>
/// Maps a <see cref="RoomEntity"/> to the flat June-2018 wire room
/// (<see cref="Room2018"/>). Server-side because DorkNet.Models can't reference
/// the data entity. Some 2018 fields have no column (Instanced, MaxPlayers,
/// ReportCount, ActivityLevelId) and get sensible defaults; IsSandbox is derived
/// from IsAGRoom (official AG rooms are not sandbox/maker rooms).
/// </summary>
public static class Room2018Mapper
{
public static Room2018 From(RoomEntity r) => new()
{
RoomId = r.Id,
Name = r.Name,
Description = r.Description,
CreatorPlayerId = r.CreatorPlayerId,
ImageName = r.ImageName,
DataBlobName = r.CurrentDataBlobName ?? string.Empty,
ActivityLevelId = string.Empty,
IsSandbox = !r.IsAGRoom,
Instanced = true,
MaxPlayers = 20,
Accessibility = r.Accessibility,
AccessibilityLocked = false,
VisitorCount = r.VisitorCount,
VisitCount = r.VisitCount,
CheerCount = r.CheerCount,
ReportCount = 0,
State = r.State,
StateModifiedAt = r.UpdatedAt,
CreatedAt = r.CreatedAt,
ModifiedAt = r.UpdatedAt,
LastVisitedAt = r.UpdatedAt,
DataModifiedAt = r.UpdatedAt,
CoOwners = [],
Hosts = [],
PersonalDetails = null,
};
}
@@ -1,270 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Account;
/// <summary>
/// api.rec.net/api/account/* — same endpoints as accounts.rec.net but routed
/// through the API service. The Accounts class in dump.cs doesn't expose its
/// base URL constant so the game may use either host; we cover both.
/// </summary>
[ApiController]
public class AccountApiController(
PlayerService playerService,
AuthService authService,
DorkNetDbContext db,
NotificationService notifications,
ServerSettingsService settings,
SignupCodeService signupCodes,
ILogger<AccountApiController> logger) : ControllerBase
{
[HttpPost("api/account/v1/create")]
[HttpPost("api/account/v1")]
[HttpPost("api/account/v2/create")]
[HttpPost("api/createaccount")]
public async Task<IActionResult> CreateAccount(
[FromForm] int platform,
[FromForm] string? platformId,
[FromForm] string? deviceId,
[FromForm] string? Username,
[FromForm] string? DisplayName)
{
if (await settings.AreSignupsDisabledAsync())
{
logger.LogInformation(
"[api-account] api/account/v1/create refused — signups disabled (device={Device} platform={Platform})",
deviceId, platform);
await signupCodes.RecordPendingDeviceAsync(
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
return Ok(new CreateAccountResponse
{
Success = false,
Error = "Account creation is currently disabled by the server admin.",
});
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: deviceId,
platform: platform,
platformId: platformId,
displayName: DisplayName ?? Username);
return Ok(new CreateAccountResponse
{
Success = true,
Error = string.Empty,
Account = new RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
},
});
}
[HttpGet("api/account/v1/me")]
[HttpGet("api/account/v2/me")]
public async Task<IActionResult> GetMe()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
return Ok(new RecNetSelfAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
Email = player.Email ?? string.Empty,
Phone = string.Empty,
Birthday = new DateTime(2000, 1, 1),
JuniorState = 0,
ParentAccountId = null,
});
}
/// <summary>
/// Resolves the player tied to the bearer token on the request. Returns
/// null when no/invalid bearer is supplied — caller decides whether
/// that's a 401 or a softer fallback. Anonymous-account creation was
/// previously the fallback path; deleted in Phase 1c because every
/// caller has gone through `/account/create` (deviceId-bound) before
/// reaching us.
/// </summary>
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return null;
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return null;
return await playerService.GetByIdAsync(playerId);
}
// Modification endpoints — POSTs land on api.* host with form body
// carrying the new value. AuthAccountController on auth.* handles
// the same shape with real persistence; we mirror that here so
// either host works.
[HttpPost("api/account/v1/displayname")]
public async Task<IActionResult> SetDisplayName([FromForm(Name = "displayName")] string? displayName,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var name = (displayName ?? value ?? string.Empty).Trim();
if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
await playerService.UpdateDisplayNameAsync(p.Id, name);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/bio")]
public async Task<IActionResult> SetBio([FromForm(Name = "bio")] string? bio,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
await playerService.UpdateBioAsync(p.Id, (bio ?? value ?? string.Empty));
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/username")]
public async Task<IActionResult> SetUsername([FromForm(Name = "username")] string? username,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var newName = (username ?? value ?? string.Empty).Trim();
if (newName.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
var taken = await db.Players.AnyAsync(x => x.Username == newName && x.Id != p.Id);
if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" });
p.Username = newName;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/birthday")]
public async Task<IActionResult> SetBirthday([FromForm(Name = "birthday")] string? birthday,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var raw = birthday ?? value ?? string.Empty;
if (DateTime.TryParse(raw, out var parsed))
{
p.Birthday = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
await db.SaveChangesAsync();
}
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/email")]
public async Task<IActionResult> SetEmail([FromForm(Name = "email")] string? email,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var addr = (email ?? value ?? string.Empty).Trim();
if (addr.Length > 0) p.Email = addr;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/phone")]
[HttpPost("api/account/v1/confirmphone")]
public async Task<IActionResult> SetPhone([FromForm(Name = "phone")] string? phone,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var num = (phone ?? value ?? string.Empty).Trim();
if (num.Length > 0) p.Phone = num;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/profileimage")]
[HttpPost("api/account/me/profileimage")]
[HttpPost("account/me/profileimage")]
[HttpPost("profileimage")]
[HttpPut("api/account/v1/profileimage")]
[HttpPut("api/account/me/profileimage")]
[HttpPut("account/me/profileimage")]
[HttpPut("profileimage")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetProfileImage(
[FromForm(Name = "imageName")] string? imageName,
[FromForm(Name = "fileName")] string? fileName,
[FromForm(Name = "image")] string? imageAlt)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
var name = imageName ?? fileName ?? imageAlt
?? Request.Query["imageName"].ToString()
?? Request.Query["fileName"].ToString();
player.ProfileImageName = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
await db.SaveChangesAsync();
logger.LogInformation(
"[api-account] profile image set: player={PlayerId} -> {Name}",
player.Id,
player.ProfileImageName ?? "<cleared>");
await NotifyAccountImageChangedAsync(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = new RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
};
var selfAccount = new RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday,
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
[HttpGet("api/account/v1/namegen")]
[HttpGet("api/account/v1/namegeneration")]
public IActionResult NameGen() => Ok(new
{
Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" },
Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" },
});
}
@@ -1,16 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Activities;
/// <summary>
/// api.{rec.net,localhost}/api/activities/* — per-activity content
/// (charades words, party-game prompts, etc.). The 2020 watch
/// fetches these as a static list at room-join time; we ship an
/// empty list so the activity's bundled defaults kick in.
/// </summary>
[ApiController]
public class ActivitiesController : ControllerBase
{
[HttpGet("api/activities/charades/v1/words")]
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
}
@@ -1,45 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Announcements;
/// <summary>
/// api.{rec.net,localhost}/api/announcement/v{1,2}/get — fetches the
/// current server announcement (shown on the watch's home tab). The
/// payload is sourced from <see cref="CommunityBoardService"/> so the
/// admin SPA's "edit announcement" form is the single place to
/// update what players see.
///
/// Wire shape per <c>RecNet/AnnouncementDTO.cs</c> — AnnouncementId,
/// AnnouncementType, LinkType, Platform are all required via
/// <c>Util.GetKey</c> (throws on miss); Title/Body/ImageName/LinkName/
/// LinkUri/CreatedAt are GetKeyOrDefault.
/// </summary>
[ApiController]
public class AnnouncementsController(CommunityBoardService communityBoard) : ControllerBase
{
[HttpGet("api/announcement/v1/get")]
[HttpGet("api/announcement/v2/get")]
public async Task<IActionResult> Current()
{
var board = await communityBoard.GetAsync();
var ann = board.CurrentAnnouncement;
if (ann is null) return Ok(Array.Empty<object>());
return Ok(new[]
{
new
{
AnnouncementId = 1L,
AnnouncementType = 0,
Title = "Server Announcement",
Body = ann.Message ?? string.Empty,
ImageName = "",
LinkType = 0,
LinkName = "",
LinkUri = ann.MoreInfoUrl ?? string.Empty,
Platform = 0,
CreatedAt = DateTime.UtcNow,
}
});
}
}
@@ -1,43 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Auth;
/// <summary>
/// Photon custom-auth webhook (api.rec.net).
///
/// Configure in Photon dashboard:
/// Authentication → Custom → URL = https://api.rec.net/api/auth/v1/photon
/// Query params sent by client: token=&lt;our_jwt&gt;
///
/// Photon ResultCode: 1 = success, 2 = fail, 3 = param error
/// </summary>
[ApiController]
public class PhotonAuthController(AuthService authService, PlayerService playerService) : ControllerBase
{
[HttpGet("api/auth/v1/photon")]
[HttpPost("api/auth/v1/photon")]
public async Task<IActionResult> Authenticate([FromQuery] string? token, [FromQuery] string? Token)
{
var jwt = token ?? Token;
if (string.IsNullOrWhiteSpace(jwt))
return Ok(new { ResultCode = 3, Message = "Missing token parameter" });
var playerId = authService.ValidateToken(jwt);
if (playerId is null)
return Ok(new { ResultCode = 2, Message = "Invalid or expired token" });
var player = await playerService.GetByIdAsync(playerId.Value);
if (player is null)
return Ok(new { ResultCode = 2, Message = "Player not found" });
return Ok(new
{
ResultCode = 1,
Message = "ok",
UserId = player.Id.ToString(),
Nickname = player.DisplayName ?? player.Username,
});
}
}
@@ -1,281 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Avatar.V2;
/// <summary>
/// api.rec.net/api/avatar/v2/gifts — wardrobe gift inbox. Mirrors
/// the client's <c>RecNet.Avatars.DowloadGiftPackages</c> +
/// <c>LocalRequestGiftPackage</c> + <c>LocalConsumeGiftPackage</c>
/// flow. Wire DTO matches <c>RecNet.Avatars+GiftPackage</c>
/// (<c>Cpp2IL_CS/.../RecNet/Avatars.cs:192-555</c>) field-for-field
/// so the watch's deserialiser doesn't crash.
///
/// Server semantics:
/// - <c>GET v2/gifts</c> returns unconsumed gifts for caller.
/// - <c>POST v2/gifts/generate</c> rolls a random unlocked-avatar
/// item and stamps a <see cref="GiftPackageEntity"/> for caller —
/// used by drop tiles and "free daily" prompts.
/// - <c>POST v2/gifts/consume/{id}</c> marks consumed and appends
/// the <see cref="GiftPackageEntity.AvatarItemDescOrHairDyeDesc"/>
/// to the player's <see cref="AvatarEntity.InventoryJson"/> so it
/// shows up in their backpack on next load.
/// </summary>
[ApiController]
[Authorize]
public class AvatarGiftsController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level) : ControllerBase
{
[HttpGet("api/avatar/v2/gifts")]
[HttpGet("api/avatar/v3/gifts")]
[HttpGet("api/avatar/v4/gifts")]
public async Task<IActionResult> List()
{
var pid = this.RequireCurrentPlayerId();
var rows = await db.GiftPackages
.Where(g => g.RecipientPlayerId == pid && !g.Consumed)
.OrderByDescending(g => g.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
public sealed record GenerateGiftRequest(int? GiftContext);
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — server picks
/// a random unlocked-avatar item and queues a gift package. The
/// 2020 client posts to this when the user clicks a free-gift
/// tile or hits a context the client maps to <c>GiftContext</c>.</summary>
[HttpPost("api/avatar/v2/gifts/generate")]
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
{
var pid = this.RequireCurrentPlayerId();
// Pick a random wardrobe StoreItem (Slug starts with
// "wardrobe-" — that's the convention StoreService uses for
// catalogue rows derived from outfits_assets bundles).
var pool = await db.StoreItems
.Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-"))
.Select(s => new { s.Slug, s.DisplayName })
.ToListAsync();
if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue");
var rng = new Random();
var pick = pool[rng.Next(pool.Count)];
var guid = pick.Slug["wardrobe-".Length..];
// Bare GUIDs crash AvatarItem.FromRecNetString — append `,,,`
// so the watch's parse-and-iterate doesn't blow up in
// GiftManager.DequeueGift. See AvatarItemsController.cs:31-40
// for the canonical wire-format note.
var gift = new GiftPackageEntity
{
RecipientPlayerId = pid,
FromPlayerId = null,
AvatarItemType = 0, // Outfit
AvatarItemDescOrHairDyeDesc = guid + ",,,",
CurrencyType = 0, // Invalid (no currency in this gift)
GiftContext = req?.GiftContext ?? 0,
GiftRarity = RollRarity(rng),
Message = $"Enjoy your new {pick.DisplayName}!",
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
// Push so the watch shows the new-gift toast.
await notifications.NotifyAsync(pid,
PushNotificationId.GiftPackageReceived, ToWire(gift));
return Ok(ToWire(gift));
}
[HttpPost("api/avatar/v2/gifts/consume/{id:long}")]
public Task<IActionResult> ConsumeViaPath(long id) => ConsumeImpl(id);
/// <summary>POST <c>/api/avatar/v2/gifts/consume/</c> (note trailing
/// slash, no id in path). The watch's <c>LocalConsumeGiftPackage</c>
/// (verified at <c>Cpp2IL_ISIL/.../RecNet/Avatars_NestedType__LocalConsumeGiftPackage_d__19.txt:276-301</c>)
/// posts to this exact URL with a form-urlencoded body containing
/// <c>Id</c> and <c>UnlockedLevel</c> fields. The previous
/// implementation took <c>[FromBody] ConsumeGiftRequest</c> which
/// only accepts <c>application/json</c> — the watch's form-encoded
/// body got 415 Unsupported Media Type
/// (<c>output_log.txt:'Failed to consume gift … HTTP Error 415'</c>),
/// which left the gift unconsumed → the avatar item never landed
/// in the player's inventory → mirror-unequip rendered a missing
/// item and crashed.
///
/// Accept both JSON and form-urlencoded by reading the raw body
/// and pulling <c>Id</c> from whichever container we got.</summary>
[HttpPost("api/avatar/v2/gifts/consume")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data", "text/plain")]
public async Task<IActionResult> ConsumeViaBody()
{
long id = 0;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var raw = form["Id"].ToString();
if (string.IsNullOrWhiteSpace(raw)) raw = form["id"].ToString();
long.TryParse(raw, out id);
}
else
{
using var reader = new System.IO.StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(body))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("Id", out var v) ||
doc.RootElement.TryGetProperty("id", out v))
{
if (v.ValueKind == System.Text.Json.JsonValueKind.Number)
v.TryGetInt64(out id);
else if (v.ValueKind == System.Text.Json.JsonValueKind.String)
long.TryParse(v.GetString(), out id);
}
}
catch (System.Text.Json.JsonException) { /* fall through; id stays 0 */ }
}
}
if (id == 0)
{
var raw = Request.Query["Id"].ToString();
if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["id"].ToString();
long.TryParse(raw, out id);
}
if (id == 0) return BadRequest("missing Id");
return await ConsumeImpl(id);
}
private async Task<IActionResult> ConsumeImpl(long id)
{
var pid = this.RequireCurrentPlayerId();
var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id);
if (gift is null) return NotFound();
if (gift.RecipientPlayerId != pid) return Forbid();
if (gift.Consumed) return Ok(ToWire(gift)); // idempotent
// Append avatar item to player's inventory list (JSON list of
// GUID strings). Defensive parse — corrupted JSON resets to
// empty list rather than 500.
if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc))
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(avatar);
}
try
{
var list = System.Text.Json.JsonSerializer
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc))
{
list.Add(gift.AvatarItemDescOrHairDyeDesc);
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
}
}
catch
{
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(
new[] { gift.AvatarItemDescOrHairDyeDesc });
avatar.UpdatedAt = DateTime.UtcNow;
}
}
// Apply currency / XP / level rewards bundled with the gift.
// The previous implementation only handled the avatar item path,
// so token gifts from the admin SPA marked themselves consumed
// but never moved the player's wallet — the player tapped the
// gift, the box played its animation, and the balance stayed
// unchanged. CurrencyType is the canonical RecNet enum
// (1 = Tokens, 2 = Coins; 0 means "no currency in this gift").
if (gift.CurrencyType > 0 && gift.Currency > 0)
{
await level.GrantCurrencyAsync(pid, gift.CurrencyType, gift.Currency,
$"gift_consumed:{gift.Id}");
}
if (gift.Xp > 0)
{
await level.AwardXpAsync(pid, gift.Xp, $"gift_consumed:{gift.Id}");
}
// Direct level grants bypass XP curves — same pattern
// /api/admin/v1/players/{id}/levelup uses. We don't surface
// that as a separate helper; bump the column inline.
if (gift.Level > 0)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null) player.Level += gift.Level;
}
gift.Consumed = true;
gift.ConsumedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// Profile-update push so the watch's wallet readout refreshes
// without waiting for the next /balance poll. Mirrors what
// /api/admin/v1/players/{id}/gift does after the inbox push so
// the toast number animates up the moment the box closes.
if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0)
{
await notifications.NotifyAsync(pid,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level });
}
return Ok(ToWire(gift));
}
/// <summary>Weighted rarity roll — Common 65, Uncommon 25,
/// Rare 8, Epic 1.8, Legendary 0.2 percent.</summary>
private static int RollRarity(Random rng) => rng.NextDouble() switch
{
< 0.65 => 0, // Common
< 0.90 => 10, // Uncommon
< 0.98 => 20, // Rare
< 0.998 => 30, // Epic
_ => 50, // Legendary
};
private static object ToWire(GiftPackageEntity g) => new
{
g.Id,
g.FromPlayerId,
g.ConsumableItemDesc,
g.AvatarItemType,
AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc,
g.AvatarItemDescOrHairDyeDesc,
g.EquipmentPrefabName,
g.EquipmentModificationGuid,
g.CurrencyType,
g.Currency,
g.Xp,
g.Level,
g.GiftContext,
g.GiftRarity,
g.Message,
g.Platform,
g.PackageMaterial,
g.PackageVariant,
g.Consumed,
g.IsValid,
g.ErrorMessage,
g.SupportsCurrentPlatform,
g.IsGifted,
};
}
@@ -1,193 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Avatar.V2;
/// <summary>
/// api.rec.net/api/avatar/v2 — older Avatar settings endpoint used by
/// the 2020 client (the V3 endpoint is for newer builds and uses a
/// different shape entirely).
///
/// Wire shape verified by disassembling RecNet.Avatar.Deserialize at
/// RVA 0xFA3C30. Required keys (Util.GetKey&lt;string&gt;): OutfitSelections,
/// HairColor, SkinColor, FaceFeatures. Empty strings satisfy the
/// deserializer; the client interprets them as "use defaults" and falls
/// back to its built-in starter avatar.
///
/// This implementation persists each set/get to <see cref="AvatarEntity"/>
/// instead of stubbing — round-trip across sessions, multi-user-safe
/// (one row per player keyed by PlayerId).
/// </summary>
[ApiController]
[Authorize]
public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller> logger) : ControllerBase
{
[HttpGet("api/avatar/v2")]
public async Task<ActionResult<AvatarV2Dto>> GetAvatar()
{
var pid = this.RequireCurrentPlayerId();
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
return Ok(ToDto(a));
}
/// <summary>
/// POST /api/avatar/v2/set — replace the player's avatar settings.
///
/// CRITICAL: do NOT use <c>[FromBody] AvatarV2Dto</c> here. The
/// watch's <c>RecNet.Avatar.Serialize()</c> emits each of
/// OutfitSelections / HairColor / SkinColor / FaceFeatures as
/// dictionary values whose runtime types are determined by the
/// IRecNetObject serializer it uses — FaceFeatures in particular
/// sometimes ships as a nested JSON OBJECT instead of a JSON
/// string, and the watch may also omit fields entirely when
/// they're empty.
///
/// System.Text.Json's strict type binder responds to either of those
/// with a 400 (model state invalid → ApiController auto-400) and
/// the watch then retries every frame, accumulating "Failed to
/// upload Rec Room player avatar changes: HTTP Error 400" errors
/// (output_log.txt:852+, 1000+ repeats) until the player object
/// gets destroyed.
///
/// Parse the body manually as JsonElement so we accept any shape
/// the watch sends: string-or-object for FaceFeatures (re-encode
/// the object as a string before persisting), missing fields (keep
/// existing values), null values (treat as empty).
/// </summary>
[HttpPost("api/avatar/v2")]
[HttpPost("api/avatar/v2/set")]
[Consumes("application/json", "application/x-www-form-urlencoded", "text/plain")]
public async Task<ActionResult<AvatarV2Dto>> SetAvatar()
{
var pid = this.RequireCurrentPlayerId();
// Read the raw body so we control parsing — bypasses the
// model binder's strict type matching that was causing 400s.
string raw;
using (var reader = new System.IO.StreamReader(Request.Body))
raw = await reader.ReadToEndAsync();
string? outfitSelections = null;
string? hairColor = null;
string? skinColor = null;
string? faceFeatures = null;
if (!string.IsNullOrWhiteSpace(raw))
{
try
{
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
outfitSelections = ReadStringField(root, "OutfitSelections");
hairColor = ReadStringField(root, "HairColor");
skinColor = ReadStringField(root, "SkinColor");
faceFeatures = ReadStringField(root, "FaceFeatures");
}
}
catch (JsonException ex)
{
// Fall through with all-null updates if the body isn't
// JSON. Log so we can see what the watch actually sends.
logger.LogWarning(
"[avatar-set] could not parse body as JSON ({Msg}); raw={Raw}",
ex.Message, raw.Length > 500 ? raw.Substring(0, 500) + "…" : raw);
}
}
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
if (a is null)
{
a = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(a);
}
// Only overwrite fields the watch actually sent; missing keys
// keep the previously-stored value so a partial PATCH-style
// update doesn't accidentally wipe HairColor/SkinColor/etc.
if (outfitSelections is not null) a.OutfitSelections = outfitSelections;
if (hairColor is not null) a.HairColor = hairColor;
if (skinColor is not null) a.SkinColor = skinColor;
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
a.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToDto(a));
}
/// <summary>Read a field that should be a string but may arrive
/// as <c>null</c>, a JSON object/array (re-encoded back to a JSON
/// string), or be missing entirely (return null → caller keeps
/// the existing DB value).</summary>
private static string? ReadStringField(JsonElement root, string name)
{
if (!root.TryGetProperty(name, out var v)) return null;
return v.ValueKind switch
{
JsonValueKind.String => v.GetString() ?? string.Empty,
JsonValueKind.Null => string.Empty,
JsonValueKind.Object => v.GetRawText(),
JsonValueKind.Array => v.GetRawText(),
JsonValueKind.Number => v.GetRawText(),
JsonValueKind.True or JsonValueKind.False => v.GetRawText(),
_ => null,
};
}
/// <summary>Public lookup of another player's equipped avatar — the
/// client uses this to render remote players' avatars in shared
/// rooms. No auth requirement on read so anonymous calls in the
/// boot path don't 401.</summary>
[HttpGet("api/avatar/v2/equipped/{playerId:long}")]
[HttpGet("api/avatar/v3/equipped/{playerId:long}")]
[HttpGet("api/avatar/v4/equipped/{playerId:long}")]
[AllowAnonymous]
public async Task<ActionResult<AvatarV2Dto>> GetEquippedFor(long playerId)
{
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
return Ok(ToDto(a));
}
/// <summary>Bare <c>api/avatar/v{2,3}/equipped</c> returns the
/// caller's own equipped avatar. Without the bare path the watch's
/// "what am I wearing" query falls through to the catch-all and
/// gets <c>[]</c>.</summary>
[HttpGet("api/avatar/v2/equipped")]
[HttpGet("api/avatar/v3/equipped")]
[HttpGet("api/avatar/v4/equipped")]
public async Task<ActionResult<AvatarV2Dto>> GetEquippedSelf()
{
var pid = this.RequireCurrentPlayerId();
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
return Ok(ToDto(a));
}
private static AvatarV2Dto ToDto(AvatarEntity? a) => new()
{
OutfitSelections = a?.OutfitSelections ?? string.Empty,
FaceFeatures = a?.FaceFeatures ?? string.Empty,
HairColor = a?.HairColor ?? string.Empty,
SkinColor = a?.SkinColor ?? string.Empty,
};
}
public class AvatarV2Dto
{
[JsonPropertyName("OutfitSelections")]
public string OutfitSelections { get; set; } = string.Empty;
[JsonPropertyName("HairColor")]
public string HairColor { get; set; } = string.Empty;
[JsonPropertyName("SkinColor")]
public string SkinColor { get; set; } = string.Empty;
[JsonPropertyName("FaceFeatures")]
public string FaceFeatures { get; set; } = string.Empty;
}
@@ -1,105 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Avatar.V3;
/// <summary>
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
///
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
/// Slot. Caps at 20 entries per player to keep the column under
/// the SQLite text-blob comfort zone.
/// </summary>
[ApiController]
[Authorize]
public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
{
private const int MaxSlots = 20;
[HttpGet("api/avatar/v1/saved")]
[HttpGet("api/avatar/v2/saved")]
[HttpGet("api/avatar/v3/saved")]
[HttpGet("api/avatar/v4/saved")]
public async Task<IActionResult> List()
{
var pid = this.RequireCurrentPlayerId();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
var list = ParseList(avatar?.SavedOutfitsJson);
return Ok(list);
}
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
/// Body is the SavedOutfit shape directly.</summary>
[HttpPost("api/avatar/v1/saved/set")]
[HttpPost("api/avatar/v3/saved/set")]
[HttpPost("api/avatar/v2/saved/set")]
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
{
if (body is null) return BadRequest("missing body");
var pid = this.RequireCurrentPlayerId();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(avatar);
}
var list = ParseList(avatar.SavedOutfitsJson);
var idx = list.FindIndex(o => o.Slot == body.Slot);
if (idx >= 0) list[idx] = body;
else
{
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
list.Add(body);
}
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(body);
}
[HttpDelete("api/avatar/v3/saved/{slot:int}")]
[HttpDelete("api/avatar/v2/saved/{slot:int}")]
public async Task<IActionResult> Delete(int slot)
{
var pid = this.RequireCurrentPlayerId();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null) return NotFound();
var list = ParseList(avatar.SavedOutfitsJson);
var removed = list.RemoveAll(o => o.Slot == slot);
if (removed == 0) return NotFound();
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok();
}
private static List<SavedOutfitDto> ParseList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
}
catch { return new(); }
}
}
public sealed class SavedOutfitDto
{
[JsonPropertyName("Slot")] public int Slot { get; set; }
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
}
@@ -1,411 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Avatar.V4;
/// <summary>
/// api.rec.net/api/avatar/v{2,3,4}/items — list of avatar items the
/// caller has UNLOCKED. The watch's
/// <c>RecNet.Avatars+&lt;DowloadUnlockedAvatarItems&gt;</c> coroutine
/// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149
/// hits <c>"{api/avatar/}v4/items"</c>) parses the result as
/// <c>List&lt;UnlockedAvatarItem&gt;</c>; the wardrobe UI then
/// renders a "buy" button for any item NOT in this list and an
/// "equip" affordance for items that are.
///
/// Wire shape per
/// Cpp2IL_ISIL/.../Avatars_NestedType_UnlockedAvatarItem.txt:186-212:
/// "AvatarItemType" (int — Outfit=0 / HairDye=1, NOT slot-based),
/// "AvatarItemDesc" (string — the item GUID with three empty
/// customizations appended, see below),
/// "PlatformMask" (int), "FriendlyName" (string),
/// "Tooltip" (string), "Rarity" (int).
///
/// IMPORTANT — <c>AvatarItemDesc</c> format: the client passes this
/// string to <c>AvatarItem.FromRecNetString(string)</c>
/// (Cpp2IL_ISIL/.../RecRoom/Avatar/Data/Runtime/AvatarItem.txt:81-257).
/// The desc is <c>String.Split(',')</c> into <c>guid + customization[0..3]</c>
/// — the inner overload then indexes <c>customization[0]</c>,
/// <c>customization[1]</c>, <c>customization[2]</c> and throws
/// <c>ArgumentException</c> at <c>ArraySegment.get_Current</c> if the
/// segment is shorter than 3. Every desc we ship MUST therefore have
/// at least 4 comma-separated parts, so we suffix bare GUIDs with
/// <c>",,,"</c> (three empty customizations).
///
/// CATALOG SOURCE — <c>data/avatar_item_lookup.json</c>. Generated by
/// the manual/CI-only BepInEx patch under
/// <c>tools/AvatarCatalogDumpPatch</c>. That patch reads the watch's bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// SerializedDictionary at boot and writes every (guid, AvatarItemData)
/// pair to JSON. THIS is the authoritative safe list — any GUID NOT
/// in this file throws <c>InvalidOperationException("Missing data
/// for guid …")</c> inside the watch's wardrobe parser, which aborts
/// the entire coroutine on the first bad item and leaves the drawer
/// empty. Items extracted from <c>outfits_assets_*.bundle</c>
/// MonoBehaviours (the older <c>data/wardrobe_items.json</c>) are
/// MASK + SWATCH asset GUIDs, NOT AvatarItem GUIDs, so that file
/// can't be used as a safelist.
///
/// PER-PLAYER ownership: the watch intersects each store tile's
/// <c>AvatarItemDesc</c> against THIS list to flip the buy-button
/// to "Already Owned." Returning the global wardrobe here therefore
/// makes every store item appear owned. We instead intersect the
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
/// catalog so the watch sees only items the player actually owns.
/// Anonymous calls return [].
/// </summary>
[ApiController]
public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase
{
/// <summary>Master catalog keyed by GUID. Loaded once on first
/// request from <c>data/avatar_item_lookup.json</c>. Same
/// lazy-load idiom as before but the catalog itself is now a
/// dump of the watch's bundled
/// <c>avatarItemDataLookup</c> instead of the asset-bundle
/// MonoBehaviour scan in the previous wardrobe_items.json.</summary>
private static readonly Lazy<CatalogData> _catalog = new(LoadCatalog);
[HttpGet("api/avatar/v2/items")]
[HttpGet("api/avatar/v3/items")]
[HttpGet("api/avatar/v4/items")]
[Authorize]
public async Task<ActionResult<List<UnlockedAvatarItemDto>>> Get()
{
// Keep this switch as a fast kill-switch, but default it on now that
// the response is bounded below. The risky path is not the parser
// itself; it is feeding the 2020 watch too many BrowsableAvatarItem /
// DynamicAvatarItemImposter rows at once.
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
return Ok(new List<UnlockedAvatarItemDto>());
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20);
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
var avatar = await db.Avatars
.Where(a => a.PlayerId == me)
.Select(a => new { a.InventoryJson, a.OutfitSelections })
.FirstOrDefaultAsync();
var owned = ParseInventory(avatar?.InventoryJson);
var ownedHairDyeSlugs = await db.PlayerInventory
.Where(i => i.PlayerId == me && i.Quantity > 0)
.Select(i => i.ItemSlug)
.ToListAsync();
var ownedHairDyes = ownedHairDyeSlugs
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List<UnlockedAvatarItemDto>());
var catalog = _catalog.Value;
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
var ordered = owned
.OrderByDescending(guid => equipped.Contains(guid))
.ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue)
.ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase);
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
var perSlot = new Dictionary<int, int>();
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
foreach (var guid in ordered)
{
if (result.Count >= maxOutfitItems) break;
// Intersect against the safelist. Items not in the bundled
// lookup would crash the watch's wardrobe parser, so they're
// silently dropped here.
if (!catalog.Items.TryGetValue(guid, out var item)) continue;
var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1;
perSlot.TryGetValue(slot, out var count);
if (count >= maxPerSlot && !equipped.Contains(guid)) continue;
perSlot[slot] = count + 1;
result.Add(item);
if (result.Count >= maxItems) break;
}
foreach (var slug in ownedHairDyes)
{
if (result.Count >= maxItems) break;
if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue;
StoreService.TryGetHairDyeName(slug, out var friendlyName);
result.Add(new UnlockedAvatarItemDto
{
AvatarItemType = AvatarItemType.HairDye,
AvatarItemDesc = hairColorGuid,
PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = friendlyName,
Tooltip = string.Empty,
Rarity = 0,
});
}
return Ok(result);
}
/// <summary>The set of GUIDs the bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// contains. Exposed so other controllers (StoreService seed,
/// PlayerService starter inventory) can validate their seed
/// data against the same safe list.</summary>
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
/// <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
/// check.</summary>
public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog
=> _catalog.Value.Items;
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
/// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to
/// build a starter loadout (one per slot) and a starter inventory
/// (a few per slot).</summary>
public static IReadOnlyDictionary<int, List<string>> BySlot
=> _catalog.Value.BySlot;
public static bool TryGetOutfitType(string guid, out int outfitType) =>
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
private static List<string> ParseInventory(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
var strings = JsonSerializer.Deserialize<List<string>>(json);
if (strings is not null) return strings;
}
catch { }
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
var result = new List<string>();
foreach (var element in doc.RootElement.EnumerateArray())
{
string? value = null;
if (element.ValueKind == JsonValueKind.String)
value = element.GetString();
else if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty("itemId", out var itemId) &&
itemId.ValueKind == JsonValueKind.String)
value = itemId.GetString();
if (string.IsNullOrWhiteSpace(value)) continue;
const string wardrobePrefix = "wardrobe-";
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
value = value[wardrobePrefix.Length..];
var comma = value.IndexOf(',');
if (comma >= 0)
value = value[..comma];
result.Add(value);
}
return result
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch { return new(); }
}
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(outfitSelections)) return result;
foreach (var selection in outfitSelections.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var comma = selection.IndexOf(',');
var guid = comma >= 0 ? selection[..comma] : selection;
if (!string.IsNullOrWhiteSpace(guid))
result.Add(guid.Trim());
}
return result;
}
private sealed record CatalogData(
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
IReadOnlySet<string> SafeGuids,
IReadOnlyDictionary<int, List<string>> BySlot,
IReadOnlyDictionary<string, int> SlotByGuid);
private static CatalogData LoadCatalog()
{
// Try both lowercase 'data/' (csproj None+Link target) and
// uppercase 'Data/' (git-tracked source folder) so case-sensitivity
// mismatches between Windows (where the dev wrote the path) and
// Linux (where Coolify builds the container) can't lose the lookup
// file. Without this fallback, a single missed file → empty
// SafeGuids → StoreService.SeedAsync skips its stale-row cleanup
// → 418 swatch-GUID rows ship live → every store tile renders as
// Hat_Angler.
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "data", "avatar_item_lookup.json"),
Path.Combine(AppContext.BaseDirectory, "Data", "avatar_item_lookup.json"),
Path.Combine(Directory.GetCurrentDirectory(), "data", "avatar_item_lookup.json"),
Path.Combine(Directory.GetCurrentDirectory(), "Data", "avatar_item_lookup.json"),
};
var path = candidates.FirstOrDefault(System.IO.File.Exists);
Console.WriteLine(path is null
? $"[avatar-catalog] avatar_item_lookup.json not found in any of: {string.Join(", ", candidates)}"
: $"[avatar-catalog] loading from {path}");
if (path is null) return new CatalogData(
new Dictionary<string, UnlockedAvatarItemDto>(),
new HashSet<string>(),
new Dictionary<int, List<string>>(),
new Dictionary<string, int>());
using var fs = System.IO.File.OpenRead(path);
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new LookupFile();
var items = new Dictionary<string, UnlockedAvatarItemDto>(dump.Items.Count, StringComparer.OrdinalIgnoreCase);
var safe = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var bySlot = new Dictionary<int, List<string>>();
var slotByGuid = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var row in dump.Items)
{
if (string.IsNullOrEmpty(row.Guid)) continue;
safe.Add(row.Guid);
// Rec Room's bundled item names are wrapped in parens like
// "(Hat_Wizard)" — strip those and the underscores for a
// friendlier UI label.
var pretty = PrettyName(row.Name);
items[row.Guid] = new UnlockedAvatarItemDto
{
// AvatarItemType is the wire enum the watch parses
// (Outfit=0 / HairDye=1), not the slot. Rows in
// avatarItemDataLookup are wearable avatar items, including
// hair meshes that *use* hair color. They must still be sent
// as Outfit; sending HairDye makes the client treat the item
// GUID as a color GUID and log "Can not find hair dye color".
AvatarItemType = AvatarItemType.Outfit,
AvatarItemDesc = row.Guid + ",,,",
PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = pretty,
Tooltip = string.Empty,
Rarity = 0,
};
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
bySlot[row.OutfitType] = lst = new List<string>();
lst.Add(row.Guid);
slotByGuid[row.Guid] = row.OutfitType;
}
return new CatalogData(items, safe, bySlot, slotByGuid);
}
private static string PrettyName(string raw)
{
if (string.IsNullOrEmpty(raw)) return string.Empty;
// Strip leading/trailing parens that the bundled config wraps
// names in, then turn underscores into spaces. Keep it simple —
// the watch shows whatever string we send.
var trimmed = raw.Trim().TrimStart('(').TrimEnd(')');
// Drop common asset-name suffixes. Items in the runtime config
// use the raw asset name (e.g. "Hat_Wizard", "shirt_sherlock");
// the watch never normalises these, so we do it server-side.
var s = trimmed.Replace('_', ' ').Trim();
return s;
}
/// <summary>Shape of <c>data/avatar_item_lookup.json</c> as written
/// by <c>tools/AvatarCatalogDumpPatch</c>.</summary>
private sealed class LookupFile
{
[JsonPropertyName("items")]
public List<LookupItem> Items { get; set; } = new();
// Auxiliary GUID lists (prefab/mask/swatch/decal). Not used
// server-side yet — they'd matter for resolving asset
// references the watch might subsequently request, but the
// wire protocol only ever ships the AvatarItem GUID.
[JsonPropertyName("prefab_guids")]
public List<string> PrefabGuids { get; set; } = new();
[JsonPropertyName("mask_guids")]
public List<string> MaskGuids { get; set; } = new();
[JsonPropertyName("swatch_guids")]
public List<string> SwatchGuids { get; set; } = new();
[JsonPropertyName("decal_guids")]
public List<string> DecalGuids { get; set; } = new();
}
private sealed class LookupItem
{
[JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty;
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("outfit_type")] public int OutfitType { get; set; }
[JsonPropertyName("outfit_set")] public int OutfitSet { get; set; }
[JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; }
[JsonPropertyName("hides_hair")] public bool HidesHair { get; set; }
[JsonPropertyName("hides_hand")] public bool HidesHand { get; set; }
[JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; }
[JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; }
[JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; }
}
/// <summary>Mirrors <c>RecNet.Avatars+AvatarItemType</c>
/// (Avatars.cs:186-190).</summary>
private static class AvatarItemType
{
public const int Outfit = 0;
public const int HairDye = 1;
}
/// <summary>OutfitType slot enum mirrored from
/// <c>RecRoom.Avatar.Data.Shared.OutfitType</c>. Used by other
/// callers (PlayerService, StoreService) for slot-aware seeding.</summary>
public static class Slot
{
public const int Hat = 0;
public const int BackHead = 1;
public const int Hair = 2;
public const int Eye = 10;
public const int Mouth = 20;
public const int Neck = 100;
public const int Shirt = 101;
public const int Belt = 102;
public const int Pocket = 103;
public const int TeamShirt = 104;
public const int Wrist = 200;
public const int Glove = 201;
public const int Watch = 202;
public const int TeamWrist = 203;
}
}
public class UnlockedAvatarItemDto
{
[JsonPropertyName("AvatarItemType")]
public int AvatarItemType { get; set; }
[JsonPropertyName("AvatarItemDesc")]
public string AvatarItemDesc { get; set; } = string.Empty;
[JsonPropertyName("PlatformMask")]
public int PlatformMask { get; set; }
[JsonPropertyName("FriendlyName")]
public string FriendlyName { get; set; } = string.Empty;
[JsonPropertyName("Tooltip")]
public string Tooltip { get; set; } = string.Empty;
[JsonPropertyName("Rarity")]
public int Rarity { get; set; }
}
@@ -1,88 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.BugReporting;
/// <summary>
/// api.rec.net/api/bugreporting/* — in-game "Report a Bug" UI.
/// Wire request DTO matches <c>RecNet.BugReporting+BugReportDTO</c>
/// (<c>Cpp2IL_CS/.../RecNet/BugReporting.cs</c>): <c>Summary,
/// Description, TestCaseKey, BuildVersion, BuildTimestamp,
/// BundleVersionCode</c>.
///
/// Plus <c>POST /api/bugreporting/v1/submit</c> (legacy v1 path) and
/// <c>POST /api/bugreporting/v2/submit</c> (renamed v2). The
/// <c>ReportBug</c> client API also includes screenshot + log byte
/// arrays as multipart parts; we accept-and-discard those (no
/// storage flow yet) but persist the text fields.
/// </summary>
[ApiController]
[Authorize]
public class BugReportsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
public sealed class BugReportRequest
{
public string? Summary { get; set; }
public string? Description { get; set; }
public string? TestCaseKey { get; set; }
public string? BuildVersion { get; set; }
public long? BuildTimestamp { get; set; }
public int? BundleVersionCode { get; set; }
}
[HttpPost("api/bugreporting/v1/submit")]
[HttpPost("api/bugreporting/v2/submit")]
[HttpPost("api/bugreporting/v1/reportbug")]
[HttpPost("api/bugreporting/v2/reportbug")]
[Consumes("application/json")]
public async Task<IActionResult> SubmitJson([FromBody] BugReportRequest req)
=> await PersistAsync(req);
/// <summary>Multipart form variant — the watch posts screenshot
/// + log as separate parts. We pull the text-only fields and
/// drop the binary attachments.</summary>
[HttpPost("api/bugreporting/v1/submit-multipart")]
[HttpPost("api/bugreporting/v2/reportbug-multipart")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> SubmitMultipart(
[FromForm(Name = "Summary")] string? summary,
[FromForm(Name = "Description")] string? description,
[FromForm(Name = "TestCaseKey")] string? testCaseKey,
[FromForm(Name = "BuildVersion")] string? buildVersion,
[FromForm(Name = "BuildTimestamp")] long? buildTimestamp)
=> await PersistAsync(new BugReportRequest
{
Summary = summary,
Description = description,
TestCaseKey = testCaseKey,
BuildVersion = buildVersion,
BuildTimestamp = buildTimestamp,
});
private async Task<IActionResult> PersistAsync(BugReportRequest req)
{
var pid = Me;
var summary = (req.Summary ?? string.Empty).Trim();
var body = (req.Description ?? string.Empty).Trim();
if (summary.Length == 0 && body.Length == 0)
return BadRequest(new { Error = "summary or description required" });
db.BugReports.Add(new BugReportEntity
{
ReporterPlayerId = pid,
Title = summary[..Math.Min(128, summary.Length)],
Body = body[..Math.Min(4000, body.Length)],
ClientVersion = req.BuildVersion ?? string.Empty,
Platform = HttpContext.Request.Headers.UserAgent.ToString()[..Math.Min(32, HttpContext.Request.Headers.UserAgent.ToString().Length)],
Category = string.IsNullOrEmpty(req.TestCaseKey) ? "bug" : $"bug:{req.TestCaseKey}",
});
await db.SaveChangesAsync();
return Ok(new { Submitted = true });
}
}
@@ -1,40 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.Cards;
/// <summary>
/// api.{rec.net,localhost}/api/cards/v1/all — home-screen card panel
/// (announcements, promos, daily-bonus tiles). Returns global cards
/// PLUS any cards targeted at the caller, sorted by Priority then
/// CreatedAt. Expired cards filtered out server-side.
/// </summary>
[ApiController]
public class CardsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/cards/v1/all")]
public async Task<IActionResult> All()
{
var pid = this.CurrentPlayerId();
var now = DateTime.UtcNow;
var rows = await db.Cards
.Where(c => (c.PlayerId == null || c.PlayerId == pid) &&
(c.ExpiresAt == null || c.ExpiresAt > now))
.OrderByDescending(c => c.Priority).ThenByDescending(c => c.CreatedAt)
.Take(50)
.Select(c => new
{
c.Id,
c.Title,
c.Body,
c.ImageName,
c.ActionUrl,
c.Category,
c.CreatedAt,
})
.ToListAsync();
return Ok(rows);
}
}
@@ -1,89 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.CommunityBoard;
/// <summary>
/// api.rec.net/api/communityboard/v1/current — backs the dorm's
/// community board. The watch's
/// <c>RecNet.CommunityBoard.GetCurrent</c> hits
/// <c>"{api/communityboard/}v1/current"</c>
/// (Cpp2IL_ISIL/.../CommunityBoard.txt:902) and parses the response as
/// a <c>CommunityBoardDTO</c> with FeaturedPlayer / FeaturedRoomGroup /
/// CurrentAnnouncement / InstagramImages / Videos
/// (CommunityBoard_NestedType_CommunityBoardDTO.txt:460-480).
///
/// State lives in <see cref="CommunityBoardService"/> as a single
/// DB row keyed at <c>CommunityBoardRows.Id = 1</c>. Admins update it
/// via <c>POST /api/admin/v1/communityboard</c> from the admin UI.
/// </summary>
[ApiController]
public class CommunityBoardController(
CommunityBoardService board,
DorkNetDbContext db,
DomainConfig domain) : ControllerBase
{
[HttpGet("/api/communityboard/v1/current")]
[HttpGet("/api/communityboard/v2/current")]
public async Task<IActionResult> Current()
{
var state = await board.GetAsync();
// InstagramImage URL backfill — the watch's
// InstagramImageDTO.Deserialize calls a string-typed GetKey for
// both ImageName AND ImageUrl
// (CommunityBoard_NestedType_InstagramImageDTO.txt:85-91); an
// empty URL yields a broken tile. The admin SPA persists only
// ImageName, so we derive the URL on read against the configured
// deployment apex (DORKNET_DOMAIN).
var instagram = state.InstagramImages.Select(img => new
{
ImageName = img.ImageName,
ImageUrl = string.IsNullOrEmpty(img.ImageUrl) && !string.IsNullOrEmpty(img.ImageName)
? $"https://{domain.Sub("img")}/{img.ImageName}"
: img.ImageUrl,
}).ToArray();
// FeaturedRoomGroup expansion — the watch's FeaturedRoomGroupDTO
// (Rooms_NestedType_FeaturedRoomGroupDTO.txt:75-93) reads
// FeaturedRooms as <c>List&lt;FeaturedRoom&gt;</c> where each
// FeaturedRoom is <c>{RoomName, RoomId, ImageName}</c> with
// RoomId required (Util.GetKey at FeaturedRoom.txt:415-417).
// The admin SPA persists the list as a bare <c>List&lt;long&gt;</c>
// of room ids — which the watch can't deserialize, throwing
// KeyNotFoundException for "RoomId" on each item. With the list
// throwing, GetCurrentCommunityBoardData fails and the whole
// community-board panel renders empty. Expand the ids to full
// FeaturedRoom shapes here so the wire payload deserialises.
object? featuredRoomGroup = null;
if (state.FeaturedRoomGroup is { } group)
{
var ids = group.FeaturedRooms.ToList();
var rooms = ids.Count == 0
? new()
: await db.Rooms
.Where(r => ids.Contains(r.Id))
.Select(r => new { r.Id, r.Name, r.ImageName })
.ToDictionaryAsync(r => r.Id);
featuredRoomGroup = new
{
Name = group.Name ?? string.Empty,
FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r)
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
.ToArray(),
};
}
return Ok(new
{
FeaturedPlayer = state.FeaturedPlayer,
FeaturedRoomGroup = featuredRoomGroup,
CurrentAnnouncement = state.CurrentAnnouncement,
InstagramImages = instagram,
Videos = state.Videos,
});
}
}
@@ -1,72 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V1;
[ApiController]
[Route("api/[controller]/v1")]
public class ConfigController(ConfigService configService) : ControllerBase
{
[HttpGet("motd")]
public ActionResult<string> GetMotd()
{
var cfg = configService.GetConfig($"{Request.Scheme}://{Request.Host}");
return Ok(cfg.MessageOfTheDay);
}
[HttpGet("objectives")]
public ActionResult<List<object>> GetObjectives() => Ok(new List<object>());
// AmplitudeConfig.Deserialize expects key "AmplitudeKey"
[HttpGet("amplitude")]
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
/// <summary>
/// `Config.GetCohortNUXButtonConfigs(cohortId)` — returns the four
/// "What would you like to do first?" buttons shown in the OOBE
/// (RegistrationSceneNUX → ChooseCohortScreen). Each button maps to
/// a room the new player can teleport into.
///
/// Wire shape per CohortNUXButtonConfig (TypeDefIndex 11516) — list of:
/// Version (int)
/// ButtonNumber (int, 0..3 → Button1..Button4)
/// Override (int, 0=None, 1=Hotlist, 2=Custom)
/// CustomRoomName (string)
/// CustomTitle (string)
/// DefaultRoomName (string)
/// DefaultTitle (string)
///
/// The screen picks `CustomRoomName` if Override=Custom, else the
/// `DefaultRoomName` (resolved by Rooms.GetByName). For simplicity all
/// four use Override=None and route to Rec Room Original rooms we
/// already seeded — picking the most newcomer-friendly ones.
/// </summary>
[HttpGet("cohortnux/{cohortId:int}")]
public IActionResult GetCohortNux(int cohortId) => Ok(new[]
{
new {
Version = 1, ButtonNumber = 0, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "RecCenter",
DefaultTitle = "Hang out at the Rec Center",
},
new {
Version = 1, ButtonNumber = 1, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "Paintball",
DefaultTitle = "Play Paintball",
},
new {
Version = 1, ButtonNumber = 2, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "Dodgeball",
DefaultTitle = "Play Dodgeball",
},
new {
Version = 1, ButtonNumber = 3, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "GoldenTrophy",
DefaultTitle = "Quest for the Golden Trophy",
},
});
}
@@ -1,19 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V1;
/// <summary>
/// Returns the full Rec Room service URL map. Some builds fetch this standalone
/// at /api/config/v1/services to discover where the other subdomains live.
/// Apex comes from <see cref="DomainConfig"/> (DORKNET_DOMAIN), so a single
/// env var controls every URL emitted.
/// </summary>
[ApiController]
[Route("api/config/v1")]
public class ServiceUrlsController(DomainConfig domain) : ControllerBase
{
[HttpGet("services")]
public ActionResult<Dictionary<string, string>> Get()
=> Ok(ConfigService.BuildServiceUrlMap(domain));
}
@@ -1,17 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.Config;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V2;
[ApiController]
[Route("api/[controller]/v2")]
public class ConfigController(ConfigService configService) : ControllerBase
{
[HttpGet]
public ActionResult<RecRoomConfig> GetConfig()
{
var baseUrl = $"{Request.Scheme}://{Request.Host}";
return Ok(configService.GetConfig(baseUrl));
}
}
@@ -1,11 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Events.V3;
[ApiController]
[Route("api/[controller]/v3")]
public class EventsController : ControllerBase
{
[HttpGet("list")]
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
}
@@ -1,15 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.GameConfigurations;
[ApiController]
public class GameConfigurationsController : ControllerBase
{
// Actual path used by the 2020 build
[HttpGet("api/gameconfigs/v1/all")]
[HttpGet("api/gameconfigs/v1")]
// Legacy path we originally assumed
[HttpGet("api/gameconfigurations/v1/all")]
[HttpGet("api/gameconfigurations/v1")]
public ActionResult<List<object>> GetAll() => Ok(new List<object>());
}
@@ -1,67 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.GameSessions;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.GameSessions.V2;
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class GameSessionsController(GameSessionService sessionService) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpPost("joinRandom")]
public async Task<ActionResult<JoinGameSessionResponse>> JoinRandom([FromBody] JoinRandomGameSessionRequest req)
{
var session = await sessionService.JoinOrCreateAsync(req.RoomId, req.ActivityLevelId, req.RegionId);
return Ok(new JoinGameSessionResponse
{
Result = JoinGameErrorCode.Success,
GameSession = session,
});
}
[HttpPost("joinById")]
public async Task<ActionResult<JoinGameSessionResponse>> JoinById([FromBody] JoinByIdRequest req)
{
var session = await sessionService.GetByIdAsync(req.GameSessionId);
if (session is null)
{
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.NotFound });
}
if (session.IsFull)
{
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Full });
}
return Ok(new JoinGameSessionResponse
{
Result = JoinGameErrorCode.Success,
GameSession = session,
});
}
[HttpPost("leave")]
public async Task<ActionResult> Leave([FromBody] long sessionId)
{
await sessionService.PlayerLeftAsync(sessionId);
return Ok();
}
[HttpGet("{id:long}")]
public async Task<ActionResult<GameSession>> GetSession(long id)
{
var session = await sessionService.GetByIdAsync(id);
if (session is null) return NotFound();
return Ok(session);
}
}
public class JoinByIdRequest
{
public long GameSessionId { get; set; }
}
@@ -1,240 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Groups;
/// <summary>
/// api.rec.net/api/groups/* — clubs / groups. Wire types from
/// <c>RecNet.Group</c> + <c>RecNet.GroupMembership</c>:
///
/// • Group: <c>GroupId, Name, Description, CreatedAt, ImageName,
/// BanStatus, CreatorId, NumMembers, Members</c>.
/// • GroupMembership: <c>GroupId, PlayerId, Permissions</c>
/// (GroupMembershipPermissions flags).
///
/// URL surface (verb + template) verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Groups.txt</c>:
/// GET api/groups/v1/{id}
/// GET api/groups/v1/name/{name}
/// GET api/groups/v1/memberships/{playerId}
/// POST api/groups/v1 (create — body CreateGroupRequest)
/// POST api/groups/v1/delete/{id}
/// </summary>
[ApiController]
public class GroupsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
private long? MeOrNull => this.CurrentPlayerId();
[HttpGet("api/groups/v1/{id:long}")]
public async Task<IActionResult> GetById(long id)
{
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
var members = await db.ClubMemberships
.Where(m => m.ClubId == id)
.ToListAsync();
return Ok(ToWire(g, members));
}
[HttpGet("api/groups/v1/name/{name}")]
public async Task<IActionResult> GetByName(string name)
{
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Name == name);
if (g is null) return NotFound();
var members = await db.ClubMemberships
.Where(m => m.ClubId == g.Id)
.ToListAsync();
return Ok(ToWire(g, members));
}
[HttpGet("api/groups/v1/memberships/{playerId:long}")]
public async Task<IActionResult> Memberships(long playerId)
{
var rows = await (from m in db.ClubMemberships
join c in db.Clubs on m.ClubId equals c.Id
where m.PlayerId == playerId
select new { m, c }).ToListAsync();
return Ok(rows.Select(r => new
{
GroupId = r.c.Id,
PlayerId = (int)r.m.PlayerId,
Permissions = r.m.Permissions,
Group = ToWire(r.c, new List<ClubMembershipEntity>()),
}));
}
public sealed class CreateGroupRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
}
[HttpPost("api/groups/v1")]
[Authorize]
public async Task<IActionResult> Create([FromBody] CreateGroupRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
return BadRequest(new { Result = 1, Error = "name required" });
var pid = Me;
var dupe = await db.Clubs.AnyAsync(c => c.Name == req.Name);
if (dupe) return Conflict(new { Result = 2, Error = "name taken" });
var club = new ClubEntity
{
Name = req.Name.Trim(),
Description = req.Description ?? string.Empty,
ImageName = req.ImageName ?? string.Empty,
CreatorPlayerId = pid,
BanStatus = 0,
};
db.Clubs.Add(club);
await db.SaveChangesAsync();
// Owner membership uses GroupMembershipPermissions.Owner = 127.
db.ClubMemberships.Add(new ClubMembershipEntity
{
ClubId = club.Id,
PlayerId = pid,
Permissions = 127,
});
await db.SaveChangesAsync();
return Ok(new
{
Result = 0,
Group = ToWire(club, new List<ClubMembershipEntity>
{
new() { ClubId = club.Id, PlayerId = pid, Permissions = 127 },
}),
});
}
public sealed class UpdateGroupRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
}
/// <summary>POST <c>api/groups/v1/{id}</c> — owner-only update of
/// description / image (per <c>RecNet.Groups.UpdateGroup</c> body
/// shape). Mirrors the create response so the watch's
/// <c>CreateModifyGroupResponse</c> deserialiser parses both.</summary>
[HttpPost("api/groups/v1/{id:long}")]
[Authorize]
public async Task<IActionResult> Update(long id, [FromBody] UpdateGroupRequest req)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId != pid) return Forbid();
if (req.Description is not null) g.Description = req.Description;
if (req.ImageName is not null) g.ImageName = req.ImageName;
// Name updates are routed to the dedicated endpoint below
// (uniqueness check + name index has to round-trip).
g.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
return Ok(new { Result = 0, Group = ToWire(g, members) });
}
public sealed class UpdateGroupNameRequest { public string? Name { get; set; } }
/// <summary>POST <c>api/groups/v1/name/{id}</c> — owner-only
/// rename. Unique-name collision returns Result=2 to match the
/// client's <c>CreateModifyGroupStatus</c> enum.</summary>
[HttpPost("api/groups/v1/name/{id:long}")]
[Authorize]
public async Task<IActionResult> UpdateName(long id, [FromBody] UpdateGroupNameRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
return BadRequest(new { Result = 1, Error = "name required" });
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId != pid) return Forbid();
var dupe = await db.Clubs.AnyAsync(c => c.Id != id && c.Name == req.Name);
if (dupe) return Conflict(new { Result = 2, Error = "name taken" });
g.Name = req.Name.Trim();
g.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
return Ok(new { Result = 0, Group = ToWire(g, members) });
}
[HttpPost("api/groups/v1/delete/{id:long}")]
[HttpDelete("api/groups/v1/delete/{id:long}")]
[Authorize]
public async Task<IActionResult> Delete(long id)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId != pid) return Forbid();
var memberships = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
db.ClubMemberships.RemoveRange(memberships);
db.Clubs.Remove(g);
await db.SaveChangesAsync();
return Ok(new { Deleted = true });
}
[HttpPost("api/groups/v1/{id:long}/join")]
[Authorize]
public async Task<IActionResult> Join(long id)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
var existing = await db.ClubMemberships
.FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid);
if (existing is not null) return Ok(new { Joined = true });
db.ClubMemberships.Add(new ClubMembershipEntity
{
ClubId = id,
PlayerId = pid,
Permissions = 0, // Member
});
await db.SaveChangesAsync();
return Ok(new { Joined = true });
}
[HttpPost("api/groups/v1/{id:long}/leave")]
[Authorize]
public async Task<IActionResult> Leave(long id)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId == pid)
return BadRequest(new { Error = "owner cannot leave; transfer ownership first" });
var row = await db.ClubMemberships
.FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid);
if (row is null) return NotFound();
db.ClubMemberships.Remove(row);
await db.SaveChangesAsync();
return Ok(new { Left = true });
}
private static object ToWire(ClubEntity g, List<ClubMembershipEntity> members) => new
{
GroupId = g.Id,
g.Name,
g.Description,
g.CreatedAt,
g.ImageName,
BanStatus = g.BanStatus,
CreatorId = (int)g.CreatorPlayerId,
NumMembers = members.Count,
Members = members.Select(m => new
{
GroupId = g.Id,
PlayerId = (int)m.PlayerId,
Permissions = m.Permissions,
}),
};
}
@@ -1,859 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Http;
using System.Text.Json;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Images;
/// <summary>
/// api.rec.net/api/images/v* — in-game camera photo endpoints. The 2020
/// watch's camera UI ("save photo to album", "share photo") posts here.
///
/// Wire shape (observed from server logs — the 2020 watch hits these
/// without a corresponding storage.rec.net/upload, meaning the bytes
/// come straight in this request body):
/// POST api/images/v4/uploadsaved
/// - body: multipart/form-data with the image file plus optional
/// fields (Caption, Description, RoomId, IsPublic, TaggedPlayerIds).
/// On the 2020 client the field name varies — we accept "File",
/// "Image", and any *.png/*.jpg upload for safety.
/// - response: an ImageInfo-shaped object (PascalCase keys). The
/// watch's success path calls a JSON parser whose strict keys are
/// AccountId, ImageName, CreatedAt — so all three are required.
/// Returning [] (the previous catch-all) made the watch silently
/// drop the photo.
///
/// Saved photos are stored as <see cref="PhotoEntity"/> rows with
/// IsPublic=false by default. A separate Share button (POST
/// api/images/v4/share/{id}) flips IsPublic=true and surfaces them on
/// feed.rec.net.
/// </summary>
[ApiController]
public class ImagesController(
DorkNetDbContext db,
PlayerPresenceService presence,
LevelService level,
IObjectStorage storage,
ImageSignatureService signatures,
NotificationService notifications,
DomainConfig domain,
ILogger<ImagesController> logger) : ControllerBase
{
private static readonly string ImageDir =
Path.Combine(AppContext.BaseDirectory, "data", "images");
private static readonly byte[] TransparentPng = new byte[]
{
0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,
0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,
0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4,0x89,0x00,0x00,0x00,
0x0D,0x49,0x44,0x41,0x54,0x78,0x9C,0x63,0x00,0x01,0x00,0x00,
0x05,0x00,0x01,0x0D,0x0A,0x2D,0xB4,0x00,0x00,0x00,0x00,0x49,
0x45,0x4E,0x44,0xAE,0x42,0x60,0x82,
};
/// <summary>POST api/images/v{2-5}/uploadsaved — receive an image
/// from the in-game camera "save to album" button. Persists the
/// bytes in <see cref="RoomDataBlobEntity"/> and creates a
/// <see cref="PhotoEntity"/> row keyed by the uploader. The photo
/// defaults to IsPublic=false (saved-only) so it doesn't surface
/// on the public feed unless the user explicitly hits Share.</summary>
[HttpPost("api/images/v2/uploadsaved")]
[HttpPost("api/images/v3/uploadsaved")]
[HttpPost("api/images/v4/uploadsaved")]
[HttpPost("api/images/v5/uploadsaved")]
// 2018 flat-screen (non-VR) camera save posts a single 'image' part to
// uploadsavedsingle (RecNet.cs:35080); ReadImageBytesAsync already falls
// back to the first uploaded file, so the same handler serves it.
[HttpPost("api/images/v2/uploadsavedsingle")]
[HttpPost("api/images/v3/uploadsavedsingle")]
[HttpPost("api/images/v4/uploadsavedsingle")]
[HttpPost("api/images/v5/uploadsavedsingle")]
[Authorize]
public async Task<IActionResult> UploadSaved()
{
var pid = this.RequireCurrentPlayerId();
var bytes = await ReadImageBytesAsync();
if (bytes is null || bytes.Length == 0)
{
logger.LogWarning("[images] uploadsaved by {Pid}: no file bytes received", pid);
return BadRequest(new { error = "missing_file" });
}
// Pull optional metadata from the form (the 2020 watch sometimes
// includes a caption / room context, sometimes posts bytes only).
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty;
var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty;
long roomId = 0;
if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid;
if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0;
var isPublic = string.Equals(form?["IsPublic"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
var isProfileThumbnail = IsProfileThumbnail(form);
LogUploadForm("uploadsaved", pid, form, isProfileThumbnail);
// Detect format. The watch uses PNG; if a JPEG slips through we
// tag it accordingly so the cdn serves the right MIME.
var extension = SniffExtension(bytes);
var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}";
// Dual-write during the migration window: S3 is the canonical
// store going forward, but we keep the DB Bytes column populated
// so reads from older code paths still work. PR 4 drops the
// column once every blob has a confirmed S3 copy.
try
{
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4));
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(
bucket, key, bytes,
extension == "png" ? "image/png" : "image/jpeg",
s3Timeout.Token);
}
catch (Exception ex)
{
logger.LogError(
ex,
"[images] S3 upload failed for {Blob}; keeping DB fallback so the client does not fail",
blobName);
}
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = pid,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = string.Empty,
});
var photo = new PhotoEntity
{
UploaderPlayerId = pid,
BlobName = blobName,
Caption = caption.Trim(),
TaggedPlayerIdsCsv = NormaliseTaggedIds(taggedIds, exceptId: pid),
RoomId = roomId,
IsPublic = isPublic,
};
db.Photos.Add(photo);
if (isProfileThumbnail)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null)
{
player.ProfileImageName = blobName;
logger.LogInformation(
"[images] profile thumbnail upload set profile image: player={PlayerId} blob={Blob}",
pid,
blobName);
}
}
await db.SaveChangesAsync();
if (isProfileThumbnail)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null)
await NotifyAccountImageChangedAsync(player);
}
// Small XP bump for taking a photo — encourages camera use.
await level.AwardXpAsync(pid, LevelService.RoomVisitXp, $"photo_saved:{photo.Id}");
logger.LogInformation(
"[images] uploaded saved photo {Id} by {Pid} ({Bytes} bytes) → {Blob}",
photo.Id, pid, bytes.Length, blobName);
return Ok(BuildImageInfo(photo, pid));
}
/// <summary>POST api/images/v{1-5}/profile — direct profile image upload.
/// Older clients use this instead of tagging an uploadsaved request as a
/// ProfileThumbnail, so this route must always update Account.ProfileImage.</summary>
[HttpPost("api/images/v1/profile")]
[HttpPost("api/images/v2/profile")]
[HttpPost("api/images/v3/profile")]
[HttpPost("api/images/v4/profile")]
[HttpPost("api/images/v5/profile")]
[Authorize]
public async Task<IActionResult> UploadProfile()
{
var pid = this.RequireCurrentPlayerId();
var bytes = await ReadImageBytesAsync();
if (bytes is null || bytes.Length == 0)
{
logger.LogWarning("[images] profile upload by {Pid}: no file bytes received", pid);
return BadRequest(new { error = "missing_file" });
}
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
LogUploadForm("profile", pid, form, isProfile: true);
var extension = SniffExtension(bytes);
var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}";
var contentType = extension == "png" ? "image/png" : "image/jpeg";
try
{
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4));
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(bucket, key, bytes, contentType, s3Timeout.Token);
}
catch (Exception ex)
{
logger.LogError(
ex,
"[images] profile S3 upload failed for {Blob}; keeping DB fallback so the client does not fail",
blobName);
}
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = pid,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = string.Empty,
});
var photo = new PhotoEntity
{
UploaderPlayerId = pid,
BlobName = blobName,
Caption = string.Empty,
TaggedPlayerIdsCsv = string.Empty,
RoomId = presence.GetRoom(pid)?.RoomId ?? 0,
IsPublic = false,
};
db.Photos.Add(photo);
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null)
player.ProfileImageName = blobName;
await db.SaveChangesAsync();
if (player is not null)
await NotifyAccountImageChangedAsync(player);
logger.LogInformation(
"[images] uploaded profile photo {Id} by {Pid} ({Bytes} bytes) -> {Blob}",
photo.Id, pid, bytes.Length, blobName);
return Ok(new
{
ImageName = blobName,
AccountId = (int)pid,
CreatedAt = photo.CreatedAt,
});
}
/// <summary>GET api/images/v{2-5}/saved — list the caller's saved
/// photos. The watch's "My Photos" tab calls this to populate the
/// album view.</summary>
[HttpGet("api/images/v2/saved")]
[HttpGet("api/images/v3/saved")]
[HttpGet("api/images/v4/saved")]
[HttpGet("api/images/v5/saved")]
[Authorize]
public async Task<IActionResult> GetSaved()
{
var pid = this.RequireCurrentPlayerId();
var rows = await db.Photos
.Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Take(200)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
}
/// <summary>GET <c>api/images/v2/named</c> — the watch's
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
/// response (the watch's <c>ExpectListResponse</c> rejects an
/// object body but tolerates an empty array).</summary>
[HttpGet("api/images/v2/named")]
public IActionResult NamedImages() => Ok(Array.Empty<object>());
/// <summary>GET <c>api/images/v1/slideshow</c> — the watch's lobby
/// slideshow info. Response shape per <c>SlideshowInfoDTO.Deserialize</c>:
/// <c>{ValidTill (DateTime, required), Images (list, required)}</c>.
/// Far-future <c>ValidTill</c> stops the watch from re-polling.</summary>
[HttpGet("api/images/v1/slideshow")]
public IActionResult SlideshowInfo() => Ok(new
{
// WireDates.FarFuture (2099), NOT year 9999 — a 9999 ValidTill
// overflows the watch's local-timezone date parse in some DST
// timezones → "malformed RecNet response". See WireDates.
ValidTill = WireDates.FarFuture,
Images = Array.Empty<object>(),
});
/// <summary>Binary image fallback for clients that request image bytes
/// through api/images/{name} instead of img.rec.net/{name}. Specific JSON
/// endpoints above still win route matching; this only handles CDN-shaped
/// image paths.</summary>
[HttpGet("api/images/{*path}", Order = 100)]
public async Task<IActionResult> ServeImageFromApiHost(string? path)
{
var fileName = Path.GetFileName(path ?? string.Empty);
// Strip the leading '$' sigil that ImageData.image_name uses for
// hash-addressed assets. Watch sends "%24<hash>.jpg"; route
// decode gives us "$<hash>.jpg"; the bytes are stored at
// "<hash>.jpg". Without the strip, placed in-world polaroids
// miss in S3 and the watch renders the "?" fallback.
if (!string.IsNullOrEmpty(fileName) && fileName[0] == '$') fileName = fileName[1..];
if (string.IsNullOrWhiteSpace(fileName) || !IsImageName(fileName))
return NotFound();
var result = await LoadImageBytesAsync(fileName);
if (result.Bytes is { Length: > 0 })
{
logger.LogInformation(
"[images] api image hit host={Host} path={Path} file={File} source={Source} bytes={Bytes} query='{Query}'",
Request.Host.Host,
path,
fileName,
result.Source,
result.Bytes.Length,
Request.QueryString);
Response.Headers.CacheControl = "public, max-age=300";
signatures.AddContentSignature(Response, result.Bytes);
return File(result.Bytes, MimeFromName(fileName));
}
logger.LogWarning(
"[images] api image MISS host={Host} path={Path} file={File} query='{Query}' -> transparent png",
Request.Host.Host,
path,
fileName,
Request.QueryString);
Response.Headers.CacheControl = "public, max-age=60";
signatures.AddContentSignature(Response, TransparentPng);
return File(TransparentPng, "image/png");
}
/// <summary>GET api/images/v{2-5}/byaccount/{accountId} — list a
/// player's PUBLIC photos. Drives the "their album" tab in
/// the watch's player profile.</summary>
[HttpGet("api/images/v2/byaccount/{accountId:long}")]
[HttpGet("api/images/v3/byaccount/{accountId:long}")]
[HttpGet("api/images/v4/byaccount/{accountId:long}")]
[HttpGet("api/images/v5/byaccount/{accountId:long}")]
public async Task<IActionResult> GetByAccount(long accountId,
[FromQuery] int take = 50, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var rows = await db.Photos
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
}
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
/// to public so it shows up on feed.rec.net. The watch's "Share"
/// button hits this; if the watch posts to a different URL we'll
/// add it when we see it in logs.</summary>
[HttpPost("api/images/v2/share/{id:long}")]
[HttpPost("api/images/v3/share/{id:long}")]
[HttpPost("api/images/v4/share/{id:long}")]
[HttpPost("api/images/v5/share/{id:long}")]
[Authorize]
public async Task<IActionResult> Share(long id)
{
var pid = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != pid) return Forbid();
photo.IsPublic = true;
await db.SaveChangesAsync();
return Ok(BuildImageInfo(photo, pid));
}
/// <summary>DELETE api/images/v{2-5}/{id} — uploader (or admin)
/// soft-deletes a saved photo.</summary>
[HttpDelete("api/images/v2/{id:long}")]
[HttpDelete("api/images/v3/{id:long}")]
[HttpDelete("api/images/v4/{id:long}")]
[HttpDelete("api/images/v5/{id:long}")]
[Authorize]
public async Task<IActionResult> Delete(long id)
{
var pid = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != pid)
{
var isAdmin = await db.Players.Where(x => x.Id == pid).Select(x => x.IsAdmin).FirstOrDefaultAsync();
if (!isAdmin) return Forbid();
}
photo.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
// ── Legacy v1 ops (real persistence, was Ack-only stubs) ─────────
public sealed class CheerImageRequest
{
public long ImageId { get; set; }
public long PhotoId { get; set; }
}
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
/// Idempotent per (player, photo).</summary>
[HttpPost("api/images/v1/cheer")]
[Authorize]
public async Task<IActionResult> CheerV1([FromBody] CheerImageRequest? body,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "PhotoId")] long? photoIdForm)
{
var pid = this.RequireCurrentPlayerId();
var photoId = body?.PhotoId ?? body?.ImageId
?? photoIdForm ?? imageIdForm ?? 0;
if (photoId <= 0) return BadRequest(new { error = "missing photoId" });
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId && p.DeletedAt == null);
if (photo is null) return NotFound();
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == pid && c.TargetPhotoId == photoId);
if (existing is null)
{
db.Cheers.Add(new CheerEntity
{
FromPlayerId = pid,
TargetPhotoId = photoId,
CheeredAt = DateTime.UtcNow,
});
photo.CheerCount += 1;
await db.SaveChangesAsync();
}
return Ok(new { photo.Id, photo.CheerCount });
}
/// <summary>GET <c>api/images/v1/listsaved</c> — the 2020 client
/// deserializes this as SavedImagesListDTO { Images: string[] }.
/// Do not return the richer v2/v4 photo-object list here.</summary>
[HttpGet("api/images/v1/listsaved")]
[Authorize]
public async Task<IActionResult> ListSavedV1()
{
var pid = this.RequireCurrentPlayerId();
var names = await db.Photos
.Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Select(p => p.BlobName)
.Take(200)
.ToListAsync();
return Ok(new { Images = names });
}
public sealed class ModifyAccessibilityRequest
{
public long PhotoId { get; set; }
public long ImageId { get; set; }
public string? ImageName { get; set; }
public bool IsPublic { get; set; }
}
/// <summary>POST <c>api/images/v1/modifyaccessibility</c> — flip
/// the IsPublic flag on a saved photo. Was Ack-only; now writes
/// the column.</summary>
[HttpPost("api/images/v1/modifyaccessibility")]
[Authorize]
public async Task<IActionResult> ModifyAccessibility(
[FromBody] ModifyAccessibilityRequest? body,
[FromForm(Name = "PhotoId")] long? photoIdForm,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "ImageName")] string? imageNameForm,
[FromForm(Name = "IsPublic")] bool? isPublicForm)
{
var pid = this.RequireCurrentPlayerId();
var photoId = body?.PhotoId ?? body?.ImageId
?? photoIdForm ?? imageIdForm ?? 0;
var imageName = body?.ImageName ?? imageNameForm;
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
return BadRequest(new { error = "missing photoId" });
var isPublic = body?.IsPublic ?? isPublicForm ?? true;
var photo = photoId > 0
? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId)
: await db.Photos.FirstOrDefaultAsync(p => p.BlobName == imageName);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != pid) return Forbid();
photo.IsPublic = isPublic;
await db.SaveChangesAsync();
return Ok(new { photo.Id, photo.IsPublic });
}
public sealed class DeleteSavedRequest
{
public long PhotoId { get; set; }
public long ImageId { get; set; }
public string? ImageName { get; set; }
}
/// <summary>POST <c>api/images/v1/deletesaved</c> + DELETE alias —
/// soft-delete a saved photo. Was Ack-only; now flips
/// <c>DeletedAt</c>.</summary>
[HttpPost("api/images/v1/deletesaved")]
[HttpDelete("api/images/v1/deletesaved/{id:long}")]
[Authorize]
public async Task<IActionResult> DeleteSavedV1(
long? id,
[FromBody] DeleteSavedRequest? body,
[FromForm(Name = "PhotoId")] long? photoIdForm,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "ImageName")] string? imageNameForm)
{
var photoId = id ?? body?.PhotoId ?? body?.ImageId
?? photoIdForm ?? imageIdForm ?? 0;
var imageName = body?.ImageName ?? imageNameForm;
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
return BadRequest(new { error = "missing photoId" });
if (photoId <= 0)
{
var pid = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p =>
p.UploaderPlayerId == pid && p.BlobName == imageName && p.DeletedAt == null);
if (photo is null) return NotFound();
photo.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
return await Delete(photoId);
}
public sealed class SendLinkRequest
{
public long PhotoId { get; set; }
public long ImageId { get; set; }
public List<long>? RecipientIds { get; set; }
}
/// <summary>POST <c>api/images/v1/sendlink</c> — send a link to a
/// photo as a DM to one or more recipients. Was Ack-only; now
/// inserts one <see cref="MessageEntity"/> per recipient.</summary>
[HttpPost("api/images/v1/sendlink")]
[Authorize]
public async Task<IActionResult> SendLink(
[FromBody] SendLinkRequest? body,
[FromForm(Name = "PhotoId")] long? photoIdForm,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "RecipientIds")] string? recipientIdsForm)
{
var pid = this.RequireCurrentPlayerId();
var photoId = body?.PhotoId ?? body?.ImageId ?? photoIdForm ?? imageIdForm ?? 0;
if (photoId <= 0) return BadRequest(new { error = "missing photoId" });
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId);
if (photo is null) return NotFound();
var recipients = body?.RecipientIds ?? ParseIdList(recipientIdsForm) ?? new();
if (recipients.Count == 0) return Ok(new { Sent = 0 });
var preview = $"shared a photo (id {photoId})";
foreach (var rid in recipients.Distinct())
{
db.Messages.Add(new MessageEntity
{
SenderPlayerId = pid,
RecipientPlayerId = rid,
Body = $"{preview} → /img/{photo.BlobName}",
});
}
await db.SaveChangesAsync();
return Ok(new { Sent = recipients.Count });
}
private static List<long>? ParseIdList(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return null;
return csv.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => long.TryParse(s.Trim(), out var v) ? v : 0L)
.Where(v => v > 0)
.ToList();
}
// ── Helpers ──────────────────────────────────────────────────────
/// <summary>Read raw image bytes from the request — supports both
/// multipart/form-data (the typical case, with field "File" or
/// "Image") and a raw octet-stream body (some 2020 client paths).</summary>
private async Task<byte[]?> ReadImageBytesAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
// Try common field names; fall back to the first uploaded file.
var file = form.Files["File"]
?? form.Files["Image"]
?? form.Files["image"]
?? form.Files["file"]
?? form.Files.FirstOrDefault();
if (file is null || file.Length == 0) return null;
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
return ms.ToArray();
}
// Raw body fallback — when the watch posts the bytes directly.
if (Request.ContentLength is > 0)
{
using var ms = new MemoryStream();
await Request.Body.CopyToAsync(ms);
return ms.ToArray();
}
return null;
}
/// <summary>Detect PNG vs JPEG by magic bytes — anything else
/// defaults to .png since the 2020 client's camera always saves
/// PNG.</summary>
private static string SniffExtension(byte[] bytes)
{
if (bytes.Length >= 4 &&
bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
return "png";
if (bytes.Length >= 3 &&
bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
return "jpg";
return "png";
}
private async Task<(byte[]? Bytes, string Source)> LoadImageBytesAsync(string fileName)
{
var (bucket, key) = BlobRouter.Route(fileName);
try
{
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var s3Bytes = await storage.GetAsync(bucket, key, s3Timeout.Token);
if (s3Bytes is { Length: > 0 })
return (s3Bytes, $"s3:{bucket}/{key}");
}
catch (Exception ex)
{
logger.LogWarning(ex,
"[images] API image S3 lookup threw for {File} bucket={Bucket} key={Key}",
fileName, bucket, key);
}
// S3 is the only canonical store. Disk-on-server is a legacy
// surface used by tools/fetch-room-images.py'd thumbnails that
// ship with the server — keep that as a last resort, but DB
// bytes are gone.
var full = Path.Combine(ImageDir, fileName);
if (System.IO.File.Exists(full))
return (await System.IO.File.ReadAllBytesAsync(full), "disk");
return (null, "miss");
}
private static bool TryGetPlayerId(string fileName, string prefix, out long playerId)
{
playerId = 0;
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
var start = prefix.Length;
var end = fileName.IndexOf('_', start);
return end > start && long.TryParse(fileName[start..end], out playerId);
}
private static bool IsImageName(string name)
{
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp" or ".avif";
}
private static string MimeFromName(string name)
{
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".avif" => "image/avif",
_ => "image/png",
};
}
private void LogUploadForm(string endpoint, long playerId, IFormCollection? form, bool isProfile)
{
if (form is null)
{
logger.LogInformation(
"[images] {Endpoint} metadata player={PlayerId} form=false profile={Profile}",
endpoint,
playerId,
isProfile);
return;
}
var fileNames = string.Join(
",",
form.Files.Select(f => $"{f.Name}:{f.FileName}:{f.Length}"));
var fieldNames = string.Join(",", form.Keys);
var meta = form["imgMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
meta = form["imageMeta"].ToString();
if (meta.Length > 512)
meta = meta[..512];
logger.LogInformation(
"[images] {Endpoint} metadata player={PlayerId} files={Files} fields={Fields} profile={Profile} imgMeta={ImgMeta}",
endpoint,
playerId,
fileNames,
fieldNames,
isProfile,
meta);
}
private static bool IsProfileThumbnail(IFormCollection? form)
{
if (form is null) return false;
static bool IsProfileValue(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
return value.Trim().Equals("4", StringComparison.OrdinalIgnoreCase)
|| value.Contains("ProfileThumbnail", StringComparison.OrdinalIgnoreCase);
}
if (IsProfileValue(form["SavedImageType"].ToString())
|| IsProfileValue(form["savedImageType"].ToString())
|| IsProfileValue(form["ImageType"].ToString())
|| IsProfileValue(form["imageType"].ToString()))
return true;
var meta = form["imgMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
meta = form["imageMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
return false;
if (IsProfileValue(meta))
return true;
try
{
using var doc = JsonDocument.Parse(meta);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return false;
foreach (var name in new[] { "savedImageType", "SavedImageType", "imageType", "ImageType" })
{
if (!doc.RootElement.TryGetProperty(name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n) && n == 4)
return true;
if (value.ValueKind == JsonValueKind.String && IsProfileValue(value.GetString()))
return true;
}
}
catch (JsonException)
{
}
return false;
}
/// <summary>Build the response shape every images/v* endpoint
/// returns. PascalCase keys + redundant aliases so whichever JSON
/// parser the 2020 watch uses, it finds the field it wants. The
/// most important keys are AccountId, ImageName, CreatedAt — those
/// are what the watch's strict deserialiser reads. <c>Url</c>
/// is built against the configured deployment apex (DORKNET_DOMAIN)
/// so every callsite for the URL string flows through one source
/// of truth.</summary>
private object BuildImageInfo(PhotoEntity p, long accountId)
{
var cdnHost = domain.Sub("cdn");
return new
{
// Primary keys the 2020 watch's ImageInfo deserialiser reads.
ImageName = p.BlobName,
AccountId = (int)accountId,
ImageType = 0,
CreatedAt = p.CreatedAt,
// Bonus fields — useful for other clients (and our own feed UI).
Id = p.Id,
Filename = p.BlobName,
Url = $"https://{cdnHost}/{p.BlobName}",
Caption = p.Caption,
Description = p.Caption,
RoomId = p.RoomId,
IsPublic = p.IsPublic,
CheerCount = p.CheerCount,
ViewCount = p.ViewCount,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
};
}
private static IEnumerable<long> ParseTagged(string? csv) =>
string.IsNullOrWhiteSpace(csv)
? Enumerable.Empty<long>()
: csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0);
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = new DorkNet.Models.Auth.RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
};
var selfAccount = new DorkNet.Models.Auth.RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday,
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
private static string NormaliseTaggedIds(string? csv, long exceptId)
{
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0 && v != exceptId)
.Distinct()
.ToList();
return string.Join(",", ids);
}
}
@@ -1,39 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace DorkNet.Server.Controllers.API.Images.V2;
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class ImagesController : ControllerBase
{
private static readonly string ImageDir =
Path.Combine(AppContext.BaseDirectory, "data", "images");
[HttpPost("upload")]
public async Task<ActionResult<UploadImageResponse>> Upload(IFormFile file)
{
Directory.CreateDirectory(ImageDir);
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".png" or ".jpg" or ".jpeg"))
return BadRequest("Only PNG/JPG accepted");
var name = $"{Guid.NewGuid():N}{ext}";
var path = Path.Combine(ImageDir, name);
await using var stream = System.IO.File.Create(path);
await file.CopyToAsync(stream);
var baseUrl = $"{Request.Scheme}://{Request.Host}";
return Ok(new UploadImageResponse { ImageName = name, Url = $"{baseUrl}/img/{name}" });
}
}
public class UploadImageResponse
{
public string ImageName { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
}
@@ -1,594 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Inventions;
/// <summary>
/// api.rec.net/api/inventions/* — Maker-Pen creations the player can
/// re-spawn or share. URL surface + verbs verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Inventions.txt</c>: the "update*"
/// routes use <c>Core.Get</c> (GET) despite being mutations, so we
/// expose them as [HttpGet]; only <c>cheer</c>, <c>report</c>,
/// <c>settags</c>, <c>save</c>, <c>addversion</c>, and <c>batch</c>
/// are POST. <c>delete</c>, <c>publish</c>, <c>unpublish</c>,
/// <c>download</c> are also GET.
///
/// Wire DTO matches <c>RecNet.Invention</c>
/// (<c>Cpp2IL_CS/.../RecNet/Invention.cs</c>) byte-for-byte: keys
/// <c>InventionId, ReplicationId, CreatorPlayerId, Name, Description,
/// ImageName, CurrentVersionNumber, IsPublished, ModifiedAt,
/// CreatedAt, FirstPublishedAt, CreationRoomId,
/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount,
/// CreatorPermission, GeneralPermission, IsAgInvention</c>.
/// </summary>
[ApiController]
public class InventionsController(
DorkNetDbContext db,
LevelService level,
NotificationService notifications) : ControllerBase
{
private long? CurrentPlayerIdOrNull => this.CurrentPlayerId();
private long CurrentPlayerId => this.RequireCurrentPlayerId();
// ── Browse ───────────────────────────────────────────────────────────
[HttpGet("api/inventions/v3/popular")]
public async Task<ActionResult> Popular([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished)
.OrderByDescending(i => i.CheerCount)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v3/saved")]
[Authorize]
public async Task<ActionResult> Saved([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var pid = CurrentPlayerId;
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.CreatorPlayerId == pid)
.OrderByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v3/created")]
[Authorize]
public Task<ActionResult> Created([FromQuery] int take = 50) => Saved(take);
/// <summary><c>GetLocalPlayerInventions</c> — same as Saved but
/// at v1 path. Watch hits both depending on tab.</summary>
[HttpGet("api/inventions/v1/mine")]
[Authorize]
public Task<ActionResult> Mine() => Saved(100);
[HttpGet("api/inventions/v1/search")]
public async Task<ActionResult> Search([FromQuery] string value = "")
{
if (string.IsNullOrWhiteSpace(value))
return Ok(Array.Empty<object>());
// The watch's @-prefix means "search by player". Two callers
// use this with different payload shapes — both end up here:
// * PlayerDetailsWatchUIFlow.SetAccount → @<accountId> (numeric)
// loads the Inventions tab on a player profile. Verified
// in Cpp2IL — see PlayerDetailsWatchUIFlow.txt:1867,1950.
// * Inventions search UI → @<username> (text) free-text
// prefix match on Username.
if (value.StartsWith('@'))
{
var tail = value[1..];
List<long> creators;
if (long.TryParse(tail, out var accountId))
{
creators = new List<long> { accountId };
}
else
{
creators = await db.Players
.Where(p => p.Username.StartsWith(tail))
.Select(p => p.Id).Take(50).ToListAsync();
}
var byCreator = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished
&& creators.Contains(i.CreatorPlayerId))
.OrderByDescending(i => i.CheerCount)
.Take(50)
.ToListAsync();
return Ok(byCreator.Select(ToWire));
}
var v = value.ToLowerInvariant();
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished
&& (i.Name.ToLower().Contains(v)
|| i.TagsCsv.ToLower().Contains(v)))
.OrderByDescending(i => i.CheerCount)
.Take(50)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
public sealed record InventionBatchRequest(List<long> InventionIds);
[HttpPost("api/inventions/v1/batch")]
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
{
if (req?.InventionIds is null || req.InventionIds.Count == 0)
return Ok(Array.Empty<object>());
var ids = req.InventionIds.Take(200).ToList();
var rows = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
/// response as <c>RecNet.GetFiltersResponse</c> (an
/// <c>IRecNetObject</c> with <c>PinnedFilters</c> +
/// <c>PopularFilters</c> string-list properties), then a
/// <c>Func&lt;GetFiltersResponse, List&lt;string&gt;&gt;</c>
/// projects whichever list the caller wanted. Verified at
/// Cpp2IL_ISIL/.../RecNet/Inventions.txt:4834-4852 — the URL
/// literal is followed by a <c>typeof(Func`2&lt;GetFiltersResponse,
/// List`1&lt;String&gt;&gt;)</c>. Returning a bare string array
/// (the previous shape) tripped the LitJson importer's
/// <c>List → Dictionary</c> cast inside <c>Util.Deserialize&lt;T&gt;</c>
/// and surfaced as "Failed to get tags: Malformed Response"
/// (output_log.txt:1602+1639) — and that bubbles up as the
/// dorm-load destabilisation we've been hunting.</summary>
[HttpGet("api/inventions/v1/tagfilters")]
public IActionResult TagFilters()
{
var tags = new[]
{
"sport", "game", "vehicle", "weapon", "decor", "tool",
"art", "music", "puzzle", "combat", "build", "race",
};
return Ok(new
{
PinnedFilters = tags,
PopularFilters = tags,
});
}
/// <summary>GET <c>api/inventions/v1/creatorIds</c> — the distinct
/// creator account ids of every invention placed in a room
/// (<c>GetCreatorIdsOfInventionsInRoom</c>, called by the 2020.03
/// watch on room load). It uses these to resolve which creators it
/// must request invention permissions for before spawning a room's
/// invention-based geometry. Returns <c>List&lt;int&gt;</c> — empty
/// when the room has no tracked inventions.</summary>
[HttpGet("api/inventions/v1/creatorIds")]
public async Task<IActionResult> CreatorIdsInRoom([FromQuery] long roomId)
{
var inventions = await ResolveRoomInventionsAsync(roomId);
var creatorIds = inventions
.Select(i => (int)i.CreatorPlayerId)
.Distinct()
.ToList();
return Ok(creatorIds);
}
/// <summary>Resolve the inventions that belong to a room. Direct
/// matches are inventions whose
/// <see cref="InventionEntity.CreationRoomId"/> equals the room's
/// local id (RR-Originals, dorms, anything created in-place).
/// Zip-imported rooms instead carry each invention's ORIGINAL Rec
/// Room CreationRoomId, which won't equal our local id and can't be
/// reverse-resolved without an OriginalRoomId column; we approximate
/// that cluster as the most-frequent non-local CreationRoomId among
/// the room creator's inventions. The two sets are unioned so a room
/// with both kinds resolves fully.</summary>
private async Task<List<InventionEntity>> ResolveRoomInventionsAsync(long roomId)
{
var creatorId = await db.Rooms
.Where(r => r.Id == roomId)
.Select(r => (long?)r.CreatorPlayerId)
.FirstOrDefaultAsync();
long? importedRoomId = null;
if (creatorId is long creator)
{
importedRoomId = await db.Inventions
.Where(i => !i.IsDeleted && i.CreatorPlayerId == creator
&& i.CreationRoomId != null && i.CreationRoomId != roomId)
.GroupBy(i => i.CreationRoomId)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefaultAsync();
}
return await db.Inventions
.Where(i => !i.IsDeleted
&& (i.CreationRoomId == roomId
|| (importedRoomId != null && i.CreationRoomId == importedRoomId)))
.ToListAsync();
}
// ── Single fetch ─────────────────────────────────────────────────────
[HttpGet("api/inventions/v1")]
public async Task<ActionResult> SingleByQuery([FromQuery] long inventionId)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (i is null) return NotFound();
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
return Forbid();
return Ok(ToWire(i));
}
[HttpGet("api/inventions/v1/details")]
public async Task<ActionResult> Details([FromQuery] long inventionId)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (i is null) return NotFound();
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
return Forbid();
var versions = await db.InventionVersions
.Where(v => v.InventionId == inventionId)
.OrderByDescending(v => v.VersionNumber)
.ToListAsync();
return Ok(new
{
Invention = ToWire(i),
Versions = versions.Select(ToVersionWire),
});
}
[HttpGet("api/inventions/v1/personaldetails/{id:long}")]
[Authorize]
public async Task<ActionResult> PersonalDetails(long id)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
if (i is null) return NotFound();
return Ok(new
{
Invention = ToWire(i),
CanEdit = i.CreatorPlayerId == CurrentPlayerId,
});
}
[HttpGet("api/inventions/v1/versions")]
public async Task<ActionResult> Versions([FromQuery] long inventionId)
{
var rows = await db.InventionVersions
.Where(v => v.InventionId == inventionId)
.OrderByDescending(v => v.VersionNumber)
.ToListAsync();
return Ok(rows.Select(ToVersionWire));
}
[HttpGet("api/inventions/v1/version")]
public async Task<ActionResult> Version(
[FromQuery] long inventionId, [FromQuery] int version)
{
var v = await db.InventionVersions
.FirstOrDefaultAsync(x => x.InventionId == inventionId && x.VersionNumber == version);
if (v is null) return NotFound();
return Ok(ToVersionWire(v));
}
// ── Create / version ─────────────────────────────────────────────────
public sealed record SaveInventionRequest(
string Name, string? Description, string? ImageName,
int? Permission, string? Tags, string BlobName,
long? CreationRoomId, int? InstantiationCost, int? LightsCost);
[HttpPost("api/inventions/v3/save")]
[Authorize]
public async Task<ActionResult> Save([FromBody] SaveInventionRequest req)
{
var pid = CurrentPlayerId;
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("missing name");
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
var inv = new InventionEntity
{
CreatorPlayerId = pid,
ReplicationId = Guid.NewGuid().ToString("D"),
Name = req.Name.Trim(),
Description = req.Description ?? string.Empty,
ImageName = req.ImageName ?? string.Empty,
Permission = Math.Clamp(req.Permission ?? 0, 0, 2),
CurrentBlobName = req.BlobName,
TagsCsv = req.Tags ?? string.Empty,
CreationRoomId = req.CreationRoomId,
CreatorPermission = 100, // Unlimited for the creator
GeneralPermission = 0, // Unassigned (private) until publish
IsPublished = false,
CurrentVersionNumber = 1,
};
db.Inventions.Add(inv);
await db.SaveChangesAsync();
// Snapshot the v1 version row.
var v1 = new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = 1,
BlobName = req.BlobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
};
db.InventionVersions.Add(v1);
await db.SaveChangesAsync();
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
return Ok(ToWire(inv));
}
public sealed record AddVersionRequest(
long InventionId, string BlobName,
int? InstantiationCost, int? LightsCost);
[HttpPost("api/inventions/v3/addversion")]
[Authorize]
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
var nextVer = inv.CurrentVersionNumber + 1;
inv.CurrentBlobName = req.BlobName;
inv.CurrentVersionNumber = nextVer;
inv.UpdatedAt = DateTime.UtcNow;
db.InventionVersions.Add(new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = nextVer,
BlobName = req.BlobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
});
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
// ── GET-based mutations (Core.Get pattern) ───────────────────────────
/// <summary>Watch sends ONE of name/description/imgName/permission
/// per request — `UpdateInvention*` ISIL builds different
/// query strings. Pull whichever ones came in and apply.</summary>
[HttpGet("api/inventions/v1/update")]
[Authorize]
public async Task<ActionResult> Update(
[FromQuery] long inventionId,
[FromQuery] string? name,
[FromQuery] string? description,
[FromQuery] string? imgName,
[FromQuery] int? permission)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
if (!string.IsNullOrWhiteSpace(name)) inv.Name = name.Trim();
if (description is not null) inv.Description = description;
if (imgName is not null) inv.ImageName = imgName;
if (permission.HasValue)
{
inv.GeneralPermission = ClampInventionPermission(permission.Value);
if (inv.GeneralPermission > 0 && !inv.IsPublished)
{
inv.IsPublished = true;
inv.FirstPublishedAt = DateTime.UtcNow;
}
// Mirror legacy 0/1/2 column for back-compat.
inv.Permission = inv.GeneralPermission >= 60 ? 2 : (inv.GeneralPermission >= 20 ? 1 : 0);
}
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/delete")]
[Authorize]
public async Task<ActionResult> Delete([FromQuery] long inventionId)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.IsDeleted = true;
inv.GeneralPermission = 0;
inv.IsPublished = false;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v2/publish")]
[Authorize]
public async Task<ActionResult> Publish(
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var perm = ClampInventionPermission(permissionLevel);
if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)");
inv.GeneralPermission = perm;
inv.IsPublished = true;
inv.FirstPublishedAt ??= DateTime.UtcNow;
inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/unpublish")]
[Authorize]
public async Task<ActionResult> Unpublish([FromQuery] long inventionId)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.GeneralPermission = 0;
inv.IsPublished = false;
inv.Permission = 0;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/download")]
public async Task<ActionResult> Download([FromQuery] long inventionId)
{
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
inv.SpawnCount += 1;
var pid = CurrentPlayerIdOrNull;
if (pid is long me && me != inv.CreatorPlayerId)
{
// NumPlayersHaveUsedInRoom — increment per-distinct-player.
// Approximate by distinct cheer/check; for now bump once
// per download by non-creator.
inv.NumPlayersHaveUsedInRoom += 1;
}
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
// ── POST mutations ───────────────────────────────────────────────────
public sealed record SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags);
[HttpPost("api/inventions/v1/settags")]
[Authorize]
public async Task<ActionResult> SetTags([FromBody] SetTagsRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var combined = string.Join(',', new[] { req.AutoTags, req.PlayerAddedTags }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(',');
inv.TagsCsv = combined;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
public sealed record CheerRequest(long InventionId);
[HttpPost("api/inventions/v1/cheer")]
[Authorize]
public async Task<ActionResult> Cheer([FromBody] CheerRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
// Idempotent: one cheer per (player, invention).
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == pid && c.TargetInventionId == req.InventionId);
if (existing is null)
{
db.Cheers.Add(new CheerEntity
{
FromPlayerId = pid,
TargetInventionId = req.InventionId,
CheeredAt = DateTime.UtcNow,
});
inv.CheerCount += 1;
await db.SaveChangesAsync();
if (inv.CreatorPlayerId != pid)
await notifications.NotifyAsync(inv.CreatorPlayerId,
PushNotificationId.InventionModerationStateChanged,
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
}
return Ok(new { inv.Id, inv.CheerCount });
}
public sealed record ReportInventionRequest(
long InventionId, int? ReportCategory, string? Details);
[HttpPost("api/inventions/v1/report")]
[Authorize]
public async Task<ActionResult> Report([FromBody] ReportInventionRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = pid,
TargetPlayerId = inv.CreatorPlayerId,
TargetInventionId = req.InventionId,
Category = req.ReportCategory ?? 5 /* Other */,
Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)],
});
await db.SaveChangesAsync();
return Ok(new { Reported = true });
}
// ── Wire serializers ─────────────────────────────────────────────────
private static int ClampInventionPermission(int v) => v switch
{
0 or 10 or 20 or 40 or 60 or 80 or 100 => v,
_ => 0,
};
private static object ToWire(InventionEntity i) => new
{
InventionId = i.Id,
ReplicationId = string.IsNullOrEmpty(i.ReplicationId)
? Guid.Empty.ToString("D") : i.ReplicationId,
// Wire field is int — IDs in this server are <2^31 so safe.
CreatorPlayerId = (int)i.CreatorPlayerId,
i.Name,
i.Description,
i.ImageName,
i.CurrentVersionNumber,
i.IsPublished,
ModifiedAt = i.UpdatedAt,
i.CreatedAt,
i.FirstPublishedAt,
i.CreationRoomId,
i.NumPlayersHaveUsedInRoom,
NumDownloads = i.SpawnCount,
i.CheerCount,
i.CreatorPermission,
i.GeneralPermission,
i.IsAgInvention,
};
private static object ToVersionWire(InventionVersionEntity v) => new
{
v.InventionId,
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
? Guid.Empty.ToString("D") : v.ReplicationId,
v.VersionNumber,
v.InstantiationCost,
v.LightsCost,
v.BlobName,
};
}
@@ -1,214 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Inventory;
/// <summary>
/// api.rec.net/api/{equipment,consumables}/* — backs the watch's
/// Backpack tab. Wire DTOs match the decompiled client classes:
///
/// • <c>RecNet.Equipment</c> — fields: <c>PrefabName, ModificationGuid,
/// PlatformMask, IsPlatformLocked, FriendlyName, Tooltip, Rarity,
/// Favorited</c>.
/// • <c>RecNet.Consumable</c> — fields: <c>Id(long), ConsumableType,
/// PlatformMask, Count, InitialCount, UnlockedLevel, CreatedAt,
/// ActiveDurationMinutes(int?), ConsumableItem(nested), Category,
/// IsActive, IsPlatformLocked</c>.
///
/// Persistence: one <see cref="PlayerInventoryEntity"/> row per
/// (player, item). For equipment, Quantity=1 + IsActive tracks
/// favourite. For consumables, Quantity is the stack count + IsActive
/// tracks "currently activated".
/// </summary>
[ApiController]
[Authorize]
public class InventoryController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// Categories the StoreItem table uses for equipment vs
// consumables. The seed data tags items per these strings.
private static readonly HashSet<string> EquipmentCategories = new()
{
"equipment", "tool", "maker_pen", "weapon",
};
private static readonly HashSet<string> ConsumableCategories = new()
{
"consumable",
};
// ── Equipment ────────────────────────────────────────────────────────
[HttpGet("api/equipment/v1/getUnlocked")]
[HttpGet("api/equipment/v2/getUnlocked")]
[HttpGet("api/equipment/v3/getUnlocked")]
public async Task<IActionResult> GetUnlockedEquipment()
{
var pid = Me;
var rows = await (from inv in db.PlayerInventory
join item in db.StoreItems on inv.ItemSlug equals item.Slug
where inv.PlayerId == pid
&& EquipmentCategories.Contains(item.Category)
select new { inv, item }).ToListAsync();
return Ok(rows.Select(r => ToEquipmentWire(r.inv, r.item)));
}
public sealed class EquipmentUpdateRequest
{
public string PrefabName { get; set; } = string.Empty;
public string ModificationGuid { get; set; } = string.Empty;
public bool Favorited { get; set; }
}
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
/// "favorited" flag on a piece of equipment (the watch lets the
/// player pin commonly-used tools).</summary>
// The 2018 client posts a JSON ARRAY of equipment objects (RecNet.cs:21812)
// — each {PrefabName, ModificationGuid, UnlockedLevel, Favorited}. Accept an
// array OR a single object so both shapes bind. Slug = modification GUID, or
// the prefab name when no mod is applied. Unowned items are skipped (not a
// hard reject) so one stale entry doesn't drop the whole batch.
[HttpPost("api/equipment/v1/update")]
public async Task<IActionResult> UpdateEquipment([FromBody] System.Text.Json.JsonElement body)
{
var pid = Me;
var items = new List<System.Text.Json.JsonElement>();
if (body.ValueKind == System.Text.Json.JsonValueKind.Array)
items.AddRange(body.EnumerateArray());
else if (body.ValueKind == System.Text.Json.JsonValueKind.Object)
items.Add(body);
static string? Str(System.Text.Json.JsonElement e, string k)
=> e.TryGetProperty(k, out var v) && v.ValueKind == System.Text.Json.JsonValueKind.String
? v.GetString() : null;
static bool Bool(System.Text.Json.JsonElement e, string k)
=> e.TryGetProperty(k, out var v) && (v.ValueKind == System.Text.Json.JsonValueKind.True
|| (v.ValueKind == System.Text.Json.JsonValueKind.String && v.GetString() == "true"));
foreach (var el in items)
{
var slug = Str(el, "ModificationGuid") is { Length: > 0 } mod ? mod : Str(el, "PrefabName");
if (string.IsNullOrWhiteSpace(slug)) continue;
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null) continue; // skip items the player doesn't own
row.IsActive = Bool(el, "Favorited");
}
await db.SaveChangesAsync();
return Ok(new { Updated = true });
}
// ── Consumables ──────────────────────────────────────────────────────
[HttpGet("api/consumables/v1/getUnlocked")]
public async Task<IActionResult> GetUnlockedConsumables()
{
var pid = Me;
var rows = await (from inv in db.PlayerInventory
join item in db.StoreItems on inv.ItemSlug equals item.Slug
where inv.PlayerId == pid
&& ConsumableCategories.Contains(item.Category)
select new { inv, item }).ToListAsync();
return Ok(rows.Select(r => ToConsumableWire(r.inv, r.item)));
}
public sealed class ConsumableItemRequest
{
public long Id { get; set; }
public int DeltaCount { get; set; }
}
[HttpPost("api/consumables/v1/consume")]
public async Task<IActionResult> Consume([FromBody] ConsumableItemRequest req)
{
var pid = Me;
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id);
if (row is null) return NotFound();
var delta = Math.Max(1, Math.Abs(req.DeltaCount));
row.Quantity = Math.Max(0, row.Quantity - delta);
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
await db.SaveChangesAsync();
await notifications.NotifyAsync(pid,
row.Quantity > 0
? PushNotificationId.ConsumableMappingAdded
: PushNotificationId.ConsumableMappingRemoved,
new { row.Id, row.ItemSlug, row.Quantity });
return Ok(new { row.Id, Count = row.Quantity });
}
public sealed class ActivateConsumableRequest
{
public long Id { get; set; }
public bool IsActive { get; set; }
}
[HttpPost("api/consumables/v1/updateActive")]
public async Task<IActionResult> UpdateActive([FromBody] ActivateConsumableRequest req)
{
var pid = Me;
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id);
if (row is null) return NotFound();
row.IsActive = req.IsActive;
await db.SaveChangesAsync();
return Ok(new { row.Id, row.IsActive });
}
// ── Wire serializers ─────────────────────────────────────────────────
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item) => new
{
PrefabName = item.Slug, // for store-derived items the slug doubles as prefab name
ModificationGuid = inv.ItemSlug,
PlatformMask = -1, // All
IsPlatformLocked = false,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
Rarity = 0, // Common (no rarity column on StoreItem yet)
Favorited = inv.IsActive,
};
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
{
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
? consumableDesc
: item.Slug;
var category = StoreService.TryGetHairDyePayload(item.Slug, out _)
? 6
: 0;
return new
{
inv.Id,
ConsumableItemDesc = type,
ConsumableType = type,
PlatformMask = -1,
Count = inv.Quantity,
InitialCount = inv.Quantity, // we don't track original purchase count
UnlockedLevel = 0,
CreatedAt = inv.AcquiredAt,
ActiveDurationMinutes = (int?)null,
ConsumableItem = new
{
Id = inv.Id,
Type = type,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
ImageName = item.ImageName,
},
Category = category,
inv.IsActive,
IsPlatformLocked = false,
};
}
}
@@ -1,301 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Leaderboard;
/// <summary>
/// leaderboard.{rec.net,localhost}/leaderboard/* — per-stat-channel
/// leaderboard reads + writes. URLs verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:1934, 2416, 2700</c>:
///
/// POST leaderboard/SetStat body: SetStatRequestDTO
/// <c>{StatChannel(int), RoomId(long), StatValue(int)}</c>
/// POST leaderboard/GetPlayerRank body: GetPlayerRankData
/// <c>{PlayerId(int), StatChannel(int), RoomId(long)}</c>
///
/// **Subdomain matters**: the client uses
/// <c>Core.SendRequest(method, Service.Leaderboard, requestUri)</c>
/// where <c>Service.Leaderboard = 9</c> resolves via the
/// <c>RecRoomConfig.ServiceUrls</c> map (we serve via
/// <c>api/config/v2</c>) to <c>https://leaderboard.{apex}</c>. So
/// the actual outbound URL is <c>leaderboard.localhost/leaderboard/SetStat</c>
/// — not <c>api.localhost/api/leaderboard/...</c>.
///
/// Response shapes:
/// SetStat → RequestResult ack <c>{success:true,error:""}</c>
/// GetPlayerRank → FullLeaderboard.Entry — <b>camelCase</b> per
/// <c>FullLeaderboard_NestedType_Entry.txt:111-121</c>:
/// <c>{playerId(int), score(long), rank(int)}</c>.
///
/// Without explicit subdomain routing the request fell through to
/// <see cref="GlobalCatchAllController"/> which returns <c>[]</c>;
/// the client's <c>Util.Deserialize&lt;Entry&gt;</c> tried to cast
/// list-as-dictionary →
/// <c>InvalidCastException: Unable to cast 'List' to 'Dictionary'</c>
/// → "Failed to retrieve leaderboard: Malformed Response".
///
/// We bind both the leaderboard subdomain (canonical) AND the api
/// host (defensive — some 2020 builds may proxy via api/) so either
/// path lands here.
/// </summary>
[ApiController]
public class LeaderboardController(
DorkNetDbContext db,
PlayerPresenceService playerPresence) : ControllerBase
{
public sealed class SetStatRequest
{
public int StatChannel { get; set; }
public long RoomId { get; set; }
public int StatValue { get; set; }
}
/// <summary>POST <c>/leaderboard/SetStat</c> on the leaderboard
/// subdomain (canonical client path) AND <c>/api/leaderboard/SetStat</c>
/// on the api host (legacy / proxy fallback).
///
/// **Wire semantics**: the watch's
/// <c>Leaderboards.ConditionalSetStat_ReturningNewValue</c>
/// (Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:645) handles the
/// per-channel direction (high-score vs low-time-better) LOCALLY
/// via a SetMode enum and only POSTs to <c>SetStat</c> when the
/// new value should replace the cached one. The wire request
/// itself carries no direction hint — it's just
/// <c>{StatChannel, RoomId, StatValue}</c>. So the server must
/// trust the client and assign unconditionally; an earlier
/// <c>Math.Max</c> here silently kept the LARGEST value, which
/// broke channels like StuntRunner (channel 7, low elapsed time
/// wins) where the watch posts the new best and we'd discard it
/// for any prior worse value.</summary>
[HttpPost("/leaderboard/SetStat")]
[HttpPost("/api/leaderboard/SetStat")]
[Authorize]
public async Task<IActionResult> SetStat([FromBody] SetStatRequest req)
{
var pid = this.RequireCurrentPlayerId();
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = pid,
StatChannel = req.StatChannel,
Value = req.StatValue,
};
db.LeaderboardStats.Add(row);
}
else
{
row.Value = req.StatValue;
row.UpdatedAt = DateTime.UtcNow;
}
// Auto-attach the channel to whichever room the score came
// from so every room implicitly gets its own leaderboards.
// The watch's <c>SetStatRequestDTO</c> carries
// <c>RoomId</c> (see Cpp2IL_ISIL .../SetStatRequestDTO);
// when it's zero (rare — direct stat report outside a room
// context) fall back to the player's current presence so a
// stray score still gets bucketed somewhere visible.
var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0);
if (roomId > 0)
{
var meta = await db.LeaderboardChannelMeta
.FirstOrDefaultAsync(c => c.Channel == req.StatChannel);
if (meta is null)
{
// First time this channel reports. Stamp a meta row so
// the admin SPA's per-room Leaderboards tab surfaces it
// immediately, with a placeholder name the admin can
// rename later. Direction defaults to higher-is-better;
// admin flips the toggle for time-trial channels.
db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity
{
Channel = req.StatChannel,
RoomId = roomId,
Name = $"Channel {req.StatChannel}",
LowerIsBetter = false,
ValueFormat = "count",
});
}
else if (meta.RoomId == 0)
{
// Channel registered globally (e.g. from the hardcoded
// KnownStatChannels fallback or an unscoped admin
// entry). Bind it to this room now that we know which
// room it came from. Don't re-bind if it already
// points at a different room — the admin must be the
// one to reassign once a channel has a home.
meta.RoomId = roomId;
meta.UpdatedAt = DateTime.UtcNow;
}
}
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
public sealed class GetPlayerRankRequest
{
public int PlayerId { get; set; }
public int StatChannel { get; set; }
public long RoomId { get; set; }
}
[HttpPost("/leaderboard/GetPlayerRank")]
[HttpPost("/api/leaderboard/GetPlayerRank")]
public async Task<IActionResult> GetPlayerRank([FromBody] GetPlayerRankRequest req)
{
var pid = req.PlayerId > 0
? (long)req.PlayerId
: this.CurrentPlayerId() ?? 0;
// Empty/no-row case: return a zero entry. The client tolerates
// this — `Entry.Deserialize` reads playerId/score/rank as
// primitives with defaults.
if (pid == 0)
return Ok(new { playerId = 0, score = 0L, rank = 0 });
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null)
return Ok(new { playerId = (int)pid, score = 0L, rank = 0 });
var higherCount = await db.LeaderboardStats
.CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value);
return Ok(new
{
playerId = (int)pid,
score = row.Value,
rank = higherCount + 1,
});
}
public sealed class GetNearbyScoresRequest
{
public int PlayerId { get; set; }
public int StatChannel { get; set; }
public long RoomId { get; set; }
public int WindowSize { get; set; }
}
/// <summary>POST <c>/leaderboard/GetNearbyScores</c> — returns the
/// N entries on either side of the caller's rank. Wire shape per
/// <c>GetNearbyScoresRequestDTO.cs</c>: PlayerId/StatChannel/RoomId
/// inherited from GetRankRequestDTO + WindowSize.</summary>
[HttpPost("/leaderboard/GetNearbyScores")]
[HttpPost("/api/leaderboard/GetNearbyScores")]
public async Task<IActionResult> GetNearbyScores([FromBody] GetNearbyScoresRequest req)
{
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
var window = Math.Clamp(req.WindowSize, 1, 50);
var all = await db.LeaderboardStats
.Where(s => s.StatChannel == req.StatChannel)
.OrderByDescending(s => s.Value)
.Select(s => new { s.PlayerId, s.Value })
.ToListAsync();
if (all.Count == 0)
{
// Empty case must still come back as a SingleLeaderboard
// Dictionary, not a bare array — the 2020 watch's
// SingleLeaderboard.Deserialize reads dict["rows"]
// (Cpp2IL_ISIL/.../RecNet/SingleLeaderboard.txt:60).
// Returning `[]` cast-fails as List→Dictionary inside
// RecNet.Util.Deserialize, logged repeatedly as
// "Received malformed RecNet response: '[]'" and
// eventually starves the StuntRunner UI of a usable
// leaderboard.
return Ok(new { rows = Array.Empty<object>() });
}
var ranked = all.Select((r, i) => new
{
playerId = (int)r.PlayerId,
score = r.Value,
rank = i + 1,
}).ToList();
var myIdx = ranked.FindIndex(r => r.playerId == (int)pid);
if (myIdx < 0)
{
return Ok(new { rows = ranked.Take(window * 2 + 1).ToArray() });
}
var lo = Math.Max(0, myIdx - window);
var hi = Math.Min(ranked.Count - 1, myIdx + window);
return Ok(new { rows = ranked.GetRange(lo, hi - lo + 1).ToArray() });
}
public sealed class GetRanksRequest
{
public int PlayerId { get; set; }
public int StatChannel { get; set; }
public long RoomId { get; set; }
public int RankStart { get; set; }
public int RankEnd { get; set; }
}
/// <summary>POST <c>/leaderboard/GetRanks</c> — returns a slice of
/// the leaderboard between RankStart and RankEnd (inclusive,
/// 1-indexed). Wire shape per <c>GetRanksRequestDTO.cs</c>.</summary>
[HttpPost("/leaderboard/GetRanks")]
[HttpPost("/api/leaderboard/GetRanks")]
public async Task<IActionResult> GetRanks([FromBody] GetRanksRequest req)
{
var start = Math.Max(1, req.RankStart);
var end = Math.Max(start, req.RankEnd);
var skip = start - 1;
var take = Math.Clamp(end - skip, 1, 200);
var rows = await db.LeaderboardStats
.Where(s => s.StatChannel == req.StatChannel)
.OrderByDescending(s => s.Value)
.Skip(skip).Take(take)
.ToListAsync();
var entries = rows.Select((s, i) => new
{
playerId = (int)s.PlayerId,
score = s.Value,
rank = start + i,
}).ToArray();
// SingleLeaderboard wrapper — see GetNearbyScores above.
return Ok(new { rows = entries });
}
/// <summary>Top-N for a channel. Used by the admin tooling and
/// by the watch's full-leaderboard scoreboard view.</summary>
[HttpGet("/leaderboard/Top")]
[HttpGet("/api/leaderboard/Top")]
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.LeaderboardStats
.Where(s => s.StatChannel == channel)
.OrderByDescending(s => s.Value)
.Take(take)
.ToListAsync();
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
// same Entry list for all four buckets — friends-vs-global
// can come later.
var entries = rows.Select((s, i) => new
{
playerId = (int)s.PlayerId,
score = s.Value,
rank = i + 1,
}).ToArray();
return Ok(new
{
GlobalOverall = entries,
GlobalPeriodic = entries,
FriendsOverall = Array.Empty<object>(),
FriendsPeriodic = Array.Empty<object>(),
NextResetUTC = DateTime.UtcNow.AddDays(7),
});
}
}
@@ -1,586 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Messages.V2;
/// <summary>
/// api.rec.net/api/messages/v2/* — direct-message inbox API. Backed
/// by <see cref="MessageEntity"/>. Sends fan out a SignalR push
/// (<c>MessageReceived</c>) so the recipient's watch refreshes
/// immediately.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class MessagesController(
DorkNetDbContext db,
NotificationService notifications,
PrivateInstanceService privateInstances,
OnlinePresenceService onlinePresence,
ServerSettingsService serverSettings,
ILogger<MessagesController> logger) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>List the caller's inbox, newest-first. Response is a
/// flat array of <see cref="MessageDto"/>; the watch groups by
/// sender locally. v1/v3 paths share the same handler — the watch
/// uses whichever URL its <c>RecNet.Messages</c> static helper
/// built from <c>GameConfig.MessageServiceVersion</c>.</summary>
[HttpGet("get")]
[HttpGet("/api/messages/v1/get")]
[HttpGet("/api/messages/v3/get")]
[HttpGet("/api/messages/v1/onlineStatus")]
public async Task<ActionResult<List<MessageDto>>> GetMessages([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me)
// Defensive filter for the empty-body ghost rows that
// accumulated before SendFormMessage rejected them. The
// watch's MessagesScreenItem renders TextMessage (type 30)
// as "Message : {body}", so an empty body becomes a
// useless "Message :" entry the user has to scroll past.
// Control-channel types (10 RequestGameInvite, 20
// FriendStatusOnline, etc.) have their own labels and
// legitimately carry no body, so we keep them.
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
.OrderByDescending(m => m.SentAt)
.Take(take)
.ToListAsync();
return Ok(await ToDtosAsync(rows));
}
/// <summary>GET <c>api/messages/v1/unread</c> — list of unread
/// messages addressed to the caller. The watch uses the count for
/// the inbox badge and the entries themselves for the toast
/// preview.</summary>
[HttpGet("/api/messages/v1/unread")]
public async Task<ActionResult<List<MessageDto>>> Unread()
{
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null)
// Same ghost-row filter as GetMessages above — keep the
// unread badge count honest by hiding empty TextMessages.
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
.OrderByDescending(m => m.SentAt)
.Take(100)
.ToListAsync();
return Ok(await ToDtosAsync(rows));
}
public sealed class DeleteRequest
{
public long? Id { get; set; }
public long? MessageId { get; set; }
public List<long>? MessageIds { get; set; }
}
/// <summary>POST <c>api/messages/v3/delete</c> — hard-delete one or
/// more messages addressed to the caller. The 2020 watch's inbox
/// "delete" action posts JSON <c>{"MessageIds":[8]}</c> (plural
/// array, verified from the live trace where the body was
/// <c>{"MessageIds":[8]}</c> against <c>/api/messages/v3/delete</c>);
/// some older / SPA call sites still post <c>id</c> or
/// <c>messageId</c> as a form/query. Accept all three shapes and
/// scope every delete to <c>RecipientPlayerId == Me</c> so the
/// sender can't unsend.</summary>
[HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v2/delete")]
[HttpPost("/api/messages/v1/delete")]
[Consumes("application/json")]
public async Task<IActionResult> DeleteJson([FromBody] DeleteRequest body)
{
var ids = new List<long>();
if (body.Id is long a && a > 0) ids.Add(a);
if (body.MessageId is long b && b > 0) ids.Add(b);
if (body.MessageIds is { Count: > 0 })
ids.AddRange(body.MessageIds.Where(v => v > 0));
return await DeleteByIds(ids);
}
[HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v2/delete")]
[HttpPost("/api/messages/v1/delete")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> DeleteForm(
[FromForm(Name = "id")] long? formId,
[FromForm(Name = "messageId")] long? formMessageId,
[FromForm(Name = "messageIds")] string? messageIdsCsv)
{
var ids = new List<long>();
if (formId is long a && a > 0) ids.Add(a);
if (formMessageId is long b && b > 0) ids.Add(b);
if (long.TryParse(Request.Query["id"], out var qi) && qi > 0) ids.Add(qi);
if (!string.IsNullOrWhiteSpace(messageIdsCsv))
{
foreach (var s in messageIdsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (long.TryParse(s.Trim(), out var v) && v > 0) ids.Add(v);
}
return await DeleteByIds(ids);
}
private async Task<IActionResult> DeleteByIds(List<long> ids)
{
ids = ids.Distinct().ToList();
if (ids.Count == 0) return BadRequest(new { error = "missing_id" });
var deleted = await db.Messages
.Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me)
.ExecuteDeleteAsync();
return Ok(new { success = true, error = "", deleted });
}
/// <summary>POST <c>api/messages/v3/markRead</c> — bulk
/// mark-as-read for every unread message addressed to the caller.
/// Optional <c>ids</c> CSV scopes to specific message ids.</summary>
[HttpPost("/api/messages/v3/markRead")]
[HttpPost("/api/messages/v1/markRead")]
public async Task<IActionResult> MarkReadBulk([FromForm(Name = "ids")] string? ids)
{
IQueryable<MessageEntity> q = db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null);
if (!string.IsNullOrWhiteSpace(ids))
{
var idSet = ids
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0)
.ToHashSet();
if (idSet.Count > 0) q = q.Where(m => idSet.Contains(m.Id));
}
var now = DateTime.UtcNow;
await q.ExecuteUpdateAsync(s => s.SetProperty(m => m.ReadAt, now));
return Ok(new { success = true, error = "" });
}
public sealed record SendRequest(long RecipientId, string Body, long? RoomId = null);
/// <summary>Send a direct message — JSON wire shape used by some
/// SPA/admin callers. The 2020 watch posts to the same path with
/// form-urlencoded fields (<see cref="SendFormMessage"/>); the
/// <c>[Consumes]</c> attribute disambiguates which action MVC
/// picks. Empty bodies are rejected so the inbox doesn't get
/// padded with whitespace pings; messages over 2000 chars are
/// truncated to fit the column.</summary>
[HttpPost("send")]
[HttpPost("/api/messages/v1/send")]
[Consumes("application/json")]
public async Task<ActionResult<MessageDto>> SendMessage([FromBody] SendRequest req)
{
if (req.RecipientId <= 0 || req.RecipientId == Me)
return BadRequest(new { error = "invalid_recipient" });
var trimmed = (req.Body ?? string.Empty).Trim();
if (trimmed.Length == 0) return BadRequest(new { error = "empty_body" });
if (trimmed.Length > 2000) trimmed = trimmed[..2000];
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = req.RecipientId,
Body = trimmed,
Type = 30, // TextMessage
RoomId = req.RoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
await BroadcastMessageAsync(entry);
return Ok(ToDto(entry));
}
/// <summary>Form-urlencoded variant — what the 2020 watch actually
/// posts when the user taps "Send Join Request" or any other
/// <c>RecNet.Messages.SendMessage(player, type, data)</c> caller.
/// Wire shape (verified against client log <c>contentType=
/// application/x-www-form-urlencoded</c>, body <c>ToPlayerId=…&amp;
/// Type=10&amp;Data=</c>): the <c>Type</c> integer is a
/// <c>RecNet.Message.MessageType</c> enum value
/// (<c>Cpp2IL_CS/.../RecNet/Message.cs</c>) — 10 =
/// RequestGameInvite, 30 = TextMessage, etc. Empty <c>Data</c> is
/// allowed for control-channel types (join request, declines)
/// where the message itself is the signal.</summary>
[HttpPost("send")]
[HttpPost("/api/messages/v1/send")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<MessageDto>> SendFormMessage(
[FromForm(Name = "ToPlayerId")] long toPlayerId,
[FromForm(Name = "Type")] int type,
[FromForm(Name = "Data")] string? data,
[FromForm(Name = "RoomId")] long? roomId)
{
if (toPlayerId <= 0 || toPlayerId == Me)
return BadRequest(new { error = "invalid_recipient" });
var trimmed = (data ?? string.Empty);
if (trimmed.Length > 2000) trimmed = trimmed[..2000];
// For TextMessage (type 30) the body IS the message — empty
// is meaningless and renders as a ghost "Message :" row in
// the watch's inbox (MessagesScreenItem format string at
// Cpp2IL_ISIL/.../MessagesScreenItem.txt:2145 is
// "Message : {0}"). Control-channel types (RequestGameInvite,
// declines, FriendStatusOnline, etc.) intentionally carry
// empty Data — they get rendered by their own label string
// in the same screen item.
if (type == 30 && trimmed.Trim().Length == 0)
return BadRequest(new { error = "empty_text_message" });
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = toPlayerId,
Body = trimmed,
Type = type,
RoomId = roomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
logger.LogInformation(
"[messages] send from={From} to={To} type={Type} dataLen={Len}",
Me, toPlayerId, type, trimmed.Length);
await BroadcastMessageAsync(entry);
return Ok(ToDto(entry));
}
/// <summary>POST <c>invite</c> — handles the watch's
/// <c>RecNet.Messages.SendGameInvite(playerId, roomInstanceId)</c>
/// (verified at <c>Cpp2IL_ISIL/.../Messages.txt:613-773</c>): form
/// fields <c>playerId</c> (recipient) + <c>roomInstanceId</c>
/// (the inviter's private match id). Two side-effects:
/// 1. Add the recipient to the invite list for that instance so
/// <c>POST /goto/instance/{id}</c> accepts them.
/// 2. Drop a Type-6 (GameInviteV2) message in their inbox carrying the
/// RoomInstanceId so the message UI's "Join" button knows
/// where to send them.
/// Registered at multiple paths because the exact URL the watch
/// builds for "invite" depends on the messages-namespace base —
/// rather than guess wrong and silently fall through to the
/// wildcard, we cover all plausible mounts and log the one that
/// actually fires.</summary>
[HttpPost("/invite")]
[HttpPost("/api/invite")]
[HttpPost("/api/messages/v1/invite")]
[HttpPost("/api/messages/v2/invite")]
[HttpPost("/api/messages/v3/invite")]
public async Task<ActionResult> SendInvite(
[FromForm(Name = "playerId")] long playerId,
[FromForm(Name = "roomInstanceId")] long roomInstanceId)
{
logger.LogInformation(
"[invite] from={From} → to={To} instance={Instance} (path={Path})",
Me, playerId, roomInstanceId, Request.Path);
if (playerId <= 0 || playerId == Me)
return BadRequest(new { error = "invalid_recipient" });
// Only the OWNER of the private instance can invite into it.
// Otherwise an attacker who somehow learned a roomInstanceId
// could invite themselves (or anyone) freely.
var inst = await privateInstances.GetAsync(roomInstanceId);
if (inst is null)
return BadRequest(new { error = "instance_not_found", roomInstanceId });
if (inst.OwnerPlayerId != Me)
return Forbid();
// Persist a real RecNet.Message.MessageType.GameInviteV2 (=6)
// so the watch's GameInviteController calls
// Matchmaking.AcceptRoomInvite(inviteId, roomId), which posts
// goto/invite/{inviteId}. Type 0 follows the sender's current
// presence instead and can land in the wrong instance.
//
// Two saves: the first allocates the message Id (which becomes
// the `inviteId` the watch sends back in /goto/invite/{N}); the
// second backfills the JSON Body once we know it. We can't
// pre-allocate the Id because EF Core's identity columns only
// populate on SaveChanges.
var roomName = await db.Rooms
.Where(r => r.Id == inst.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Private match";
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = playerId,
// Placeholder so the row passes the NOT NULL constraint
// until we overwrite it post-save with the real JSON.
Body = string.Empty,
Type = 6,
RoomId = inst.RoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
// InviteAsync AFTER the message save so we can pin the freshly-
// allocated Message.Id onto the invitee row's
// LatestInviteMessageId. This is what /goto/invite/{N} falls
// back to when the watch's accept flow has already raced
// through DELETE /api/messages/v3/delete — the message row is
// gone but the (instance, player) → messageId mapping survives.
await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id);
// Body shape MUST be the JSON the watch's Message.Deserialize
// builds RoomInviteDetails from (verified at
// Cpp2IL_ISIL/.../RecNet/Message.txt:786-805 — for Type 6/7
// the parser hits `Json.Deserialize` and reads "inviteId" +
// "name"; for other types it `Int32.Parse`s the body).
//
// inviteId → the message Id; watch echoes it back in
// /goto/invite/{inviteId}, server looks the
// row up to resolve roomInstanceId.
// name → display string in the invite toast / row.
// roomInstanceId → extra field the watch ignores; server uses
// it (via TryParseRoomInstanceId's JSON
// fallback) so /goto/invite/{N} doesn't have
// to re-query the private-instance service.
//
// The roomInstanceId MUST be a JSON STRING, not a number. LitJson
// chokes on the dict-cast when a value is a 64-bit integer larger
// than int32 — confirmed via SaveDebugMod trace: the watch logs
// `data={"inviteId":31,"name":"Dorm_1811750","roomInstanceId":6546545006375796736}`
// `RoomInviteDetails=<null>`
// `System.NullReferenceException at RecNet.Util.GetKey from
// RecNet.Message.Deserialize`
// Json.Deserialize returns a null cast-result on the long value,
// GetKey("inviteId") then NREs and the whole parse aborts before
// RoomInviteDetails is built. Stringifying the value sidesteps
// LitJson's long-int handling entirely; the watch only reads
// inviteId + name so the string form is invisible to it.
entry.Body = System.Text.Json.JsonSerializer.Serialize(new
{
inviteId = entry.Id,
name = roomName,
roomInstanceId = roomInstanceId.ToString(System.Globalization.CultureInfo.InvariantCulture),
});
await db.SaveChangesAsync();
await BroadcastMessageAsync(entry);
return Ok(new { success = true, messageId = entry.Id });
}
/// <summary>Mark a single message as read. No-op if not
/// addressed to the caller — prevents the sender from "reading"
/// the recipient's copy.</summary>
[HttpPost("markread/{id:long}")]
public async Task<ActionResult> MarkRead(long id)
{
var msg = await db.Messages.FirstOrDefaultAsync(m =>
m.Id == id && m.RecipientPlayerId == Me);
if (msg is null) return NotFound();
if (msg.ReadAt is null)
{
msg.ReadAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
return Ok();
}
public sealed class SendMultipleBody
{
public List<long>? ToPlayerIds { get; set; }
public int Type { get; set; }
public string? Data { get; set; }
public long? RoomId { get; set; }
}
/// <summary>POST <c>api/messages/v1/sendMultiple</c> — fan-out a
/// message to many recipients. Persists one MessageEntity per
/// recipient. Wire shape: <c>{ToPlayerIds (int[]), Type (int), Data (string)}</c>.</summary>
[HttpPost("/api/messages/v1/sendMultiple")]
[Consumes("application/json")]
public async Task<IActionResult> SendMultiple([FromBody] SendMultipleBody body)
{
if (body.ToPlayerIds is null || body.ToPlayerIds.Count == 0)
return Ok(Array.Empty<object>());
var entries = new List<MessageEntity>();
foreach (var to in body.ToPlayerIds.Distinct().Where(id => id > 0 && id != Me))
{
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = to,
Body = body.Data ?? string.Empty,
Type = body.Type,
RoomId = body.RoomId,
};
entries.Add(entry);
db.Messages.Add(entry);
}
await db.SaveChangesAsync();
foreach (var entry in entries)
{
await BroadcastMessageAsync(entry);
}
return Ok(Array.Empty<object>());
}
/// <summary>POST <c>api/offlineinvite/v1/send</c> — send an invite
/// message to a player who's offline; surfaces in their inbox on
/// next login. Wire shape: form-urlencoded <c>RecipientId</c> +
/// <c>Data</c>. Idempotent per (sender, recipient, body).</summary>
[HttpPost("/api/offlineinvite/v1/send")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> OfflineInviteSend(
[FromForm(Name = "RecipientId")] long? recipientId,
[FromForm(Name = "Data")] string? data)
{
if (recipientId is not long rid || rid <= 0 || rid == Me)
return Ok(new { success = false, error = "invalid_recipient" });
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = rid,
Body = data ?? string.Empty,
Type = 0,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
await BroadcastMessageAsync(entry);
return Ok(new { success = true, messageId = entry.Id });
}
/// <summary>GET <c>api/offlineinvite/v1/all</c> — invites
/// received while the player was offline. Pulls unread
/// MessageEntity rows whose body looks like an invite.</summary>
[HttpGet("/api/offlineinvite/v1/all")]
public async Task<IActionResult> OfflineInviteAll()
{
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null
&& (m.Body.Contains("invite") || m.Body.Contains("event")))
.OrderByDescending(m => m.SentAt)
.Take(50)
.Select(m => new
{
InviteId = m.Id,
FromPlayerId = (int)m.SenderPlayerId,
m.Body,
SentAt = m.SentAt,
})
.ToListAsync();
return Ok(rows);
}
/// <summary>The boot sequence asks for online friends. We
/// derive that from the SignalR hub's connected-player set —
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>.</summary>
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
public async Task<ActionResult> FavoriteFriendOnlineStatus()
{
// Honors the global-friends toggle (everyone is a friend when on).
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, Me);
var online = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(friendIds.Select(id => new
{
AccountId = id,
Online = online.Contains(id),
}));
}
/// <summary>Wire shape verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Message.txt:591-787</c>.
/// <c>RecNet.Message.Deserialize</c> calls <c>Util.GetKey</c>
/// (throws on miss) for: <c>Id</c> (long), <c>FromPlayerId</c> (int),
/// <c>SentTime</c> (DateTime), <c>Type</c> (int — MessageType enum),
/// and reads <c>Data</c> (string) via <c>Util.GetKeyOrDefault</c>.
/// Optional nullable wrappers: <c>RoomId</c>, <c>PlayerEventId</c>.
/// Previous response used <c>{Id, Sender, Recipient, Body, SentAt, ReadAt}</c>
/// which crashed at <c>Util.GetKey("FromPlayerId")</c>.</summary>
public sealed record MessageDto(
long Id,
long FromPlayerId,
DateTime SentTime,
int Type,
string Data,
long? RoomId,
long? PlayerEventId);
private async Task<List<MessageDto>> ToDtosAsync(List<MessageEntity> rows)
{
var instanceIds = rows
.Where(m => IsInstanceInviteType(m.Type)
&& m.RoomId is null
&& long.TryParse(m.Body, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out _))
.Select(m => long.Parse(m.Body, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture))
.Distinct()
.ToList();
var roomByInstance = instanceIds.Count == 0
? new Dictionary<long, long>()
: await db.PrivateInstances
.Where(i => instanceIds.Contains(i.Id))
.Select(i => new { i.Id, i.RoomId })
.ToDictionaryAsync(i => i.Id, i => i.RoomId);
return rows.Select(m =>
{
long? roomId = m.RoomId;
if (roomId is null
&& IsInstanceInviteType(m.Type)
&& long.TryParse(m.Body, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var instanceId)
&& roomByInstance.TryGetValue(instanceId, out var rid))
{
roomId = rid;
}
return ToDto(m, roomId);
}).ToList();
}
private static bool IsInstanceInviteType(int type) => type is 6 or 7;
/// <summary>Push a <c>MessageReceived</c> SignalR notification to BOTH
/// the recipient AND the sender. The recipient half is the obvious one
/// — that's what fires the watch's <c>OnMessageAdded</c> event and
/// gives them the toast / inbox-row update. The sender-half is less
/// obvious but equally needed: the watch's <c>Messages.SendMessage</c>
/// (Messages.txt:1790-1900) just does <c>ExpectHttpStatusSuccess</c>
/// on the response and never touches the local <c>MessageList</c>,
/// so without a self-targeted push the sender's chat thread doesn't
/// know about its own outgoing message until the next
/// <c>RefreshList</c> (which only fires on hub-reconnect or first
/// inbox open). Pushing to both sides keeps both watches in sync
/// in real time. Dedup happens client-side in
/// <c>OnMessageReceived</c> via the "already-in-MessageList by Id"
/// check (Messages.txt:2790-2793), so a player who somehow ends up
/// as both sender and recipient (admin self-messaging) only sees
/// the message once.</summary>
private async Task BroadcastMessageAsync(MessageEntity entry)
{
var dto = ToDto(entry);
await notifications.NotifyAsync(entry.RecipientPlayerId,
PushNotificationId.MessageReceived, dto);
if (entry.SenderPlayerId != entry.RecipientPlayerId)
{
await notifications.NotifyAsync(entry.SenderPlayerId,
PushNotificationId.MessageReceived, dto);
}
}
private static MessageDto ToDto(MessageEntity m, long? roomId = null) =>
new(
Id: m.Id,
FromPlayerId: m.SenderPlayerId,
SentTime: m.SentAt,
// Round-trip the stored Type verbatim. Earlier we coerced
// Type==0 → 30 for "pre-Type-column rows", but the
// Messages.Type Postgres patch (Program.cs:612-614) added
// the column with DEFAULT 30 and backfilled every legacy
// row to 30, so any Type==0 row in the DB now is a real
// GameInvite (e.g. SendInvite above). Coercing it to 30
// routes it through TextMessageController and renders
// "Message : <instanceId>" with no Accept button.
Type: m.Type,
Data: m.Body,
RoomId: roomId ?? m.RoomId,
PlayerEventId: null);
}
@@ -1,211 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Moderation;
/// <summary>
/// In-game moderation endpoints invoked by the watch's player-action
/// dialogs. URLs / form-fields verified against the Cpp2IL ISIL dump:
///
/// POST api/PlayerReporting/v3/create
/// PlayerReporting.txt:1696 + 1712-1822
/// Form: PlayerIdReported (int), ReportCategory (int), Details (string),
/// HeightReporter (float "F2"), HeightReported (float "F2"),
/// RoomId (long).
///
/// POST api/PlayerReporting/v3/voteToKick
/// PlayerReporting.txt:3175 + 3215-3237
/// Form: PlayerId (long), Response (bool), GameSessionId (long).
///
/// POST api/PlayerReporting/v1/instantKick
/// PlayerReporting.txt:3417 (single) + 3590 (bulk)
/// Body: PlayerId(s) — one or many. Admin/dev only.
///
/// POST api/PlayerReporting/v1/deviceId
/// PlayerReporting.txt:3736 + 3749
/// Form: oldDeviceId (string).
///
/// POST api/PlayersBanned/v2/ban
/// Moderation.txt:623-702
/// Form: PlayerId (long), Reason (int), BanType (int),
/// DisplayReason (string), BannedUntil (string MM/dd/yyyy HH:mm).
/// Admin only.
///
/// Without these handlers the watch fell through to the JSON catch-all
/// returning <c>{}</c>, which the OkResponse parser tolerates but the
/// admin-action then has no observable effect — i.e. "the admin
/// system doesn't work at all".
/// </summary>
[ApiController]
[Authorize]
public class InGameModerationController(
DorkNetDbContext db,
NotificationService notifications,
ILogger<InGameModerationController> logger) : ControllerBase
{
// ── /v3/create — file a player report ────────────────────────────
[HttpPost("api/PlayerReporting/v3/create")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> CreateReport(
[FromForm(Name = "PlayerIdReported")] long? playerIdReported,
[FromForm(Name = "ReportCategory")] int reportCategory = 0,
[FromForm(Name = "Details")] string? details = null,
[FromForm(Name = "HeightReporter")] float? heightReporter = null,
[FromForm(Name = "HeightReported")] float? heightReported = null,
[FromForm(Name = "RoomId")] long roomId = 0)
{
if (playerIdReported is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" });
var reporter = this.RequireCurrentPlayerId();
if (reporter == target)
return Ok(new { success = false, error = "cannot_report_self" });
var trimmed = (details ?? string.Empty).Trim();
if (trimmed.Length > 1000) trimmed = trimmed[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
TargetPlayerId = target,
Category = reportCategory,
Message = trimmed,
RoomId = roomId,
});
await db.SaveChangesAsync();
logger.LogInformation(
"[mod] report filed: reporter={Reporter} target={Target} category={Cat} room={Room}",
reporter, target, reportCategory, roomId);
return Ok(new { success = true, error = "" });
}
// ── /v3/voteToKick — room-vote kick ──────────────────────────────
[HttpPost("api/PlayerReporting/v3/voteToKick")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public IActionResult VoteToKick(
[FromForm(Name = "PlayerId")] long? playerId,
[FromForm(Name = "Response")] bool? response,
[FromForm(Name = "GameSessionId")] long gameSessionId = 0)
{
var caller = this.RequireCurrentPlayerId();
logger.LogInformation(
"[mod] vote-to-kick by {Caller}: target={Target} yes={Yes} session={Session}",
caller, playerId, response, gameSessionId);
// Real implementation would tally votes per (session, target) and
// push a kick once the threshold tripped. For now we accept the
// vote without side-effects so the watch UI's "voted" indicator
// shows.
return Ok(new { success = true, error = "" });
}
// ── /v1/instantKick — admin one-shot ─────────────────────────────
/// <summary>
/// Admin/dev one-shot kick. The watch posts either
/// <c>?playerId=N</c> for a single target (PlayerReporting.txt:3417)
/// or a list body for bulk kicks (3590). Either form lands here.
/// Gated to <see cref="PlayerEntity.IsAdmin"/> /
/// <see cref="PlayerEntity.IsDeveloper"/>.
/// </summary>
[HttpPost("api/PlayerReporting/v1/instantKick")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> InstantKick(
[FromQuery(Name = "playerId")] long? singleId,
[FromForm(Name = "PlayerId")] long? formId,
[FromForm(Name = "PlayerIds")] string? formIds)
{
var caller = this.RequireCurrentPlayerId();
if (!await IsCallerAdminOrDevAsync(caller))
return Forbid();
var ids = new List<long>();
if (singleId is long s && s > 0) ids.Add(s);
if (formId is long f && f > 0) ids.Add(f);
if (!string.IsNullOrEmpty(formIds))
{
foreach (var part in formIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
}
ids = ids.Distinct().ToList();
if (ids.Count == 0) return Ok(new { success = false, error = "missing_target" });
foreach (var target in ids)
{
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
// Push a SignalR ModerationKick notification — the watch's
// PlayerReporting.OnModerationKick handler boots the player
// back to their dorm.
await notifications.NotifyAsync(
target,
Models.Notification.PushNotificationId.ModerationKick);
}
return Ok(new { success = true, error = "" });
}
// ── /v1/deviceId — log mismatched device id ──────────────────────
[HttpPost("api/PlayerReporting/v1/deviceId")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public IActionResult MismatchedDeviceWarning(
[FromForm(Name = "oldDeviceId")] string? oldDeviceId)
{
logger.LogInformation(
"[mod] mismatched device id reported: caller={Caller} old={Old}",
this.CurrentPlayerId(), oldDeviceId);
return Ok(new { success = true, error = "" });
}
// ── /api/PlayersBanned/v2/ban — admin ban ────────────────────────
[HttpPost("api/PlayersBanned/v2/ban")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> Ban(
[FromForm(Name = "PlayerId")] long? playerId,
[FromForm(Name = "Reason")] int reason = 0,
[FromForm(Name = "BanType")] int banType = 0,
[FromForm(Name = "DisplayReason")] string? displayReason = null,
[FromForm(Name = "BannedUntil")] string? bannedUntil = null)
{
var caller = this.RequireCurrentPlayerId();
if (!await IsCallerAdminOrDevAsync(caller))
return Forbid();
if (playerId is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" });
// BannedUntil arrives as "MM/dd/yyyy HH:mm" per Moderation.txt:696.
DateTime? until = null;
if (!string.IsNullOrEmpty(bannedUntil) &&
DateTime.TryParseExact(bannedUntil, "MM/dd/yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out var parsed))
{
until = parsed;
}
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == target);
if (p is null) return Ok(new { success = false, error = "no_such_player" });
p.BannedUntil = until ?? DateTime.UtcNow.AddYears(99);
await db.SaveChangesAsync();
logger.LogInformation(
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
caller, target, reason, p.BannedUntil, displayReason);
// Push a ModerationRoomBan notification so the target watch
// shows the ban dialog and disconnects (PlayerReporting.txt:2253).
await notifications.NotifyAsync(
target,
Models.Notification.PushNotificationId.ModerationRoomBan);
return Ok(new { success = true, error = "" });
}
private async Task<bool> IsCallerAdminOrDevAsync(long callerId) =>
await db.Players
.Where(p => p.Id == callerId)
.Select(p => p.IsAdmin || p.IsDeveloper)
.FirstOrDefaultAsync();
}
@@ -1,81 +0,0 @@
using System.Net.WebSockets;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Notification.V2;
/// <summary>
/// api/notification/v2 — the JUNE-2018 client opens a raw WebSocket here for
/// push notifications (RecNet.cs:48895, RecNet.IJADBHMBMPG wraps a
/// BestHTTP.WebSocket with open/message(string)/error events). The 2020 client
/// replaced this with the SignalR hub at notify.{apex}/hub/v1.
///
/// This branch targets 2018, so we ACCEPT the WebSocket and hold it open. We
/// don't push anything yet (server-driven notifications are a later feature) —
/// an open, quiet socket keeps the client's notification subsystem "connected"
/// instead of erroring/retry-looping on the old 410 Gone. Incoming frames
/// (subscribe/auth) are drained and ignored.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
public class NotificationController(
DomainConfig domain,
ILogger<NotificationController> log) : ControllerBase
{
[HttpGet]
public async Task Connect()
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
{
// Non-WS probe (or a curl check) — return an empty notification
// list rather than 410 so HTTP callers don't treat it as an error.
Response.StatusCode = StatusCodes.Status200OK;
Response.ContentType = "application/json";
await Response.WriteAsync("[]");
return;
}
using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync();
log.LogInformation("[notify] 2018 client opened notification WebSocket");
// HANDSHAKE: on connect the 2018 client enters PerformingHandshake and
// blocks ("Connecting to RecNet") until the SERVER sends a handshake
// message — any valid JSON object marks the channel Connected
// (RecNet.cs:49616-49620, JFBBMEIDBNA just needs dict != null). Without
// it both sides wait for each other ~35s, then the client aborts the
// channel and shows "Failed to connect to RecNet". Send {} immediately.
var handshake = System.Text.Encoding.UTF8.GetBytes("{}");
await ws.SendAsync(handshake, WebSocketMessageType.Text, endOfMessage: true,
HttpContext.RequestAborted);
var buf = new byte[4096];
try
{
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(
new ArraySegment<byte>(buf), HttpContext.RequestAborted);
if (result.MessageType == WebSocketMessageType.Close)
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure,
"bye", HttpContext.RequestAborted);
break;
}
// Drain & ignore subscribe/auth frames — no server push yet.
}
}
catch (OperationCanceledException) { /* client navigated away */ }
catch (WebSocketException) { /* abrupt disconnect — normal */ }
log.LogDebug("[notify] notification WebSocket closed");
}
/// <summary>GET <c>api/notification/hub/v1</c> — 2020 SignalR hub pointer
/// (unused by the 2018 client; kept for any 2019/2020 caller).</summary>
[HttpGet("/api/notification/hub/v1")]
public IActionResult HubInfo() => Ok(new
{
Url = $"https://{domain.Sub("notify")}/hub/v1",
Protocol = "signalr",
Version = 1,
});
}
@@ -1,128 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.NotificationPrefs;
/// <summary>
/// api.rec.net/api/messages/v1/IOSGetNotificationPreferences +
/// IOSModifyNotificationPreferences. Wire DTO matches
/// <c>RecNet.IOSNotificationPreferences</c> (decompiled at
/// <c>Cpp2IL_CS/.../RecNet/IOSNotificationPreferences.cs</c>):
/// <c>ChatMessage(bool), FriendInvite(bool), FavoriteFriendOnline(bool)</c>.
///
/// Persists via <see cref="NotificationPrefsEntity"/> keyed by
/// (PlayerId, Platform). Defaults to all-on when no row exists yet.
/// </summary>
[ApiController]
[Authorize]
public class IOSNotificationPrefsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("api/messages/v1/IOSGetNotificationPreferences")]
public async Task<IActionResult> Get()
{
var pid = Me;
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid);
return Ok(ToWire(row));
}
public sealed class IOSPrefsRequest
{
public bool ChatMessage { get; set; } = true;
public bool FriendInvite { get; set; } = true;
public bool FavoriteFriendOnline { get; set; } = true;
}
[HttpPost("api/messages/v1/IOSModifyNotificationPreferences")]
public async Task<IActionResult> Modify([FromBody] IOSPrefsRequest body)
{
var pid = Me;
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid);
if (row is null)
{
row = new NotificationPrefsEntity
{
PlayerId = pid,
Platform = "ios",
};
db.NotificationPrefs.Add(row);
}
row.AllowMessage = body.ChatMessage;
row.AllowFriendRequest = body.FriendInvite;
row.AllowAnnouncements = body.FavoriteFriendOnline;
row.AllowAll = body.ChatMessage || body.FriendInvite || body.FavoriteFriendOnline;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(row));
}
/// <summary>POST <c>IOSResetNotificationPreferencesBadgeCount</c> —
/// the watch sends this when the user opens the iOS app to clear
/// the badge counter. No persistent state to mutate; ack.</summary>
[HttpPost("api/messages/v1/IOSResetNotificationPreferencesBadgeCount")]
public IActionResult ResetBadge() => Ok(new { Reset = true });
public sealed class SaveTokenRequest
{
public string? Token { get; set; }
public string? DeviceToken { get; set; }
public string? Platform { get; set; }
}
/// <summary>POST <c>api/messages/v1/IOSSaveDeviceToken</c> —
/// upserts the caller's APNS token to
/// <see cref="PushTokenEntity"/> for a future APNS proxy to fan
/// out push notifications.</summary>
[HttpPost("api/messages/v1/IOSSaveDeviceToken")]
public async Task<IActionResult> SaveDeviceToken(
[FromBody] SaveTokenRequest? body,
[FromForm(Name = "Token")] string? tokenForm,
[FromForm(Name = "DeviceToken")] string? deviceTokenForm)
{
var pid = Me;
var token = body?.Token ?? body?.DeviceToken ?? tokenForm ?? deviceTokenForm ?? string.Empty;
var platform = body?.Platform ?? "ios";
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "missing token" });
var row = await db.PushTokens.FirstOrDefaultAsync(t =>
t.PlayerId == pid && t.Platform == platform);
if (row is null)
{
row = new PushTokenEntity { PlayerId = pid, Platform = platform };
db.PushTokens.Add(row);
}
row.Token = token;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { Saved = true });
}
/// <summary>POST <c>api/messages/v1/IOSClearDeviceToken</c> —
/// drops every push-token row for the caller (e.g. on iOS
/// logout).</summary>
[HttpPost("api/messages/v1/IOSClearDeviceToken")]
public async Task<IActionResult> ClearDeviceToken()
{
var pid = Me;
var rows = await db.PushTokens.Where(t => t.PlayerId == pid).ToListAsync();
if (rows.Count > 0)
{
db.PushTokens.RemoveRange(rows);
await db.SaveChangesAsync();
}
return Ok(new { Cleared = rows.Count });
}
private static object ToWire(NotificationPrefsEntity? r) => new
{
ChatMessage = r?.AllowMessage ?? true,
FriendInvite = r?.AllowFriendRequest ?? true,
FavoriteFriendOnline = r?.AllowAnnouncements ?? true,
};
}
@@ -1,335 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Photos;
/// <summary>
/// api.rec.net/api/photos/v1/* — social-feed wrapper around the
/// in-game camera. The camera itself uses storage.rec.net/upload with
/// FileType=Image to push the PNG bytes; that returns a Filename. The
/// watch's "Share" panel then POSTs here with that Filename, a caption
/// and any tagged players to create the public-feed photo.
///
/// Public reads (feed, by-player, by-room) are anonymous so the
/// feed.rec.net frontend can browse without an admin token.
/// Mutations (post, cheer, delete) require auth.
/// </summary>
[ApiController]
// Two hosts: api.rec.net is what the in-game watch hits; feed.rec.net
// also serves these endpoints so the public frontend website can do
// same-origin fetches without CORS.
[Route("api/photos/v1")]
public class PhotosController(
DorkNetDbContext db,
PlayerPresenceService presence,
NotificationService notifications,
LevelService level,
DomainConfig domain) : ControllerBase
{
public sealed record PostPhotoRequest(
string ImageName,
string? Caption,
string? TaggedPlayerIds,
long? RoomId,
bool? IsPublic);
/// <summary>POST api/photos/v1/post — promote an uploaded image
/// blob to the photo feed. Body: ImageName (filename returned by
/// storage.rec.net/upload), Caption, TaggedPlayerIds (CSV),
/// optional RoomId (defaults to caller's current room from
/// PlayerPresenceService), IsPublic (defaults true). Returns the
/// created PhotoEntity.</summary>
[HttpPost("post")]
[Authorize]
public async Task<ActionResult> PostPhoto([FromBody] PostPhotoRequest body)
{
var me = this.RequireCurrentPlayerId();
if (string.IsNullOrWhiteSpace(body.ImageName))
return BadRequest(new { error = "missing_image_name" });
// Verify the blob actually exists and was uploaded by us. Without
// this check anyone could cite another player's filename and
// post-attribute the photo to themselves.
var blob = await db.RoomDataBlobs
.Where(b => b.BlobName == body.ImageName)
.Select(b => new { b.UploadedByPlayerId })
.FirstOrDefaultAsync();
if (blob is null)
return NotFound(new { error = "image_blob_not_found" });
if (blob.UploadedByPlayerId != me)
return Forbid();
// Auto-resolve current room if the caller didn't supply one.
var roomId = body.RoomId ?? presence.GetRoom(me)?.RoomId ?? 0;
var photo = new PhotoEntity
{
UploaderPlayerId = me,
BlobName = body.ImageName,
Caption = (body.Caption ?? string.Empty).Trim(),
TaggedPlayerIdsCsv = NormaliseTaggedIds(body.TaggedPlayerIds, exceptId: me),
RoomId = roomId,
IsPublic = body.IsPublic ?? true,
};
db.Photos.Add(photo);
await db.SaveChangesAsync();
// Posting a photo gives a small XP bump — encourages camera use.
await level.AwardXpAsync(me, LevelService.InventionSavedXp, $"photo_posted:{photo.Id}");
// Push to tagged players so their "Photos of me" feed refreshes.
foreach (var taggedId in ParseTagged(photo.TaggedPlayerIdsCsv))
await notifications.NotifyAsync(taggedId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "TaggedInPhoto", PhotoId = photo.Id, From = me });
return Ok(ToDto(photo));
}
/// <summary>GET api/photos/v1/feed — public newest-first feed.
/// Anonymous-safe (so the frontend website can render without a
/// JWT). Soft-deleted and private photos are excluded.</summary>
[HttpGet("feed")]
[AllowAnonymous]
public async Task<ActionResult> Feed([FromQuery] int take = 20, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var rows = await db.Photos
.Where(p => p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(await EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/by/{playerId} — photos posted by a
/// specific player. Anonymous-safe. Private photos hidden unless
/// the caller is the uploader (or an admin).</summary>
[HttpGet("by/{playerId:long}")]
[AllowAnonymous]
public async Task<ActionResult> ByPlayer(long playerId,
[FromQuery] int take = 20, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var me = this.CurrentPlayerId();
var includePrivate = me == playerId || await IsAdminAsync(me);
var q = db.Photos.Where(p => p.UploaderPlayerId == playerId && p.DeletedAt == null);
if (!includePrivate) q = q.Where(p => p.IsPublic);
var rows = await q
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(await EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/of/{playerId} — photos the player
/// is tagged in. Anonymous-safe.</summary>
[HttpGet("of/{playerId:long}")]
[AllowAnonymous]
public async Task<ActionResult> OfPlayer(long playerId,
[FromQuery] int take = 20, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var needle = $",{playerId},";
// SQLite EF translates `string.Contains` to LIKE — wrapping the
// CSV with leading/trailing commas avoids "12" matching "112".
var rows = await db.Photos
.Where(p => p.IsPublic && p.DeletedAt == null &&
("," + p.TaggedPlayerIdsCsv + ",").Contains(needle))
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(await EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/in/{roomId} — photos taken in a
/// specific room. Anonymous-safe.</summary>
[HttpGet("in/{roomId:long}")]
[AllowAnonymous]
public async Task<ActionResult> InRoom(long roomId,
[FromQuery] int take = 20, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var rows = await db.Photos
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(await EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/{id} — single photo detail.
/// Increments ViewCount as a side effect (denormalised for the
/// "trending" sort).</summary>
[HttpGet("{id:long}")]
[AllowAnonymous]
public async Task<ActionResult> Get(long id)
{
var p = await db.Photos.FirstOrDefaultAsync(x => x.Id == id);
if (p is null || p.DeletedAt is not null) return NotFound();
if (!p.IsPublic)
{
var me = this.CurrentPlayerId();
if (me != p.UploaderPlayerId && !await IsAdminAsync(me)) return NotFound();
}
p.ViewCount += 1;
await db.SaveChangesAsync();
var enriched = await EnrichAsync(new[] { p });
return Ok(enriched.First());
}
/// <summary>POST api/photos/v1/{id}/cheer — like a photo. Idempotent
/// per (caller, photo) pair. Pushes a notification to the
/// uploader so their watch can refresh the count.</summary>
[HttpPost("{id:long}/cheer")]
[Authorize]
public async Task<ActionResult> Cheer(long id)
{
var me = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null || photo.DeletedAt is not null) return NotFound();
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == me && c.TargetPhotoId == id &&
c.TargetPlayerId == 0 && c.TargetRoomId == 0);
if (existing is not null) return Ok(new { already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = me,
TargetPhotoId = id,
});
photo.CheerCount += 1;
await db.SaveChangesAsync();
if (photo.UploaderPlayerId != me)
{
await level.AwardXpAsync(photo.UploaderPlayerId,
LevelService.CheerReceivedXp, $"photo_cheer:{id}");
await notifications.NotifyAsync(photo.UploaderPlayerId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "PhotoCheer", PhotoId = id, From = me });
}
return Ok(new { cheered = true, count = photo.CheerCount });
}
/// <summary>DELETE api/photos/v1/{id} — soft-delete. Allowed for
/// the uploader or an admin. The blob bytes stay in
/// RoomDataBlobs for audit; the row's DeletedAt timestamp hides
/// it from feeds.</summary>
[HttpDelete("{id:long}")]
[Authorize]
public async Task<ActionResult> Delete(long id)
{
var me = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != me && !await IsAdminAsync(me))
return Forbid();
photo.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
// ── Helpers ──────────────────────────────────────────────────────
/// <summary>Trim/dedupe tagged ids and drop the uploader's own id.
/// CSV in, CSV out.</summary>
private static string NormaliseTaggedIds(string? csv, long exceptId)
{
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0 && v != exceptId)
.Distinct()
.ToList();
return string.Join(",", ids);
}
private static IEnumerable<long> ParseTagged(string? csv) =>
string.IsNullOrWhiteSpace(csv)
? Enumerable.Empty<long>()
: csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0);
private async Task<bool> IsAdminAsync(long? playerId)
{
if (playerId is not long id) return false;
return await db.Players.Where(p => p.Id == id).Select(p => p.IsAdmin).FirstOrDefaultAsync();
}
/// <summary>Materialise photo rows into the wire DTO shape. One
/// extra DB hit fetches the uploader display names and room names
/// in batch so the JSON has human-readable labels — saves the
/// frontend from a per-row N+1.</summary>
private async Task<List<object>> EnrichAsync(IEnumerable<PhotoEntity> photos)
{
var photoList = photos.ToList();
if (photoList.Count == 0) return new();
var uploaderIds = photoList.Select(p => p.UploaderPlayerId).Distinct().ToList();
var roomIds = photoList.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList();
var uploaderNames = await db.Players
.Where(p => uploaderIds.Contains(p.Id))
.Select(p => new { p.Id, p.DisplayName, p.Username })
.ToListAsync();
var roomNames = await db.Rooms
.Where(r => roomIds.Contains(r.Id))
.Select(r => new { r.Id, r.Name })
.ToListAsync();
var uMap = uploaderNames.ToDictionary(u => u.Id);
var rMap = roomNames.ToDictionary(r => r.Id);
return photoList.Select(p =>
{
uMap.TryGetValue(p.UploaderPlayerId, out var u);
rMap.TryGetValue(p.RoomId, out var r);
return (object)new
{
p.Id,
p.UploaderPlayerId,
UploaderDisplayName = u?.DisplayName ?? $"Player_{p.UploaderPlayerId}",
UploaderUsername = u?.Username ?? string.Empty,
p.BlobName,
ImageUrl = $"https://{domain.Sub("cdn")}/{p.BlobName}",
p.Caption,
p.RoomId,
RoomName = r?.Name ?? string.Empty,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
p.IsPublic,
p.CheerCount,
p.ViewCount,
p.CreatedAt,
};
}).ToList();
}
private object ToDto(PhotoEntity p) => new
{
p.Id,
p.UploaderPlayerId,
p.BlobName,
ImageUrl = $"https://{domain.Sub("cdn")}/{p.BlobName}",
p.Caption,
p.RoomId,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
p.IsPublic,
p.CheerCount,
p.ViewCount,
p.CreatedAt,
};
}
@@ -1,255 +0,0 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Compat2018;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlatformLogin.V1;
/// <summary>
/// api.rec.net/api/platformlogin/v1/* — the JUNE-2018 login family.
///
/// The 2018 client (build 20180621_EA) logs in via a JSON body and parses the
/// PascalCase "login envelope" {Error, Player, Token, FirstLoginOfTheDay,
/// AnalyticsSessionId} (RecNet.cs:40014-40021). This is wholly different from
/// the 2019/2020 OAuth password-grant flow handled by the V2 controller, so it
/// lives in its own versioned controller. This branch targets the 2018 client;
/// the V2 controller still serves 2019/2020 builds.
///
/// Bodies are read leniently from JSON OR form OR query so JsonUtility,
/// LitJson, and form-encoded callers all bind. The single login Token IS the
/// JWT access token — the 2018 client re-attaches it as "Bearer {Token}" on
/// every authenticated call and refreshes it via GET v1/refreshlogin.
/// </summary>
[ApiController]
[Route("api/platformlogin/v1")]
public class PlatformLoginV1Controller(
PlayerService playerService,
AuthService authService,
ServerSettingsService settings,
SignupCodeService signupCodes,
DorkNetDbContext db,
ILogger<PlatformLoginV1Controller> logger) : ControllerBase
{
// POST api/platformlogin/v1/loginaccount — primary sign-in.
[HttpPost("loginaccount")]
public async Task<IActionResult> LoginAccount()
{
var p = await ReadParamsAsync();
var username = p.Get("Username");
var password = p.Get("Password");
var deviceId = p.Get("DeviceId");
var platformId = p.Get("PlatformId");
var platform = p.GetInt("Platform");
PlayerEntity player;
// Username + password: real credential check against PasswordHash.
if (!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(username))
{
var byName = await playerService.GetByUsernameAsync(username);
if (byName?.PasswordHash is null ||
!BCrypt.Net.BCrypt.Verify(password, byName.PasswordHash))
return Ok(Fail("Incorrect username or password."));
player = byName;
await playerService.TagPlatformAsync(player.Id, platform, platformId);
}
else
{
// Device-id fallback (no password yet). Honour the signups-disabled
// toggle: refuse before minting a brand-new account for an unknown
// device, and stash it for /join code redemption.
if (await settings.AreSignupsDisabledAsync()
&& await playerService.GetByDeviceAsync(deviceId) is null)
{
await signupCodes.RecordPendingDeviceAsync(
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
return Ok(Fail("Account creation is currently disabled by the server admin."));
}
player = await playerService.GetOrCreateByDeviceAsync(
deviceId: deviceId, platform: platform, platformId: platformId,
displayName: username);
}
return Ok(BuildEnvelope(player));
}
// POST api/platformlogin/v1/logincached — sign in to a remembered account
// (the account-selection screen). Body carries PlayerId.
[HttpPost("logincached")]
public async Task<IActionResult> LoginCached()
{
var p = await ReadParamsAsync();
var id = p.GetLong("PlayerId");
if (id == 0) return Ok(Fail("Missing PlayerId."));
var player = await playerService.GetByIdAsync(id);
if (player is null) return Ok(Fail("That account no longer exists."));
await playerService.TagPlatformAsync(player.Id, p.GetInt("Platform"), p.Get("PlatformId"));
return Ok(BuildEnvelope(player));
}
// POST api/platformlogin/v1/createaccount — create AND sign in in one call.
[HttpPost("createaccount")]
public async Task<IActionResult> CreateAccount()
{
var p = await ReadParamsAsync();
var deviceId = p.Get("DeviceId");
var platformId = p.Get("PlatformId");
var platform = p.GetInt("Platform");
if (await settings.AreSignupsDisabledAsync()
&& await playerService.GetByDeviceAsync(deviceId) is null)
{
await signupCodes.RecordPendingDeviceAsync(
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
return Ok(Fail("Account creation is currently disabled by the server admin."));
}
var player = await playerService.CreateNewAccountAsync(
deviceId: deviceId, platform: platform, platformId: platformId,
displayName: p.Get("Username"));
// Attach birthday / email if the signup form supplied them.
var birthdayTicks = p.GetLong("Birthday");
var email = p.Get("Email");
if (birthdayTicks != 0 || !string.IsNullOrEmpty(email))
{
var entity = await db.Players.FirstOrDefaultAsync(x => x.Id == player.Id);
if (entity is not null)
{
if (birthdayTicks != 0)
entity.Birthday = new DateTime(birthdayTicks, DateTimeKind.Utc);
if (!string.IsNullOrEmpty(email)) entity.Email = email;
await db.SaveChangesAsync();
player = entity;
}
}
return Ok(BuildEnvelope(player, firstLogin: true));
}
// POST api/platformlogin/v1/registeraccount?Email=... — attach an email to
// the authenticated account. Reads {Success, Message} (NOT "error").
[HttpPost("registeraccount")]
public async Task<IActionResult> RegisterAccount([FromQuery(Name = "Email")] string? email)
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Ok(new SuccessMessage2018 { Success = false, Message = "Not signed in." });
var entity = await db.Players.FirstOrDefaultAsync(x => x.Id == pid);
if (entity is null) return Ok(new SuccessMessage2018 { Success = false, Message = "Account not found." });
if (!string.IsNullOrWhiteSpace(email)) { entity.Email = email; await db.SaveChangesAsync(); }
return Ok(new SuccessMessage2018 { Success = true, Message = string.Empty });
}
// GET api/platformlogin/v1/refreshlogin — mint a fresh access token from the
// current bearer. Response is {Token} (PascalCase). The 2018 client calls
// Application.Quit() on an empty Token, so a 401 (no body) is the correct
// "session ended" signal.
[HttpGet("refreshlogin")]
public async Task<IActionResult> RefreshLogin()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
if (authService.ValidateToken(token) is not long pid) return Unauthorized();
var player = await playerService.GetByIdAsync(pid);
if (player is null) return Unauthorized();
var (access, _) = authService.GenerateTokenPair(player.Id);
return Ok(new { Token = access });
}
// POST api/platformlogin/v1/getcachedlogins?Platform=&PlatformId= — the
// account-selection list. Returns a BARE JSON ARRAY of Player2018 objects.
[HttpPost("getcachedlogins")]
[HttpGet("getcachedlogins")]
public async Task<IActionResult> GetCachedLogins()
{
// The 2018 client sends Platform/PlatformId in the FORM BODY
// (application/x-www-form-urlencoded), not the query string — so read
// from form/query/json. Reading [FromQuery] only returned [] (empty
// account grid + broke +profile auto-login). Verified from the wire:
// req=Platform=0&PlatformId=76561198969189675.
var p = await ReadParamsAsync();
var players = await playerService.GetCachedLoginsAsync(p.GetInt("Platform"), p.Get("PlatformId"));
return Ok(players.Select(ToPlayer2018).ToList());
}
// POST api/platformlogin/v1/removecachedlogin?Platform=&PlatformId= —
// "forget this account". No persistent cache to clear; ack with 200.
[HttpPost("removecachedlogin")]
public IActionResult RemoveCachedLogin() => Ok(new SuccessMessage2018 { Success = true });
// POST api/platformlogin/v1/logout?LoginLockToken=... — fire-and-forget.
[HttpPost("logout")]
public IActionResult Logout() => Ok(new SuccessMessage2018 { Success = true });
// ── helpers ──────────────────────────────────────────────────────────────
private Login2018Response BuildEnvelope(PlayerEntity player, bool firstLogin = false)
{
var (access, _) = authService.GenerateTokenPair(player.Id);
// First-login-of-the-day is cosmetic (drives a daily XP bonus); derive
// it from LastSeenAt where we still have the pre-update value.
var first = firstLogin || player.LastSeenAt.Date < DateTime.UtcNow.Date;
return new Login2018Response
{
Error = string.Empty,
Player = ToPlayer2018(player),
Token = access,
FirstLoginOfTheDay = first,
AnalyticsSessionId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
}
private static Login2018Response Fail(string message) => new() { Error = message };
private static Player2018 ToPlayer2018(PlayerEntity p) => Player2018Mapper.From(p);
/// <summary>Read request params from JSON body, form, and query — merged,
/// case-insensitive — so JsonUtility/LitJson/form/query callers all bind.</summary>
private async Task<ParamBag> ReadParamsAsync()
{
var bag = new ParamBag();
foreach (var kv in Request.Query) bag.Set(kv.Key, kv.Value.ToString());
if (Request.HasFormContentType)
{
try { foreach (var kv in Request.Form) bag.Set(kv.Key, kv.Value.ToString()); }
catch { /* not a form */ }
}
else if (Request.ContentLength is > 0)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
foreach (var prop in doc.RootElement.EnumerateObject())
bag.Set(prop.Name, prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString(),
JsonValueKind.Number => prop.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null,
});
Request.Body.Position = 0;
}
catch { /* not JSON */ }
}
return bag;
}
private sealed class ParamBag
{
private readonly Dictionary<string, string?> _d = new(StringComparer.OrdinalIgnoreCase);
public void Set(string k, string? v) { if (!string.IsNullOrEmpty(v)) _d[k] = v; }
public string? Get(string k) => _d.TryGetValue(k, out var v) ? v : null;
public int GetInt(string k) => int.TryParse(Get(k), out var v) ? v : 0;
public long GetLong(string k) => long.TryParse(Get(k), out var v) ? v : 0;
}
}
@@ -1,214 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlatformLogin.V2;
/// <summary>
/// api.rec.net/api/platformlogin/* — OAuth-style password-grant login.
/// Login.LOGIN_API in dump.cs (line 578249) is "api/platformlogin/" — the
/// version suffix (v2 / v5) is appended by callers. The 2020 build uses v5.
///
/// Request body is application/x-www-form-urlencoded (HTTPUrlEncodedForm in
/// Login.LoginHelper, dump.cs:578319), NOT JSON. Form fields are bound
/// case-insensitively by ASP.NET Core, so the parameter names don't have to
/// match the on-the-wire casing exactly.
///
/// Response is a Login.LoginResponse: { Error, ErrorDescription,
/// AccessToken, RefreshToken } — JSON keys must be PascalCase (the
/// Util.GetKey calls inside Login.LoginResponse.Deserialize pass PascalCase
/// string literals; verified via global-metadata.dat scan).
/// </summary>
[ApiController]
[Route("api/platformlogin")]
public class PlatformLoginController(
PlayerService playerService,
AuthService authService,
ServerSettingsService settings,
SignupCodeService signupCodes,
DorkNetDbContext db) : ControllerBase
{
// Login.LoginToAccount → POST api/platformlogin/v2 / v5
// Form fields the 2020 client sends are not yet fully confirmed; common
// OAuth-2 password-grant + Rec Room platform fields:
// grant_type, username, password, platform, platformId, deviceId,
// buildTimestamp, version. Bind permissively and ignore extras.
[HttpPost("v2")]
[HttpPost("v5")]
public async Task<ActionResult<LoginResponse>> Login(
[FromForm(Name = "username")] string? Username,
[FromForm(Name = "Name")] string? Name,
[FromForm(Name = "platform")] int Platform = 0,
[FromForm(Name = "platformId")] string? PlatformId = null,
[FromForm(Name = "deviceId")] string? DeviceId = null,
[FromForm(Name = "buildTimestamp")] long BuildTimestamp = 0,
[FromForm(Name = "version")] string? Version = null)
{
var displayName = Username ?? Name ?? PlatformId ?? "Player";
// Admin "signups disabled" gate. Mirrors the same guard on
// /connect/token's device-id fallback in AuthController — if
// the device has no existing account, GetOrCreate would mint a
// new one here, bypassing the toggle. Refuse pre-creation.
if (await settings.AreSignupsDisabledAsync()
&& await playerService.GetByDeviceAsync(DeviceId) is null)
{
// Stash the refused device so the player can claim it on the
// /join page (matched by IP) with a signup code.
await signupCodes.RecordPendingDeviceAsync(
DeviceId, Platform, PlatformId, SignupCodeService.ClientIp(HttpContext));
return Unauthorized(new LoginResponse
{
Error = "signups_disabled",
ErrorDescription = "Account creation is currently disabled by the server admin.",
});
}
// Account identity is keyed off deviceId. The service handles the
// null/empty case by synthesising a stable id from platformId or a
// GUID — either way, no two unrelated callers ever share an account.
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: DeviceId,
platform: Platform,
platformId: PlatformId,
displayName: displayName);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new LoginResponse
{
AccessToken = access,
RefreshToken = refresh,
});
}
// Login.DownloadCachedLogins → GET api/platformlogin/cached
// Returns List<Login.CachedLogin>. Empty array means no remembered accounts.
//
// The 2020 client doesn't include a platformId in the path here, but
// BestHTTP appends current-session credentials as query params on these
// bootstrap calls — so we accept platform/platformId via query or form
// and reuse the same lookup as auth.rec.net/cachedlogin/forplatformid.
[HttpGet("cached")]
[HttpGet("cachedlogins")]
[HttpGet("savedlogins")]
[HttpGet("v1/cached")]
[HttpGet("v2/cached")]
public async Task<IActionResult> GetCachedLogins(
[FromQuery] int platform = 0,
[FromQuery] string? platformId = null)
{
var players = await playerService.GetCachedLoginsAsync(platform, platformId);
return Ok(players.Select(p => new CachedLogin
{
Platform = platform,
PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
}).ToList());
}
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
[HttpDelete("cached")]
[HttpDelete("cachedlogins")]
public IActionResult RemoveCachedLogin() => Ok(new { });
// Login.RefreshLogin → POST api/platformlogin/refresh
// Response is Login.RefreshLoginResponse { Token } — single field, the
// new access token. (Note: dump.cs Login.RefreshLoginResponse has only
// "Token", not "AccessToken".)
[HttpPost("refresh")]
public async Task<IActionResult> Refresh()
{
// Refresh requires a valid bearer (access OR refresh JWT); both are
// signed with the same secret so ValidateToken accepts either.
// Refusing to "GetOrCreateAsync(null)" here means a stale install
// can't quietly mint a brand-new account when its refresh token is
// gone — it has to go through the cached-login flow instead.
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var resolved = authService.ValidateToken(token);
if (resolved is not long playerId)
return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
var (access, _) = authService.GenerateTokenPair(player.Id);
return Ok(new RefreshLoginResponse { Token = access });
}
// Login.LoginToCachedAccount(int accountId) → POST api/platformlogin/cached/{accountId}
// Returns the same Login.LoginResponse shape as the regular login.
[HttpPost("cached/{accountId:long}")]
public async Task<IActionResult> LoginToCachedAccount(
long accountId,
[FromForm] int platform = 0,
[FromForm(Name = "platformId")] string? platformId = null,
[FromForm(Name = "platform_id")] string? platformIdSnake = null)
{
// No GetOrCreateAsync(null) fallback — if the cached id doesn't
// resolve, the cache is stale (another DB instance, deleted account,
// etc.) and we'd rather force the client back to the create-account
// flow than silently mint a new player.
var player = await playerService.GetByIdAsync(accountId);
if (player is null) return Unauthorized();
await playerService.TagPlatformAsync(player.Id, platform, platformId ?? platformIdSnake);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new LoginResponse
{
AccessToken = access,
RefreshToken = refresh,
});
}
// Login.CheckHasPassword → GET api/platformlogin/haspassword
// Client uses ExpectPrimitiveResponse<bool> → Convert.ChangeType
// on the raw response, so we MUST emit a bare boolean literal.
// Real password state lives on PlayerEntity.PasswordHash;
// AuthAccountController on auth.* does the same lookup against the
// authenticated player. We mirror it here so the api.* host also
// returns the truthful value instead of an always-true stub.
[HttpGet("haspassword")]
public async Task<IActionResult> HasPassword()
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Content("false", "application/json");
var player = await playerService.GetByIdAsync(pid);
var has = player?.PasswordHash is { Length: > 0 };
return Content(has ? "true" : "false", "application/json");
}
// Login.CreatePassword / ChangePassword / ResetPassword on auth.*
// already do the real bcrypt write — they're wired in
// AuthAccountController. The api.* path duplicates the route
// without persisting; rather than mirror the full logic here,
// delegate to AuthService.ChangePassword for consistency.
[HttpPost("password")]
[HttpPut("password")]
public async Task<IActionResult> SetPassword([FromForm(Name = "newPassword")] string? newPassword,
[FromForm(Name = "password")] string? legacy)
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Unauthorized();
var pw = newPassword ?? legacy ?? string.Empty;
if (pw.Length < 6) return Ok(new RecNetResult { Success = false, Error = "password_too_short" });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is null) return NotFound();
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(pw, workFactor: 11);
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("resetpassword")]
public IActionResult ResetPassword()
{
// Real password reset would email a token; on a private
// server with no SMTP this is an explicit no-op (the admin
// SPA "change password" tool is the supported path).
return Ok(new RecNetResult { Success = false, Error = "reset_not_supported" });
}
}
@@ -1,107 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerCheer;
/// <summary>
/// api.{rec.net,localhost}/api/PlayerCheer/v1/* — peer cheer flow.
/// Cheers between players are stored as <see cref="CheerEntity"/>
/// rows keyed by <c>TargetPlayerId</c>. The "selected cheer" badge
/// each player can pin to their profile is stored as a
/// <see cref="PlayerSettingEntity"/> with key <c>SelectedCheer</c>.
/// </summary>
[ApiController]
[Authorize]
public class PlayerCheerController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers
/// the target player. <c>Type</c> matches the same enum as
/// <c>CheerEntity.Type</c>. Idempotent on (caller, target, type)
/// to match <c>PlayerStateController.CheerPlayer</c>, so the same
/// category click doesn't inflate the count.</summary>
[HttpPost("api/PlayerCheer/v1/create")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> Create(
[FromForm(Name = "TargetAccountId")] long? targetAccountId,
[FromForm(Name = "PlayerIdTo")] long? playerIdTo,
[FromForm(Name = "Type")] int type = 0,
[FromForm(Name = "CheerCategory")] int? cheerCategory = null)
{
// 2018 client posts PlayerIdTo + CheerCategory (RecNet.cs); 2020 posts
// TargetAccountId + Type. Accept both.
type = cheerCategory ?? type;
if ((targetAccountId ?? playerIdTo) is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" });
if (Me == target)
return Ok(new { success = false, error = "cannot_cheer_self" });
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == Me && c.TargetPlayerId == target &&
c.TargetRoomId == 0 && c.Type == type);
if (existing is not null) return Ok(new { success = true, already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = Me,
TargetPlayerId = target,
Type = type,
});
await db.SaveChangesAsync();
await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}");
await notifications.NotifyAsync(target,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "CheerReceived", From = Me, Type = type });
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
/// a cheer category as the caller's selected badge. Stored on the
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).</summary>
[HttpPost("api/PlayerCheer/v1/SetSelectedCheer")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetSelectedCheer(
[FromForm(Name = "Cheer")] int cheer = 0,
[FromForm(Name = "CheerCategory")] int? cheerCategory = null)
{
cheer = cheerCategory ?? cheer; // 2018 sends CheerCategory
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer");
if (existing is null)
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = cheer.ToString() });
else
existing.Value = cheer.ToString();
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
/// <summary>GET <c>api/PlayerCheer/v1/all</c> — every cheer the
/// caller has received. Drives the profile's "X cheers" badge.</summary>
[HttpGet("api/PlayerCheer/v1/all")]
public async Task<IActionResult> All()
{
var rows = await db.Cheers
.Where(c => c.TargetPlayerId == Me)
.OrderByDescending(c => c.CheeredAt)
.Take(200)
.Select(c => new
{
Id = c.Id,
FromPlayerId = c.FromPlayerId,
Type = c.Type,
CheeredAt = c.CheeredAt,
})
.ToListAsync();
return Ok(rows);
}
}
@@ -1,69 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.PlayerElo;
/// <summary>
/// api.rec.net/api/PlayerElo/* — match-result Elo deltas. The 2020
/// client posts after each ranked match (Royale, Paintball, Soccer)
/// with a delta the server applies to the per-player Elo row.
///
/// Wire request matches <c>RecNet.Elo.ReportPlayerElo</c>:
/// <c>{GameMode(int), EloDelta(int), Won(bool)}</c>
/// (we tolerate a few synonym fields). Response: the new Elo as a
/// JSON primitive int.
/// </summary>
[ApiController]
public class PlayerEloController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
public sealed class ReportEloRequest
{
public int GameMode { get; set; }
public int EloDelta { get; set; }
public bool Won { get; set; }
}
[HttpPost("api/PlayerElo/v1/reportPlayerElo")]
[Authorize]
public async Task<IActionResult> ReportPlayerElo([FromBody] ReportEloRequest req)
{
var pid = Me;
var row = await db.PlayerElo.FirstOrDefaultAsync(p =>
p.PlayerId == pid && p.GameMode == req.GameMode);
if (row is null)
{
row = new PlayerEloEntity
{
PlayerId = pid,
GameMode = req.GameMode,
};
db.PlayerElo.Add(row);
}
// Clamp Elo to a reasonable range to avoid overflow exploits.
row.Elo = Math.Clamp(row.Elo + req.EloDelta, 0, 5000);
row.MatchesPlayed += 1;
if (req.Won) row.Wins += 1; else row.Losses += 1;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(row.Elo);
}
[HttpGet("api/PlayerElo/v1/get")]
public async Task<IActionResult> Get(
[FromQuery] long? playerId, [FromQuery] int gameMode = 0)
{
var pid = playerId ?? this.CurrentPlayerId() ?? 0;
if (pid == 0) return Ok(new { Elo = 1000, Wins = 0, Losses = 0 });
var row = await db.PlayerElo.FirstOrDefaultAsync(p =>
p.PlayerId == pid && p.GameMode == gameMode);
return Ok(row is null
? new { Elo = 1000, Wins = 0, Losses = 0 }
: new { row.Elo, row.Wins, row.Losses });
}
}
@@ -1,319 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerEvents;
/// <summary>
/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for
/// player-scheduled gatherings.
/// </summary>
[ApiController]
[Authorize]
public class PlayerEventsController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// ── Browse (v2) ──────────────────────────────────────────────────────
/// <summary>GET <c>api/playerevents/v2</c> — upcoming events list.
/// Returns the next 50 events ordered by start time. Wire shape
/// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId,
/// Title, Description, StartsAt, EndsAt, Capacity).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v2")]
public async Task<ActionResult> ListUpcoming(
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var rows = await db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v2/{eventId:long}")]
public async Task<ActionResult> GetOne(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
return Ok(ToWire(ev));
}
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
/// events). Response keys (both required, via
/// <c>Util.GetObjectListKey</c>): <c>Created</c>, <c>Responses</c>.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all")]
public async Task<ActionResult> AllPlayerEvents()
{
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Ok(new { Created = Array.Empty<object>(), Responses = Array.Empty<object>() });
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == me)
.OrderByDescending(e => e.StartsAt)
.Select(e => new
{
e.Id,
e.Title,
e.Description,
e.RoomId,
e.StartsAt,
e.EndsAt,
e.Capacity,
})
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == me)
.Select(r => new
{
r.Id,
r.EventId,
r.Response,
r.CreatedAt,
})
.ToListAsync();
return Ok(new { Created = created, Responses = responses });
}
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
/// as <c>v1/{id}/rsvp</c>; behaves identically.</summary>
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
=> Rsvp(eventId, body);
/// <summary>Wire shape verified against
/// <c>Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815</c>:
/// PascalCase keys <c>PlayerEventId, Name, Description,
/// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId,
/// ImageName, Accessibility</c>. Server-side we store these
/// under different names (Title, StartsAt, EndsAt, Capacity) —
/// remap explicitly here so the watch's
/// <c>PlayerEvent.Deserialize</c> picks them up.</summary>
private static object ToWire(PlayerEventEntity ev) => new
{
PlayerEventId = ev.Id,
Name = ev.Title,
ev.Description,
StartTime = ev.StartsAt,
EndTime = ev.EndsAt,
CreatorPlayerId = (int)ev.CreatorPlayerId,
AttendeeCount = 0, // computed at query time when needed
ev.RoomId,
ImageName = string.Empty,
Accessibility = 1, // Public
};
public sealed record CreateEventRequest(
string Title, string? Description, long RoomId,
DateTime StartsAt, DateTime EndsAt, int Capacity);
[HttpPost("api/playerevents/v1/create")]
[HttpPost("api/playerevents/v1")]
public async Task<ActionResult> Create([FromBody] CreateEventRequest req)
{
if (string.IsNullOrWhiteSpace(req.Title))
return BadRequest("missing_title");
var ev = new PlayerEventEntity
{
CreatorPlayerId = Me,
RoomId = req.RoomId,
Title = req.Title.Trim(),
Description = (req.Description ?? string.Empty).Trim(),
StartsAt = req.StartsAt,
EndsAt = req.EndsAt > req.StartsAt ? req.EndsAt : req.StartsAt.AddHours(1),
Capacity = Math.Max(0, req.Capacity),
};
db.PlayerEvents.Add(ev);
await db.SaveChangesAsync();
return Ok(new
{
ev.Id, ev.Title, ev.Description, ev.RoomId,
ev.StartsAt, ev.EndsAt, ev.Capacity,
});
}
public sealed record RsvpRequest(int Response);
[HttpPost("api/playerevents/v1/{eventId:long}/rsvp")]
public async Task<ActionResult> Rsvp(long eventId, [FromBody] RsvpRequest body)
{
if (body.Response < 0 || body.Response > 2)
return BadRequest("invalid_response");
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
var row = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.EventId == eventId && r.PlayerId == Me);
if (row is null)
{
row = new PlayerEventResponseEntity { EventId = eventId, PlayerId = Me };
db.PlayerEventResponses.Add(row);
}
row.Response = body.Response;
await db.SaveChangesAsync();
// Tell the creator someone RSVP'd. They can ignore but the
// watch will refresh their event detail card.
if (ev.CreatorPlayerId != Me)
await notifications.NotifyAsync(ev.CreatorPlayerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, From = Me, body.Response });
return Ok(new { row.EventId, row.PlayerId, row.Response });
}
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
/// caller's RSVP state for an event. <c>Response</c> is the
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
/// Idempotent — re-responding overwrites.</summary>
[HttpPost("api/playerevents/v1/respond")]
public async Task<IActionResult> RespondForm(
[FromForm(Name = "EventId")] long? eventId,
[FromForm(Name = "Response")] int response = 0)
{
if (eventId is not long evt || evt <= 0) return Ok(new { success = false });
var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null)
db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response });
else
existing.Response = response;
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
/// the caller's RSVP row.</summary>
[HttpPost("api/playerevents/v1/deleteResponse")]
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
{
if (eventId is not long evt) return Ok(new { success = true });
await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync();
return Ok(new { success = true, error = "" });
}
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
/// delete an event the caller created.</summary>
[HttpDelete("api/playerevents/v2/delete/{id:long}")]
public async Task<IActionResult> DeleteByPath(long id)
{
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me);
if (evt is null) return NotFound();
db.PlayerEvents.Remove(evt);
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
[HttpDelete("api/playerevents/v1/{eventId:long}")]
public async Task<ActionResult> Delete(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
db.PlayerEvents.Remove(ev);
// Cascade-clean the responses; SQLite has no FK cascade
// configured here so do it manually.
var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId);
db.PlayerEventResponses.RemoveRange(responses);
await db.SaveChangesAsync();
return Ok();
}
// ── Bulk-invite (Phase 8) ────────────────────────────────────────────
public sealed class BulkInviteRequest
{
public long PlayerEventId { get; set; }
public List<int>? InvitedPlayerIds { get; set; }
}
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c> — wire
/// type per agent ISIL extraction: <c>BulkInviteRequest{PlayerEventId,
/// InvitedPlayerIds:List&lt;int&gt;}</c>; response
/// <c>BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}</c>.
/// We insert one MessageEntity per recipient so the watch's inbox
/// shows the invite, and push a per-recipient notification.</summary>
[HttpPost("api/playerevents/v1/bulkInvite")]
public async Task<IActionResult> BulkInvite([FromBody] BulkInviteRequest req)
{
if (req?.InvitedPlayerIds is null || req.InvitedPlayerIds.Count == 0)
return Ok(new { FailedInvites = Array.Empty<object>(), Result = 0 });
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
var failed = new List<object>();
var sender = Me;
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
foreach (var rid in req.InvitedPlayerIds.Distinct())
{
var exists = await db.Players.AnyAsync(p => p.Id == rid);
if (!exists)
{
failed.Add(new { PlayerId = rid, Error = "unknown player" });
continue;
}
db.Messages.Add(new MessageEntity
{
SenderPlayerId = sender,
RecipientPlayerId = rid,
Body = inviteText,
});
await notifications.NotifyAsync(rid,
PushNotificationId.PlayerEventCreated,
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
}
await db.SaveChangesAsync();
return Ok(new { FailedInvites = failed, Result = 0 });
}
// ── Report (Phase 8) ─────────────────────────────────────────────────
public sealed class PlayerEventReportRequest
{
public int ReportCategory { get; set; }
public long PlayerEventId { get; set; }
public string? Details { get; set; }
}
[HttpPost("api/playerevents/v1/report")]
public async Task<IActionResult> Report([FromBody] PlayerEventReportRequest req)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
var msg = (req.Details ?? string.Empty).Trim();
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = Me,
TargetPlayerId = ev.CreatorPlayerId,
TargetEventId = req.PlayerEventId,
Category = req.ReportCategory,
Message = msg[..Math.Min(1000, msg.Length)],
});
await db.SaveChangesAsync();
return Ok(new { Reported = true });
}
}
@@ -1,210 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.PlayerReporting;
/// <summary>
/// api.rec.net/api/PlayerReporting/v1/* — real player report flow.
/// The 2020 client's "report player" UI in the watch posts here.
/// Form fields the watch sends (per the in-game send_report dialog):
/// <c>TargetPlayerId</c> — int account id of the offender
/// <c>Type</c> — RoomieReportCategory enum (1..5)
/// <c>Message</c> — free-form description (capped at 1000)
/// <c>GameSessionId</c> — optional, current session for context
/// <c>RoomId</c> — optional, room the offence happened in
///
/// Anyone authenticated can file a report. Admins read the queue
/// from <c>GET api/admin/v1/reports</c>.
/// </summary>
[ApiController]
[Authorize]
public class ReportsController(DorkNetDbContext db) : ControllerBase
{
[HttpPost("api/PlayerReporting/v1/report")]
[HttpPost("api/PlayerReporting/v1/playerreport")]
[HttpPost("api/PlayerReporting/v2/report")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SubmitReport(
[FromForm(Name = "TargetPlayerId")] long? targetPlayerId,
[FromForm(Name = "Type")] int type = 0,
[FromForm(Name = "Message")] string? message = null,
[FromForm(Name = "GameSessionId")] long gameSessionId = 0,
[FromForm(Name = "RoomId")] long roomId = 0)
{
if (targetPlayerId is not long target || target <= 0)
return BadRequest(new { success = false, error = "missing_target" });
var reporter = this.RequireCurrentPlayerId();
if (reporter == target)
return BadRequest(new { success = false, error = "cannot_report_self" });
// Cap message length to match the watch UI's enforcement +
// the column max — anything longer is almost certainly junk.
var trimmed = (message ?? string.Empty).Trim();
if (trimmed.Length > 1000) trimmed = trimmed[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
TargetPlayerId = target,
Category = type,
Message = trimmed,
GameSessionId = gameSessionId,
RoomId = roomId,
});
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
/// <summary>GET <c>api/PlayerReporting/v2/detail?playerId={id}</c>
/// — moderator detail panel. Lists recent reports against the
/// target player. Public — admin-only mutations are elsewhere.</summary>
[HttpGet("api/PlayerReporting/v2/detail")]
public async Task<IActionResult> ReportDetail(
[FromQuery] long? playerId, [FromQuery] long? accountId)
{
var pid = playerId ?? accountId ?? 0;
if (pid <= 0) return Ok(new { PlayerId = 0L, OpenReports = 0, RecentReports = Array.Empty<object>() });
var reports = await db.Reports
.Where(r => r.TargetPlayerId == pid)
.OrderByDescending(r => r.CreatedAt)
.Take(20)
.ToListAsync();
return Ok(new
{
PlayerId = pid,
OpenReports = reports.Count(r => r.ResolvedAt == null),
RecentReports = reports.Select(r => new
{
r.Id, r.Category, r.Message,
r.ReporterPlayerId, r.RoomId, r.CreatedAt, r.ResolvedAt,
}),
});
}
public sealed class V2BanRequest
{
public long PlayerId { get; set; }
public int? Category { get; set; }
public string? Reason { get; set; }
public DateTime? Until { get; set; }
}
/// <summary>POST <c>api/PlayerReporting/v2/ban</c> — admin-only.
/// Sets <see cref="PlayerEntity.BannedUntil"/> + appends a
/// resolved <see cref="ReportEntity"/> for the audit trail.</summary>
[HttpPost("api/PlayerReporting/v2/ban")]
[Authorize]
public async Task<IActionResult> V2Ban([FromBody] V2BanRequest req)
{
var me = this.RequireCurrentPlayerId();
if (req.PlayerId <= 0) return BadRequest(new { error = "missing playerId" });
var isAdmin = await db.Players
.Where(p => p.Id == me)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
if (!isAdmin) return Forbid();
var target = await db.Players.FirstOrDefaultAsync(p => p.Id == req.PlayerId);
if (target is null) return NotFound();
target.BannedUntil = req.Until ?? DateTime.UtcNow.AddYears(100);
var reason = req.Reason ?? string.Empty;
if (reason.Length > 1000) reason = reason[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = me,
TargetPlayerId = req.PlayerId,
Category = req.Category ?? 5,
Message = reason,
ResolvedAt = DateTime.UtcNow,
ResolverAdminId = me,
ResolutionNote = "v2/ban",
});
await db.SaveChangesAsync();
return Ok(new { Banned = true, target.Id, target.BannedUntil });
}
public sealed class HileRequest
{
public string? Detail { get; set; }
public string? Body { get; set; }
public string? Message { get; set; }
public int? Type { get; set; }
public long? GameSessionId { get; set; }
}
/// <summary>POST <c>api/PlayerReporting/v1/hile</c> — anti-cheat
/// heartbeat. Stored as a <see cref="BugReportEntity"/> tagged
/// <c>hile</c> so admins can audit the stream.</summary>
[HttpPost("api/PlayerReporting/v1/hile")]
public async Task<IActionResult> Hile()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return await SaveHileAsync(
form["Message"].FirstOrDefault() ??
form["Detail"].FirstOrDefault() ??
form["Body"].FirstOrDefault(),
int.TryParse(form["Type"].FirstOrDefault(), out var t) ? t : (int?)null,
long.TryParse(form["GameSessionId"].FirstOrDefault(), out var g) ? g : (long?)null);
}
HileRequest? body = null;
try
{
body = await JsonSerializer.DeserializeAsync<HileRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException) { }
return await SaveHileAsync(
body?.Message ?? body?.Detail ?? body?.Body,
body?.Type,
body?.GameSessionId);
}
/// <summary>POST <c>api/PlayerReporting/v1/log</c> — bare client
/// telemetry log. Persisted as a low-priority BugReport for admin
/// visibility but never acted on automatically.</summary>
[HttpPost("api/PlayerReporting/v1/log")]
public async Task<IActionResult> ReportLog()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
await SaveHileAsync(
form["Message"].FirstOrDefault() ?? form["Body"].FirstOrDefault(),
null, null);
}
return Ok(new { success = true, error = "" });
}
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new { success = true });
var detail = (rawDetail ?? string.Empty).Trim();
if (type is int hileType)
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
if (detail.Length > 4000) detail = detail[..4000];
db.BugReports.Add(new BugReportEntity
{
ReporterPlayerId = me,
Title = "hile heartbeat",
Body = detail,
GameSessionId = gameSessionId ?? 0,
Category = "hile",
});
await db.SaveChangesAsync();
return Ok(new { success = true });
}
}
@@ -1,227 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Models.Players;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerState;
/// <summary>
/// api.rec.net endpoints that return player-state objects whose Deserialize
/// methods do strict required-key reads via Util.GetKey&lt;T&gt;. The previous
/// catch-all returned {} or {"Success":true} for these, which crashed the
/// client with KeyNotFoundException at:
/// RecNet.Progression.Deserialize → "PlayerId", "Level", "XP"
/// RecNet.Reputation.Deserialize → "AccountId", "Noteriety" (sic), Cheer*, Sub*
/// RecNet.ModerationBlockDetail.Deserialize → "ReportCategory", "Duration", "GameSessionId", "Message"
///
/// Each endpoint here returns the minimum-viable shape (zeros / empty
/// strings) that satisfies every required key. Real values can be plugged
/// in later when persistence is added for these.
///
/// Specific routes here win over the api/PlayerReporting/{*path} and
/// api.rec.net catch-alls.
/// </summary>
[ApiController]
public class PlayerStateController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level) : ControllerBase
{
// RecNet.Progressions.GetProgressionById / GetMyProgression — real
// values from PlayerEntity.Level / .XP. Default 1/0 when the
// account hasn't been seen yet (synthetic ids passed in queries).
[HttpGet("api/players/v1/progression/{accountId:long}")]
public async Task<ActionResult<Progression>> GetProgression(long accountId)
{
var p = await db.Players
.Where(p => p.Id == accountId)
.Select(p => new { p.Id, p.Level, p.XP })
.FirstOrDefaultAsync();
return Ok(new Progression
{
PlayerId = (int)(p?.Id ?? accountId),
Level = p?.Level ?? 1,
XP = p?.XP ?? 0,
});
}
[HttpPost("api/players/v1/progression/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Progression>>> GetProgressionBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
if (idList.Count == 0) return Ok(new List<Progression>());
var rows = await db.Players
.Where(p => idList.Contains(p.Id))
.Select(p => new Progression
{
PlayerId = (int)p.Id,
Level = p.Level,
XP = p.XP,
})
.ToListAsync();
// Synthesise zeros for any ids the watch asked about that
// don't exist in our DB — keeps array length stable.
var found = rows.Select(r => r.PlayerId).ToHashSet();
rows.AddRange(idList
.Where(id => !found.Contains((int)id))
.Select(id => new Progression { PlayerId = (int)id }));
return Ok(rows);
}
// RecNet.Reputations.GetReputationById — aggregates CheerEntity
// by category. Each Cheer* count is the sum of distinct cheers
// received from other players in that category.
[HttpGet("api/playerReputation/v1/{accountId:long}")]
public async Task<ActionResult<Reputation>> GetReputation(long accountId) =>
Ok(await BuildReputationAsync(accountId));
[HttpPost("api/playerReputation/v1/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Reputation>>> GetReputationBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
var result = new List<Reputation>(idList.Count);
foreach (var id in idList)
result.Add(await BuildReputationAsync(id));
return Ok(result);
}
/// <summary>POST api/playerReputation/v1/cheer/{playerId} — cheer
/// another player. Idempotent on (caller, target, type) so
/// repeat-clicking the same category doesn't inflate the count.
/// Pushes a presence-update notification so the target's watch
/// refreshes their cheer total.</summary>
[HttpPost("api/playerReputation/v1/cheer/{playerId:long}")]
[Authorize]
public async Task<ActionResult> CheerPlayer(long playerId, [FromQuery] int type = 0)
{
var me = this.RequireCurrentPlayerId();
if (me == playerId) return BadRequest("cannot_cheer_self");
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == me && c.TargetPlayerId == playerId &&
c.TargetRoomId == 0 && c.Type == type);
if (existing is not null) return Ok(new { already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = me,
TargetPlayerId = playerId,
Type = type,
});
await db.SaveChangesAsync();
// Reward the cheered player with a small XP bump.
await level.AwardXpAsync(playerId, LevelService.CheerReceivedXp, $"cheer_from:{me}");
await notifications.NotifyAsync(playerId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "CheerReceived", From = me, Type = type });
return Ok();
}
/// <summary>POST api/rooms/v1/cheer/{roomId} — cheer a room.
/// Same idempotency rules as player cheers; bumps
/// <see cref="RoomEntity.CheerCount"/> as a denormalised counter
/// so the watch's "Hot" rooms feed doesn't have to JOIN-aggregate
/// on every request.</summary>
[HttpPost("api/rooms/v1/cheer/{roomId:long}")]
[Authorize]
public async Task<ActionResult> CheerRoom(long roomId, [FromQuery] int type = 0)
{
var me = this.RequireCurrentPlayerId();
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == me && c.TargetRoomId == roomId &&
c.TargetPlayerId == 0 && c.Type == type);
if (existing is not null) return Ok(new { already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = me,
TargetRoomId = roomId,
Type = type,
});
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is not null)
{
room.CheerCount += 1;
// Cheers feed the Hot-tab ranking — weight 5x a first
// visit so the "Trending" feed reflects what players
// actually like, not just what they passed through.
room.HotScore += 10.0;
}
await db.SaveChangesAsync();
return Ok();
}
private async Task<Reputation> BuildReputationAsync(long accountId)
{
var rows = await db.Cheers
.Where(c => c.TargetPlayerId == accountId)
.GroupBy(c => c.Type)
.Select(g => new { Type = g.Key, Count = g.Count() })
.ToListAsync();
int Count(int type) => rows.FirstOrDefault(r => r.Type == type)?.Count ?? 0;
var subscriberCount = await db.Subscriptions
.CountAsync(s => s.TargetPlayerId == accountId);
var subscribedCount = await db.Subscriptions
.CountAsync(s => s.SubscriberPlayerId == accountId);
// SelectedCheer is the pinned badge category the player chose
// via PlayerCheerController.SetSelectedCheer. Stored as a
// PlayerSettingEntity with key "SelectedCheer" and Value = the
// category int as string. Null when unset — UI just shows no
// pinned badge.
var selected = await db.PlayerSettings
.Where(s => s.PlayerId == accountId && s.Key == "SelectedCheer")
.Select(s => s.Value)
.FirstOrDefaultAsync();
int? selectedCheer = int.TryParse(selected, out var sc) ? sc : null;
return new Reputation
{
AccountId = (int)accountId,
Notoriety = rows.Sum(r => r.Count),
CheerGeneral = Count(0),
CheerHelpful = Count(1),
CheerGreatHost = Count(2),
CheerSportsman = Count(3),
CheerCreative = Count(4),
CheerCredit = Count(5),
SelectedCheer = selectedCheer,
SubscriberCount = subscriberCount,
SubscribedCount = subscribedCount,
};
}
/// <summary>
/// "1,2,3" → [1, 2, 3]. The client formats Ids as a comma-separated
/// string when calling AddField on the form. Tolerant of empty input
/// and whitespace.
/// </summary>
private static IEnumerable<int> ParseIds(string? ids) =>
string.IsNullOrWhiteSpace(ids)
? Enumerable.Empty<int>()
: ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => int.TryParse(s, out var v) ? v : 0)
.Where(v => v != 0);
// RecNet.PlayerReporting.GetModerationBlockDetails
// IMPORTANT: returns a single object, not an array. Defaults indicate
// "user is not currently blocked / kicked".
// ASP.NET Core route matching is case-insensitive, so a single attribute
// covers both "PlayerReporting" and "playerreporting" callers.
[HttpGet("api/PlayerReporting/v1/moderationBlockDetails")]
public ActionResult<ModerationBlockDetail> GetModerationBlockDetails() =>
Ok(new ModerationBlockDetail());
}
@@ -1,83 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerSubscriptions;
/// <summary>
/// api.rec.net/api/playersubscriptions/v1/* — asymmetric follow
/// graph. The <c>my</c> endpoint returns who the caller follows;
/// subscribe/unsubscribe mutate the row directly. Reputation
/// aggregates pull SubscriberCount / SubscribedCount counts from
/// here.
/// </summary>
[ApiController]
[Route("api/[controller]/v1")]
[Authorize]
public class PlayerSubscriptionsController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("my")]
public async Task<ActionResult> My()
{
var subs = await db.Subscriptions
.Where(s => s.SubscriberPlayerId == Me)
.Select(s => new { s.TargetPlayerId, s.CreatedAt })
.ToListAsync();
return Ok(subs);
}
[HttpGet("{playerId:long}/subscribers")]
public async Task<ActionResult> Subscribers(long playerId)
{
var subs = await db.Subscriptions
.Where(s => s.TargetPlayerId == playerId)
.Select(s => new { s.SubscriberPlayerId, s.CreatedAt })
.ToListAsync();
return Ok(subs);
}
[HttpPost("subscribe/{targetId:long}")]
public async Task<ActionResult> Subscribe(long targetId)
{
if (targetId == Me) return BadRequest("cannot_subscribe_to_self");
var existing = await db.Subscriptions.FirstOrDefaultAsync(s =>
s.SubscriberPlayerId == Me && s.TargetPlayerId == targetId);
if (existing is not null) return Ok(new { already_subscribed = true });
db.Subscriptions.Add(new SubscriptionEntity
{
SubscriberPlayerId = Me,
TargetPlayerId = targetId,
});
await db.SaveChangesAsync();
// Tell the target their subscriber count changed so the watch
// refreshes their profile page if open.
await notifications.NotifyAsync(targetId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "NewSubscriber", From = Me });
return Ok();
}
[HttpDelete("subscribe/{targetId:long}")]
[HttpPost("unsubscribe/{targetId:long}")]
public async Task<ActionResult> Unsubscribe(long targetId)
{
var row = await db.Subscriptions.FirstOrDefaultAsync(s =>
s.SubscriberPlayerId == Me && s.TargetPlayerId == targetId);
if (row is null) return NotFound();
db.Subscriptions.Remove(row);
await db.SaveChangesAsync();
return Ok();
}
}
@@ -1,104 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Compat2018;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Players.V2;
/// <summary>
/// api/players/* for the June-2018 client. This branch is 2018-only, so every
/// endpoint returns the 2018 <see cref="Player2018"/> shape (keys Id/Developer/
/// JuniorProfile/… — NOT the 2020 PlayerProfile's AccountId/IsDeveloper).
///
/// The 2018 client mixes path versions: profile fetch + batch are v1, while
/// search + displayname are v2. We map both. Param names follow the 2018 client
/// (search ?name=, displayname form Name).
/// </summary>
[ApiController]
[Authorize]
public class PlayersController(PlayerService playerService, NotificationService notificationService) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
// GET api/players/v1|v2/me
[HttpGet("/api/players/v1/me")]
[HttpGet("/api/players/v2/me")]
public async Task<ActionResult<Player2018>> GetSelf()
{
var player = await playerService.GetByIdAsync(CurrentPlayerId);
if (player is null) return NotFound();
return Ok(Player2018Mapper.From(player));
}
// GET api/players/v1|v2/{id} — primary profile fetch (2018 uses v1).
[HttpGet("/api/players/v1/{id:long}")]
[HttpGet("/api/players/v2/{id:long}")]
public async Task<ActionResult<Player2018>> GetById(long id)
{
var player = await playerService.GetByIdAsync(id);
if (player is null) return NotFound();
return Ok(Player2018Mapper.From(player));
}
// POST api/players/v1/list — batch profile resolution (JSON array of ids).
[HttpPost("/api/players/v1/list")]
[HttpPost("/api/players/v2/list")]
public async Task<ActionResult<List<Player2018>>> GetMany([FromBody] long[]? ids)
{
if (ids is null || ids.Length == 0) return Ok(new List<Player2018>());
var players = await playerService.GetByIdsAsync(ids);
return Ok(players.Select(Player2018Mapper.From).ToList());
}
// GET api/players/v2/search?name= (2018) — also accept ?query= for safety.
[HttpGet("/api/players/v1/search")]
[HttpGet("/api/players/v2/search")]
public async Task<ActionResult<List<Player2018>>> Search(
[FromQuery] string? name, [FromQuery] string? query)
{
var term = !string.IsNullOrWhiteSpace(name) ? name : query;
if (string.IsNullOrWhiteSpace(term)) return Ok(new List<Player2018>());
var results = await playerService.SearchAsync(term);
return Ok(results.Select(Player2018Mapper.From).ToList());
}
// POST api/players/v2/displayname (2018 form key "Name"). Also accept
// "DisplayName" for safety.
[HttpPost("/api/players/v1/displayname")]
[HttpPost("/api/players/v2/displayname")]
public async Task<ActionResult<Player2018>> UpdateDisplayName(
[FromForm] string? Name, [FromForm] string? DisplayName)
{
var newName = !string.IsNullOrWhiteSpace(Name) ? Name : DisplayName;
if (string.IsNullOrWhiteSpace(newName)) return BadRequest("Name is required");
if (!await playerService.UpdateDisplayNameAsync(CurrentPlayerId, newName)) return NotFound();
var player = await playerService.GetByIdAsync(CurrentPlayerId);
await notificationService.NotifyAsync(CurrentPlayerId,
Models.Notification.PushNotificationId.SubscriptionUpdateProfile, Player2018Mapper.From(player!));
return Ok(Player2018Mapper.From(player!));
}
// POST api/players/v1/bio (2018 form key "Bio").
[HttpPost("/api/players/v1/bio")]
[HttpPost("/api/players/v2/bio")]
public async Task<ActionResult<Player2018>> UpdateBio([FromForm] string? Bio)
{
await playerService.UpdateBioAsync(CurrentPlayerId, Bio ?? string.Empty);
var player = await playerService.GetByIdAsync(CurrentPlayerId);
return Ok(Player2018Mapper.From(player!));
}
// POST api/players/v1/avoidJuniors (form "AvoidJuniors"). Best-effort:
// acknowledged so the toggle in the watch settings doesn't 404. (Server
// doesn't currently filter matchmaking on this flag.)
[HttpPost("/api/players/v1/avoidJuniors")]
[HttpPut("/api/players/v1/avoidJuniors")]
public async Task<ActionResult<Player2018>> AvoidJuniors()
{
var player = await playerService.GetByIdAsync(CurrentPlayerId);
if (player is null) return NotFound();
return Ok(Player2018Mapper.From(player));
}
}
@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.PlayersBanned;
/// <summary>
/// api.{rec.net,localhost}/api/PlayersBanned/v1/all — current global
/// ban list. The watch's mod panel queries this on open.
/// </summary>
[ApiController]
public class PlayersBannedController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/PlayersBanned/v1/all")]
public async Task<IActionResult> All()
{
var now = DateTime.UtcNow;
var rows = await db.Players
.Where(p => p.BannedUntil != null && p.BannedUntil > now)
.Select(p => new
{
PlayerId = (int)p.Id,
p.Username,
BannedUntil = p.BannedUntil,
})
.ToListAsync();
return Ok(rows);
}
}
@@ -1,35 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.Players;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Presence;
/// <summary>
/// api/presence/* — the June-2018 client's friend-presence poll (every ~30s,
/// RecNet.cs:63249). POST api/presence/v2/list takes a JSON array of account
/// ids and returns one <see cref="Presence2018"/> per id. There is no 2020
/// equivalent on this host (the 2020 client used match.rec.net/player), so this
/// controller is 2018-specific. Online state comes from
/// <see cref="OnlinePresenceService"/>; GameSession is left null for now (the
/// client renders "online" without it and fills in room info on join).
/// </summary>
[ApiController]
[Authorize]
public class PresenceController(OnlinePresenceService presence) : ControllerBase
{
[HttpPost("/api/presence/v2/list")]
[HttpPost("/api/presence/v1/list")]
public ActionResult<List<Presence2018>> List([FromBody] long[]? ids)
{
if (ids is null || ids.Length == 0) return Ok(new List<Presence2018>());
var online = presence.OnlinePlayerIds().ToHashSet();
var result = ids.Distinct().Select(id => new Presence2018
{
PlayerId = id,
IsOnline = online.Contains(id),
GameSession = null,
}).ToList();
return Ok(result);
}
}
@@ -1,607 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.ProgressionApi;
/// <summary>
/// api.rec.net/api/{challenge,checklist,objectives}/* — daily
/// challenges, onboarding checklist, and the full objectives surface
/// (myprogress, cleargroup, updateobjective, completegroup). All
/// concepts share the same persistence model:
/// <see cref="ObjectiveProgressEntity"/> rows keyed by a prefix:
/// <c>challenge:{id}</c>, <c>checklist:{id}</c>, <c>group:{id}</c>,
/// <c>obj:{group}:{index}</c>.
/// </summary>
[ApiController]
public class ProgressionController(
DorkNetDbContext db,
LevelService level,
ServerSettingsService serverSettings,
StoreService store) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
private long? MeOrNull => this.CurrentPlayerId();
private const int RecCenterTokens = 2;
// ── Challenges ───────────────────────────────────────────────────────
/// <summary>GET <c>api/challenge/v{1,2}/getCurrent</c> — returns
/// the active daily-challenge map (NOT a flat challenge object).
/// Verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Challenges.txt:105-184</c>
/// (<c>GetCurrentChallenges</c> hits this URL and parses as
/// <c>ChallengeMap</c>). Wire shape per
/// <c>RecNet/ChallengeMap.txt:540-616</c>:
///
/// <c>ChallengeMapId</c> (int, required), <c>CompletedRequired</c>
/// (bool, required), <c>StartAt</c>/<c>EndAt</c>/<c>ServerTime</c>
/// (DateTime, required), <c>Challenges</c> (List&lt;RecNetChallenge&gt;,
/// required), <c>Gift</c> (ChallengeGift, required),
/// <c>ChallengeThemeString</c> (string, optional).
///
/// Each <c>RecNetChallenge</c> requires only <c>ChallengeId</c>;
/// <c>Name</c>/<c>Config</c>/<c>Description</c>/<c>Tooltip</c>/<c>Complete</c>
/// are optional. Each <c>ChallengeGift</c> requires <c>GiftDropId</c>,
/// <c>Xp</c>, <c>Level</c>, <c>GiftContext</c>, <c>GiftRarity</c>.
///
/// Previous flat-challenge response crashed
/// <c>ChallengeMap.Deserialize</c> at <c>Util.GetKey("ChallengeMapId")</c>.</summary>
[HttpGet("api/challenge/v1/getCurrent")]
[HttpGet("api/challenge/v2/getCurrent")]
public async Task<IActionResult> CurrentChallenge()
{
var pid = MeOrNull;
var now = DateTime.UtcNow;
var start = now.Date.AddDays(-(int)now.DayOfWeek);
var mapId = CurrentChallengeMapId();
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var defs = weekly.Challenges;
var keys = defs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray();
var rows = pid is long me && keys.Length > 0
? await db.ObjectiveProgress
.Where(o => o.PlayerId == me && keys.Contains(o.Key))
.ToDictionaryAsync(o => o.Key, o => o.IsCompleted)
: new Dictionary<string, bool>();
bool Done(int idx) => rows.TryGetValue(ChallengeProgressKey(mapId, idx), out var v) && v;
var reward = weekly.Reward;
return Ok(new
{
ChallengeMapId = mapId,
CompletedRequired = weekly.CompletedRequired,
StartAt = start,
EndAt = start.AddDays(7),
ServerTime = now,
ChallengeThemeString = "Weekly",
Challenges = defs.Select(c => new
{
ChallengeId = ChallengeId(mapId, c.Index),
c.Name,
c.Config,
c.Description,
c.Tooltip,
Complete = Done(c.Index),
}),
Gift = BuildGiftDto(mapId, reward),
});
}
/// <summary>Project a <see cref="WeeklyChallengeReward"/> onto the
/// 2020.03 <c>ChallengeGift</c> wire shape (dump.cs ChallengeGift,
/// TypeDefIndex 11455). <c>AvatarItemType</c> is sent as
/// <c>Outfit</c> (0) whenever an <c>AvatarItemDesc</c> is present and
/// <c>null</c> otherwise — the client's gift-card render binds the
/// avatar preview through it, and an <c>AvatarItemDesc</c> with no
/// <c>AvatarItemType</c> leaves that binding half-populated (same
/// failure mode StoreService documents for the shop grid).</summary>
private static object BuildGiftDto(int mapId, WeeklyChallengeReward reward) => new
{
GiftDropId = reward.GiftDropId != 0 ? reward.GiftDropId : mapId,
reward.AvatarItemDesc,
AvatarItemType = string.IsNullOrEmpty(reward.AvatarItemDesc)
? (int?)null
: StoreAvatarItemTypeOutfit,
reward.ConsumableItemDesc,
reward.EquipmentPrefabName,
reward.EquipmentModificationGuid,
Xp = reward.Xp,
reward.Level,
GiftContext = reward.GiftContext,
GiftRarity = reward.GiftRarity,
};
/// <summary>AvatarItemType.Outfit — kept in sync with
/// <c>StoreService</c>'s constant of the same value.</summary>
private const int StoreAvatarItemTypeOutfit = 0;
// ObjectiveProgress row key for a weekly challenge slot.
private static string ChallengeProgressKey(int mapId, int index) => $"challenge:{mapId}:{index}";
// Stable per-map ChallengeId the watch round-trips: mapId*10 + slot
// index. Caps the slate at 10 challenges (NormalizeWeeklyChallenges
// enforces the same Take(10)).
private static int ChallengeId(int mapId, int index) => mapId * 10 + index;
// Recover the slot index from whatever the watch echoes back. Prefer
// the mapId*10 + index encoding; fall back to the low digit for
// clients that send a bare id. Clamp into the configured range.
private static int ChallengeIndex(int mapId, int challengeId, int challengeCount)
{
if (challengeId >= mapId * 10 && challengeId < mapId * 10 + 10)
return Math.Clamp(challengeId - mapId * 10, 0, challengeCount - 1);
return Math.Clamp(Math.Abs(challengeId % 10), 0, challengeCount - 1);
}
public sealed class UpdateChallengeRequest
{
public int Id { get; set; }
public int ChallengeMapId { get; set; }
public int ChallengeId { get; set; }
public int Progress { get; set; }
}
[HttpPost("api/challenge/v2/updateProgress")]
[Authorize]
public async Task<IActionResult> UpdateChallengeProgress([FromForm] UpdateChallengeRequest formReq)
{
var pid = Me;
var req = await ReadChallengeRequestAsync(formReq);
var mapId = req.ChallengeMapId != 0 ? req.ChallengeMapId : CurrentChallengeMapId();
var challengeId = req.ChallengeId != 0 ? req.ChallengeId : req.Id;
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var challengeCount = Math.Max(1, weekly.Challenges.Count);
var index = ChallengeIndex(mapId, challengeId, challengeCount);
var key = ChallengeProgressKey(mapId, index);
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
if (!row.IsCompleted)
{
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
var weeklyRewarded = false;
var allKeys = weekly.Challenges
.Select(c => ChallengeProgressKey(mapId, c.Index))
.ToArray();
var completeCount = await db.ObjectiveProgress
.CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted);
if (completeCount >= allKeys.Length)
{
var reward = weekly.Reward;
weeklyRewarded = await GrantRewardOnceAsync(
pid,
$"weekly-challenge:{mapId}",
xp: reward.Xp,
tokens: reward.Tokens,
reason: $"weekly_challenge:{mapId}");
// Land the actual skin/consumable, not just XP + tokens — the
// December build only granted currency, so configured item
// rewards showed on the watch but never reached the player's
// inventory. Gated behind the same once-only sentinel so a
// re-submit doesn't re-grant: only grant the item on the boot
// transition where currency was also granted.
if (weeklyRewarded && !string.IsNullOrWhiteSpace(reward.Slug))
await store.GrantItemFreeBySlugAsync(pid, reward.Slug);
}
return Ok(new
{
ChallengeMapId = mapId,
ChallengeId = challengeId,
Progress = Math.Max(req.Progress, 10),
IsCompleted = true,
ClearedAt = row.ClearedAt,
Rewarded = weeklyRewarded,
});
}
// ── Checklist ────────────────────────────────────────────────────────
/// <summary>GET <c>api/checklist/v{1,2}/current</c> — returns
/// a flat <c>List&lt;ChecklistObjective&gt;</c>, NOT a wrapper
/// object. Wire shape per
/// <c>Cpp2IL_CS/.../RecNet/Checklist.cs</c>:
/// <c>Order(int), Objective(ObjectiveType int), Count(int),
/// CreditAmount(int)</c>. All four keys are required
/// (<c>Util.GetKey</c>, not <c>OrDefault</c>).
///
/// Completion state is tracked separately by the client via
/// <see cref="ObjectiveProgress"/> rows it cross-references — we
/// don't put a per-item "done" flag on the wire; the client
/// matches by Order. Returning an empty list ([]) is also valid
/// per the deserialiser (List wrapper accepts empty).</summary>
[HttpGet("api/checklist/v1/current")]
[HttpGet("api/checklist/v2/current")]
public IActionResult CurrentChecklist()
{
// Default NUX checklist for a brand-new account. Each row
// maps an in-game step to an ObjectiveType enum value
// (decompiled from ProgressionManager.cs).
return Ok(new[]
{
new { Order = 0, Objective = 38 /* SaveOutfitSlot */, Count = 1, CreditAmount = 25 },
new { Order = 1, Objective = 32 /* VisitACustomRoom */, Count = 1, CreditAmount = 25 },
new { Order = 2, Objective = 2 /* AddAFriend */, Count = 1, CreditAmount = 25 },
new { Order = 3, Objective = 30 /* GoToRecCenter */, Count = 1, CreditAmount = 25 },
new { Order = 4, Objective = 6 /* CheerAPlayer */, Count = 1, CreditAmount = 25 },
});
}
public sealed class CompleteChecklistRequest
{
public int Id { get; set; }
public int ItemIndex { get; set; }
}
[HttpPost("api/checklist/v1/complete")]
[HttpPost("api/checklist/v2/complete")]
[Authorize]
public async Task<IActionResult> CompleteChecklist([FromBody] CompleteChecklistRequest req)
{
var pid = Me;
var id = req.ItemIndex != 0 ? req.ItemIndex : req.Id;
var key = $"checklist:{id}";
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var rewarded = await GrantRewardOnceAsync(
pid,
$"checklist:{id}",
xp: 25,
tokens: 25,
reason: $"checklist:{id}");
var balance = await level.GetBalanceAsync(pid, RecCenterTokens);
return Ok(BalanceUpdateResponse(balance, RecCenterTokens, rewarded ? 25 : 0, 303));
}
// ── Objectives bulk-complete (real persistence) ──────────────────────
/// <summary>POST <c>api/objectives/v1/completegroup</c> — replaces
/// the previous shape-stub in MissingEndpointsController. Now
/// upserts a real <see cref="ObjectiveProgressEntity"/> row keyed
/// by <c>group:{id}</c>.</summary>
public sealed class UpdateObjectiveRequest
{
public int Group { get; set; }
public int Index { get; set; }
public float Progress { get; set; }
public bool IsCompleted { get; set; }
}
/// <summary>POST <c>api/objectives/v1/updateobjective</c> — per-
/// objective progress update. Was previously caught by the
/// namespace wildcard returning <c>[]</c>; now persists to
/// <see cref="ObjectiveProgressEntity"/> keyed
/// <c>obj:{group}:{index}</c> so progress survives sessions.
/// Wire shape from <c>RecNet.Objectives.UpdateObjectiveProgress</c>.</summary>
[HttpPost("api/objectives/v1/updateobjective")]
[Authorize]
public async Task<IActionResult> UpdateObjective([FromBody] UpdateObjectiveRequest req)
{
var pid = Me;
var key = $"obj:{req.Group}:{req.Index}";
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
if (req.IsCompleted || req.Progress >= 1f)
{
row.IsCompleted = true;
row.ClearedAt ??= DateTime.UtcNow;
}
await db.SaveChangesAsync();
var rewardKey = $"obj:{req.Group}:{req.Index}";
var isRewarded = await IsRewardedAsync(pid, rewardKey);
return Ok(new
{
req.Group,
req.Index,
Progress = row.IsCompleted ? 1f : req.Progress,
VisualProgress = row.IsCompleted ? 1f : req.Progress,
IsCompleted = row.IsCompleted,
IsRewarded = isRewarded,
IsDirty = false,
});
}
[HttpPost("api/objectives/v1/completegroup")]
[Authorize]
public async Task<IActionResult> CompleteGroup([FromBody] CompleteGroupBody body)
{
var pid = Me;
var key = $"group:{body.Group}";
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var rewarded = await GrantObjectiveGroupRewardAsync(pid, body.Group);
return Ok(new
{
Group = body.Group,
IsCompleted = true,
ClearedAt = row.ClearedAt!.Value.ToString("o"),
RequiresCompleteOnServer = false,
IsRewarded = true,
Rewarded = rewarded,
});
}
public sealed class CompleteGroupBody { public int Group { get; set; } }
/// <summary>GET <c>api/objectives/v1/myprogress</c> +
/// <c>api/players/v2/objectives</c> — the watch's daily/weekly
/// objectives checklist. Returns
/// <c>{Objectives: [...], ObjectiveGroups: [...]}</c> per
/// <c>Objectives.MyProgress.Deserialize</c> (RVA 0x14510A0).
/// Empty lists are acceptable; both keys must be present.</summary>
[HttpGet("api/objectives/v1/myprogress")]
[HttpGet("api/players/v2/objectives")]
public async Task<IActionResult> MyObjectiveProgress()
{
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Ok(new { Objectives = Array.Empty<object>(), ObjectiveGroups = Array.Empty<object>() });
var rows = await db.ObjectiveProgress
.Where(o => o.PlayerId == me)
.ToListAsync();
var rewardKeys = rows
.Where(o => o.Key.StartsWith("reward:"))
.Select(o => o.Key["reward:".Length..])
.ToHashSet(StringComparer.Ordinal);
var objectives = rows
.Where(o => o.Key.StartsWith("obj:"))
.Select(o =>
{
var (grp, idx) = ParseObjectiveKey(o.Key);
return new
{
Index = idx,
Group = grp,
Progress = o.IsCompleted ? 1f : 0f,
VisualProgress = o.IsCompleted ? 1f : 0f,
IsCompleted = o.IsCompleted,
IsRewarded = rewardKeys.Contains(o.Key),
IsDirty = false,
};
});
var groups = rows
.Where(o => o.Key.StartsWith("group:"))
.Select(o => new
{
Group = int.TryParse(o.Key["group:".Length..], out var g) ? g : 0,
IsCompleted = o.IsCompleted,
ClearedAt = o.ClearedAt ?? new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc),
RequiresCompleteOnServer = false,
IsRewarded = rewardKeys.Contains(o.Key),
});
return Ok(new { Objectives = objectives, ObjectiveGroups = groups });
}
/// <summary>POST <c>api/objectives/v1/cleargroup</c> — flips a
/// group's <c>IsCompleted</c> flag, used by the checklist's
/// "claim group reward" button. Response is a single
/// <c>ObjectiveGroupProgress</c> with Group/IsCompleted/ClearedAt
/// required keys; <c>RequiresCompleteOnServer</c> is optional.</summary>
[HttpPost("api/objectives/v1/cleargroup")]
public async Task<IActionResult> ClearGroup([FromForm(Name = "group")] int? group)
{
var pid = this.CurrentPlayerId();
var key = $"group:{group ?? 0}";
var now = DateTime.UtcNow;
if (pid is long me)
{
var row = await db.ObjectiveProgress
.FirstOrDefaultAsync(o => o.PlayerId == me && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = me, Key = key };
db.ObjectiveProgress.Add(row);
}
row.IsCompleted = true;
row.ClearedAt = now;
await db.SaveChangesAsync();
await GrantObjectiveGroupRewardAsync(me, group ?? 0);
}
return Ok(new
{
Group = group ?? 0,
IsCompleted = true,
ClearedAt = now.ToString("o"),
RequiresCompleteOnServer = false,
IsRewarded = true,
});
}
/// <summary>POST <c>api/players/v2/objectives</c> — batched
/// ProgressionManager completion records. These are separate from
/// ObjectiveProgress rows: they grant XP/currency for daily
/// objective events and must be idempotent per UTC day.</summary>
[HttpPost("api/players/v2/objectives")]
[Authorize]
public async Task<IActionResult> CompletePlayerObjectives([FromBody] List<PlayerObjectiveCompletionRecord>? records)
{
var pid = Me;
var grantedXp = 0;
var grantedTokens = 0;
foreach (var record in records ?? new())
{
var (xp, tokens, reason) = RewardForObjectiveType(record.ObjectiveType, record.AdditionalXp);
if (xp <= 0 && tokens <= 0) continue;
if (await GrantRewardOnceAsync(pid, $"{reason}:{DateTime.UtcNow:yyyyMMdd}", xp, tokens, reason))
{
grantedXp += xp;
grantedTokens += tokens;
}
}
var balance = await level.GetBalanceAsync(pid, RecCenterTokens);
return Ok(new
{
GrantedXp = grantedXp,
GrantedCurrency = grantedTokens,
CurrencyType = RecCenterTokens,
Balance = balance,
BalanceType = 0,
});
}
public sealed class PlayerObjectiveCompletionRecord
{
public int ObjectiveType { get; set; }
public bool InParty { get; set; }
public int AdditionalXp { get; set; }
}
private static (int Group, int Index) ParseObjectiveKey(string key)
{
const string prefix = "obj:";
if (!key.StartsWith(prefix)) return (-1, -1);
var rest = key[prefix.Length..];
var colon = rest.IndexOf(':');
if (colon <= 0) return (-1, -1);
var grp = int.TryParse(rest[..colon], out var g) ? g : -1;
var idx = int.TryParse(rest[(colon + 1)..], out var i) ? i : -1;
return (grp, idx);
}
private static int CurrentChallengeMapId() =>
(int)((DateTime.UtcNow - new DateTime(2020, 1, 1)).Days / 7 % 52 + 1);
private async Task<UpdateChallengeRequest> ReadChallengeRequestAsync(UpdateChallengeRequest formReq)
{
if (Request.HasFormContentType || formReq.Id != 0 || formReq.ChallengeId != 0 || formReq.ChallengeMapId != 0)
return formReq;
try
{
var body = await System.Text.Json.JsonSerializer.DeserializeAsync<UpdateChallengeRequest>(
Request.Body,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return body ?? formReq;
}
catch
{
return formReq;
}
}
private async Task<bool> GrantObjectiveGroupRewardAsync(long playerId, int group)
{
var (xp, tokens, name) = group switch
{
0 => (150, 150, "daily-objectives"),
1 => (100, 100, "new-player-punch-card"),
2 => (75, 75, "orientation-checklist"),
_ => (50, 50, $"objective-group-{group}"),
};
return await GrantRewardOnceAsync(playerId, $"group:{group}", xp, tokens, name);
}
private static (int Xp, int Tokens, string Reason) RewardForObjectiveType(int objectiveType, int additionalXp)
{
var xp = Math.Max(additionalXp, 0);
return objectiveType switch
{
10 or 11 or 12 => (Math.Max(xp, 25), 25, $"daily-objective:{objectiveType}"),
13 => (Math.Max(xp, 150), 150, "all-daily-objectives"),
14 => (Math.Max(xp, 50), 50, "complete-any-daily"),
15 => (Math.Max(xp, 75), 75, "complete-any-weekly"),
25 => (Math.Max(xp, 25), 25, "nux-punchcard-objective"),
26 => (Math.Max(xp, 100), 100, "nux-all-punchcard-objectives"),
_ => (xp, 0, $"objective:{objectiveType}"),
};
}
private async Task<bool> IsRewardedAsync(long playerId, string rewardKey) =>
await db.ObjectiveProgress.AnyAsync(o =>
o.PlayerId == playerId && o.Key == $"reward:{rewardKey}" && o.IsCompleted);
private async Task<bool> GrantRewardOnceAsync(long playerId, string rewardKey, int xp, int tokens, string reason)
{
var sentinelKey = $"reward:{rewardKey}";
var exists = await db.ObjectiveProgress.AnyAsync(o =>
o.PlayerId == playerId && o.Key == sentinelKey && o.IsCompleted);
if (exists) return false;
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = playerId,
Key = sentinelKey,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
if (xp > 0) await level.AwardXpAsync(playerId, xp, reason);
if (tokens > 0) await level.GrantCurrencyAsync(playerId, RecCenterTokens, tokens, reason);
return true;
}
private static object BalanceUpdateResponse(long balance, int currencyType, int delta, int balanceAddType) => new
{
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0,
Data = delta <= 0
? Array.Empty<object>()
: new object[]
{
new
{
BalanceAddType = balanceAddType,
BaseAward = delta,
BonusAward = 0,
RateLimit = 0,
CurrentCount = 1,
Total = delta,
BalanceType = 0,
BalanceInGiftBox = false,
},
},
},
},
};
}
@@ -1,51 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.QuickPlay;
/// <summary>
/// api.rec.net/api/quickPlay/* — the watch's "Quick Play" tile picks
/// a popular open AG room when the player wants to jump in
/// somewhere social without browsing.
///
/// • <c>GET v{1,2}/getandclear</c> — returns the next match the
/// user was queued for, then clears it. We just synthesise a
/// "join hot AG room" target on each call (no real queue).
/// • <c>POST v1/set</c> — stores a desired-match tag. We log + ack;
/// no persistence needed for a single-region private server.
/// </summary>
[ApiController]
public class QuickPlayController(DorkNetDbContext db) : ControllerBase
{
/// <summary>GET <c>v{1,2}/getandclear</c> — the watch polls this
/// on every connect / navigation to check whether the user has a
/// pending quickplay queue. Returning a real {RoomId, RoomName}
/// makes the client treat it as "you queued, go there now" and
/// immediately teleports — that's WHY users were randomly
/// spawning in Paddleball etc.
///
/// Correct behaviour: return empty {} unless we actually have a
/// queued match for the caller. The /set endpoint is what
/// queues; getandclear is the consume.
///
/// We don't persist queue state on a single-region private
/// server (no real matchmaking pool), so always return empty.</summary>
[HttpGet("api/quickPlay/v1/getandclear")]
[HttpGet("api/quickPlay/v2/getandclear")]
public IActionResult GetAndClear() => Ok(new { });
public sealed class SetQuickPlayRequest
{
public string? Tag { get; set; }
}
[HttpPost("api/quickPlay/v1/set")]
[Authorize]
public IActionResult Set([FromBody] SetQuickPlayRequest? req)
{
// Preference is per-session; we don't persist (no benefit in
// a small private deployment). Just ack.
return Ok(new { Tag = req?.Tag ?? string.Empty, Set = true });
}
}
@@ -1,523 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
// Storage-side enum aliased so the wire mapping stays explicit at
// every callsite. Wire enum values (RelationshipType /
// ReciprocalStatus) live in this file as constants — they're tied
// to the disassembly and not used elsewhere.
using EntityStatus = DorkNet.Server.Data.Entities.RelationshipStatus;
namespace DorkNet.Server.Controllers.API.Relationships.V2;
/// <summary>
/// api/relationships/v2 — friendship + reciprocal-state endpoints.
///
/// All routes / wire shapes verified against the patched 2020 client:
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationships.txt
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationship.txt
///
/// Wire response = a Relationship object (or List of them for /get).
/// Relationship.Deserialize strict-reads via Util.GetKey&lt;T&gt;:
/// "PlayerID" (int) — REQUIRED — the OTHER account's id
/// "RelationshipType" (int) — REQUIRED — 0..3
/// "Muted" (int) — REQUIRED — ReciprocalStatus 0..3
/// "Ignored" (int) — REQUIRED — ReciprocalStatus 0..3
/// "Favorited" (int) — optional (Util.GetKeyOrDefault, defaults 0)
///
/// RelationshipType (from caller's perspective):
/// 0 None, 1 FriendRequestSent, 2 FriendRequestReceived, 3 Friend
/// ReciprocalStatus (symmetric flag — who set it):
/// 0 None, 1 Local (just me), 2 Remote (just them), 3 Mutual
///
/// URLs called from the watch (Relationships.txt, all under
/// "api/relationships/"):
/// POST v2/addfriend?id={N} AddFriend
/// POST v2/removefriend?id={N} RemoveFriend / cancel-pending
/// POST v2/sendfriendrequest?id={N} SendFriendRequest
/// POST v2/acceptfriendrequest?id={N} AcceptFriendRequest
/// POST v1/favorite?id={N} FavoritePlayer
/// POST v1/unfavorite?id={N} UnfavoritePlayer
/// POST v1/mute / unmute / ignore / unignore (PostPreferenceChange)
/// GET v2/get GetMyRelationships
/// All POST URLs return one Relationship; GET returns List&lt;Relationship&gt;.
/// </summary>
[ApiController]
[Authorize]
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings) : ControllerBase
{
// RelationshipType — int values match Relationship.RelationshipType
// in the patched dump.cs.
private const int RT_None = 0;
private const int RT_FriendRequestSent = 1;
private const int RT_FriendRequestReceived = 2;
private const int RT_Friend = 3;
// ReciprocalStatus values — also match the dump.cs enum.
private const int RS_None = 0;
private const int RS_Local = 1;
private const int RS_Remote = 2;
private const int RS_Mutual = 3;
private long CurrentPlayerId => this.RequireCurrentPlayerId();
// ── GET /api/relationships/v2/get ────────────────────────────────
// Watch URL: "api/relationships/v2/get". Returns the caller's full
// relationship list as List<Relationship>.
[HttpGet("api/relationships/v2/get")]
public async Task<ActionResult> GetMyRelationships()
{
var me = CurrentPlayerId;
// Global-friends mode (admin toggle): synthesize a Friend row for
// every other account WITHOUT persisting anything. Real rows are
// overlaid so mute/favorite still apply and Blocked stays non-friend.
if (await settings.IsGlobalFriendsEnabledAsync())
return Ok(await BuildGlobalFriendListAsync(me));
var rels = await db.Relationships
.Where(r => r.RequesterId == me || r.TargetId == me)
.ToListAsync();
// Merge the (up to two) rows per pair so a favorite/mute/ignore the
// caller set on a friendship the OTHER player initiated isn't dropped
// by the watch (which keys its list by PlayerID — duplicate ids lose).
var wire = rels
.GroupBy(r => r.RequesterId == me ? r.TargetId : r.RequesterId)
.Select(g => BuildMerged(me, g.Key, g.ToList()))
.ToList();
return Ok(wire);
}
/// <summary>Synthesize the caller's friend list as "everyone" for the
/// global-friends toggle. One Friend entry per other account; real rows
/// are overlaid so a Block stays None and mute/favorite carry through.</summary>
private async Task<List<Dictionary<string, object>>> BuildGlobalFriendListAsync(long me)
{
var realRows = await db.Relationships
.Where(r => r.RequesterId == me || r.TargetId == me)
.ToListAsync();
// Group both directional rows per pair so the caller's favorite/mute/
// ignore (stored on the row where they're the requester) survives even
// when the friendship row itself was created by the other player.
var realByOther = realRows
.GroupBy(r => r.RequesterId == me ? r.TargetId : r.RequesterId)
.ToDictionary(g => g.Key, g => g.ToList());
var others = await db.Players
.Where(p => p.Id != me && p.Id != RelationshipQueries.SystemAccountId)
.Select(p => new { p.Id, p.Username })
.ToListAsync();
var wire = new List<Dictionary<string, object>>(others.Count);
foreach (var other in others)
{
var otherId = other.Id;
if (realByOther.TryGetValue(otherId, out var pair))
{
// A real block wins — that pair is genuinely not friends.
if (pair.Any(r => r.Status == EntityStatus.Blocked)) { wire.Add(EmptyRelationship(otherId)); continue; }
// Carry the caller's own preference bits from their row.
var mineRow = pair.FirstOrDefault(r => r.RequesterId == me);
wire.Add(SynthFriend(otherId, mineRow, me));
}
else
{
// Hide auto-generated Player_NNN placeholders from the
// synthesized list (they never set a real username).
if (RelationshipQueries.IsPlaceholderUsername(other.Username)) continue;
wire.Add(SynthFriend(otherId, null, me));
}
}
return wire;
}
/// <summary>A synthetic Friend relationship for global-friends mode,
/// carrying mute/ignore/favorite from the caller's real row when present.</summary>
private static Dictionary<string, object> SynthFriend(long other, RelationshipEntity? real, long me)
{
var mineDir = real is not null && real.RequesterId == me;
return new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = RT_Friend,
["Muted"] = mineDir && real!.Muted ? 1 : 0,
["Ignored"] = mineDir && real!.Ignored ? 1 : 0,
["Favorited"] = mineDir && real!.Favorited ? 1 : 0,
};
}
/// <summary>GET <c>api/relationships/v2/personaldetails/{playerId}</c>
/// — single relationship row from the caller's perspective. Used
/// by the watch's profile-card view to determine whether to show
/// the "Add Friend" or "Remove Friend" affordance. Returns the
/// same Relationship wire shape as <c>v2/get</c>'s list elements.</summary>
[HttpGet("api/relationships/v2/personaldetails/{playerId:long}")]
public async Task<ActionResult> PersonalDetails(long playerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || playerId == me) return Ok(EmptyRelationship(playerId));
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == playerId) ||
(r.RequesterId == playerId && r.TargetId == me))
.ToListAsync();
var blocked = pair.Any(r => r.Status == EntityStatus.Blocked);
var mineRow = pair.FirstOrDefault(r => r.RequesterId == me);
// Global-friends mode: report Friend for any real account that isn't
// the system account and isn't explicitly blocked. Placeholder
// Player_NNN accounts are hidden unless there's a genuine row.
if (playerId != RelationshipQueries.SystemAccountId
&& !blocked
&& await settings.IsGlobalFriendsEnabledAsync())
{
var isPlaceholder = pair.Count == 0 && RelationshipQueries.IsPlaceholderUsername(
await db.Players.Where(p => p.Id == playerId)
.Select(p => p.Username).FirstOrDefaultAsync());
if (!isPlaceholder) return Ok(SynthFriend(playerId, mineRow, me));
}
return Ok(pair.Count == 0 ? EmptyRelationship(playerId) : BuildMerged(me, playerId, pair));
}
// ── /api/relationships/v2/sendfriendrequest?id={N} ───────────────
// Watch URL from Relationships.txt: "{0}v2/sendfriendrequest?id={1}".
// The ISIL labels the call site Core.Post but the live trace shows
// the watch sending GET — accepting both is harmless and the
// safest cross-build choice.
[HttpGet("api/relationships/v2/sendfriendrequest")]
[HttpPost("api/relationships/v2/sendfriendrequest")]
public async Task<ActionResult> SendFriendRequest([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0 || id == me)
return Ok(EmptyRelationship(id));
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == id) ||
(r.RequesterId == id && r.TargetId == me))
.ToListAsync();
// Anything that already defines a status (friend/pending/block) wins —
// a None row is preference-only and doesn't count as a relationship.
if (pair.Any(r => r.Status != EntityStatus.None))
return Ok(BuildMerged(me, id, pair));
// Promote the caller's own preference-only row if present (keeping its
// favorite/mute/ignore bits) rather than spawning a second row.
var row = pair.FirstOrDefault(r => r.RequesterId == me);
if (row is not null)
{
row.Status = EntityStatus.PendingSent;
row.UpdatedAt = DateTime.UtcNow;
}
else
{
row = new RelationshipEntity
{
RequesterId = me,
TargetId = id,
Status = EntityStatus.PendingSent,
};
db.Relationships.Add(row);
}
await db.SaveChangesAsync();
await notifications.FriendRequestReceived(id, me);
return Ok(BuildRelationship(row, me));
}
// ── /api/relationships/v2/acceptfriendrequest?id={N} ────────────
[HttpGet("api/relationships/v2/acceptfriendrequest")]
[HttpPost("api/relationships/v2/acceptfriendrequest")]
public async Task<ActionResult> AcceptFriendRequest([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0)
return Ok(EmptyRelationship(id));
// Only an incoming pending row can be accepted. The other side
// must have requested us — RequesterId=them, TargetId=me.
var rel = await db.Relationships.FirstOrDefaultAsync(r =>
r.RequesterId == id && r.TargetId == me &&
r.Status == EntityStatus.PendingSent);
if (rel is null)
return Ok(EmptyRelationship(id));
rel.Status = EntityStatus.Friend;
rel.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await notifications.FriendRequestAccepted(id, me);
return Ok(BuildRelationship(rel, me));
}
// ── /api/relationships/v2/removefriend?id={N} ────────────────────
// Symmetrical: undoes a friendship OR cancels a pending request OR
// declines an incoming one. Block rows are left alone — the watch
// has a separate explicit unblock path. Live trace shows the watch
// sending GET (BestHTTP queueing oddity vs the ISIL Core.Post call);
// accept both verbs.
[HttpGet("api/relationships/v2/removefriend")]
[HttpPost("api/relationships/v2/removefriend")]
public async Task<ActionResult> RemoveFriend([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0)
return Ok(EmptyRelationship(id));
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == id) ||
(r.RequesterId == id && r.TargetId == me))
.ToListAsync();
if (pair.Count == 0)
return Ok(EmptyRelationship(id));
// Drop every non-block row for the pair (friendship row + any stray
// preference row), leaving a block intact — the watch unblocks
// through a separate path.
var removable = pair.Where(r => r.Status != EntityStatus.Blocked).ToList();
if (removable.Count == 0)
return Ok(BuildMerged(me, id, pair));
db.Relationships.RemoveRange(removable);
await db.SaveChangesAsync();
await notifications.FriendRemoved(id, me);
var remaining = pair.Except(removable).ToList();
return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining));
}
// ── /api/relationships/v2/addfriend?id={N} ───────────────────────
// Per the disassembly, AddFriend is its own endpoint distinct from
// SendFriendRequest. Live trace shows the watch sending GET; ISIL
// says Core.Post. Accept both verbs.
[HttpGet("api/relationships/v2/addfriend")]
[HttpPost("api/relationships/v2/addfriend")]
public async Task<ActionResult> AddFriend([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0 || id == me)
return Ok(EmptyRelationship(id));
var pair = await db.Relationships
.Where(r => (r.RequesterId == me && r.TargetId == id) ||
(r.RequesterId == id && r.TargetId == me))
.ToListAsync();
// Don't override a block, and don't spawn a duplicate when a
// friendship row already exists (in either direction).
if (pair.Any(r => r.Status == EntityStatus.Blocked) ||
pair.Any(r => r.Status == EntityStatus.Friend))
return Ok(BuildMerged(me, id, pair));
// Promote the caller's own row (preference-only or pending) to Friend,
// or create one if none exists.
var existing = pair.FirstOrDefault(r => r.RequesterId == me);
if (existing is null)
{
existing = new RelationshipEntity
{
RequesterId = me,
TargetId = id,
Status = EntityStatus.Friend,
};
db.Relationships.Add(existing);
}
else
{
existing.Status = EntityStatus.Friend;
existing.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return Ok(BuildRelationship(existing, me));
}
// ── v1 favorite / unfavorite ─────────────────────────────────────
// Watch URLs: "api/relationships/v1/favorite?id={N}" and
// "api/relationships/v1/unfavorite?id={N}". These return a
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
// disassembly (Core.Post … ExpectHttpStatusSuccess), not a
// Relationship. The flag is persisted on the caller's directional row
// and surfaced back through v2/get + personaldetails (see BuildMerged).
[HttpPost("api/relationships/v1/favorite")]
public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true);
[HttpPost("api/relationships/v1/unfavorite")]
public Task<IActionResult> Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false);
[HttpPost("api/relationships/v1/mute")]
public Task<IActionResult> Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: true);
[HttpPost("api/relationships/v1/unmute")]
public Task<IActionResult> Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: false);
[HttpPost("api/relationships/v1/ignore")]
public Task<IActionResult> Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: true);
[HttpPost("api/relationships/v1/unignore")]
public Task<IActionResult> Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: false);
/// <summary>Update favorited/muted/ignored on the caller's row.
/// Creates a placeholder row when none exists (so an "ignore"
/// before any friend request is still recorded).</summary>
private async Task<IActionResult> SetPreference(long otherId,
bool? favorited = null, bool? muted = null, bool? ignored = null)
{
var me = this.RequireCurrentPlayerId();
if (otherId <= 0 || otherId == me)
return Ok(new Models.Auth.RecNetResult { Success = false, Error = "invalid_id" });
var row = await db.Relationships.FirstOrDefaultAsync(r =>
r.RequesterId == me && r.TargetId == otherId);
if (row is null)
{
// A preference (favorite/mute/ignore) is the caller's own bit and
// lives on their own directional row. Status None — favorite/mute/
// ignore must NOT fabricate a friendship; the real friendship row
// (this direction or the other) carries the actual status and the
// read path merges the two.
row = new RelationshipEntity
{
RequesterId = me,
TargetId = otherId,
Status = EntityStatus.None,
};
db.Relationships.Add(row);
}
if (favorited is bool f) row.Favorited = f;
if (muted is bool m) row.Muted = m;
if (ignored is bool i) row.Ignored = i;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
}
public sealed class BulkIgnoreRequest
{
public int Platform { get; set; }
public List<string>? PlatformUserIds { get; set; }
}
/// <summary>POST <c>api/relationships/v1/bulkignoreplatformusers</c>
/// — bulk-import Steam/Oculus blocks. Persists to
/// <see cref="PlatformIgnoreEntity"/> so when those users sign
/// up later we can auto-set their relationship to Ignored.</summary>
[HttpPost("api/relationships/v1/bulkignoreplatformusers")]
[Authorize]
public async Task<IActionResult> BulkIgnorePlatformUsers([FromBody] BulkIgnoreRequest req)
{
var me = this.RequireCurrentPlayerId();
if (req?.PlatformUserIds is null || req.PlatformUserIds.Count == 0)
return Ok(new { Imported = 0 });
var added = 0;
foreach (var puid in req.PlatformUserIds.Distinct())
{
if (string.IsNullOrWhiteSpace(puid)) continue;
var exists = await db.PlatformIgnores.AnyAsync(i =>
i.PlayerId == me && i.Platform == req.Platform && i.PlatformUserId == puid);
if (exists) continue;
db.PlatformIgnores.Add(new PlatformIgnoreEntity
{
PlayerId = me,
Platform = req.Platform,
PlatformUserId = puid,
});
added++;
}
await db.SaveChangesAsync();
return Ok(new { Imported = added });
}
// ── helpers ──────────────────────────────────────────────────────
/// <summary>Map a storage row to the watch's wire RelationshipType
/// from <paramref name="me"/>'s perspective. PendingSent flips
/// direction depending on which side originated.</summary>
private static int MapType(RelationshipEntity r, long me) => r.Status switch
{
EntityStatus.Friend => RT_Friend,
EntityStatus.Blocked => RT_None, // Blocked is not a friendship; the watch reads block state via a separate flag we don't track yet.
EntityStatus.PendingSent => r.RequesterId == me ? RT_FriendRequestSent : RT_FriendRequestReceived,
EntityStatus.PendingReceived => r.RequesterId == me ? RT_FriendRequestReceived : RT_FriendRequestSent,
_ => RT_None,
};
/// <summary>
/// Build the wire object the patched <c>Relationship.Deserialize</c>
/// reads. Keys are case-sensitive (Util.GetKey is a Dictionary
/// TryGetValue) — we ship exactly what's in the disassembly.
/// Implemented as a Dictionary because System.Text.Json's
/// case-insensitive collision check rejects anonymous types with
/// PascalCase property names like "PlayerID" + "Muted" if any
/// other property could collide on lowercase — using a Dictionary
/// avoids that whole class of trouble.
/// </summary>
private static Dictionary<string, object> BuildRelationship(RelationshipEntity r, long me)
{
var other = r.RequesterId == me ? r.TargetId : r.RequesterId;
// ReciprocalStatus enum: 0=None, 1=ByMe, 2=ByThem, 3=Both.
// Our schema only tracks "by me" (the requester's perspective)
// so we can answer ByMe / None; ByThem / Both would require
// looking up the OTHER direction's row too. Cheap extra query
// omitted — the watch's UI only consumes the ByMe bit.
var mineDir = r.RequesterId == me;
var muted = mineDir && r.Muted ? 1 : 0;
var ignored = mineDir && r.Ignored ? 1 : 0;
var favored = mineDir && r.Favorited ? 1 : 0;
return new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = MapType(r, me),
["Muted"] = muted,
["Ignored"] = ignored,
["Favorited"] = favored,
};
}
/// <summary>Merge all rows for one pair (up to two — one per direction)
/// into a single wire object from <paramref name="me"/>'s view. The
/// relationship type comes from whichever row defines it (Blocked &gt;
/// Friend &gt; Pending); the favorite/mute/ignore bits are the caller's
/// own, read from the row where the caller is the requester. Without this
/// a favorite the caller set on a friendship the OTHER player initiated
/// lands on a separate row and is dropped by the watch (its list is keyed
/// by PlayerID, so a duplicate id loses).</summary>
private static Dictionary<string, object> BuildMerged(long me, long other, List<RelationshipEntity> pair)
{
var statusRow =
pair.FirstOrDefault(r => r.Status == EntityStatus.Blocked)
?? pair.FirstOrDefault(r => r.Status == EntityStatus.Friend)
?? pair.FirstOrDefault(r => r.Status is EntityStatus.PendingSent or EntityStatus.PendingReceived)
?? pair[0];
var mineRow = pair.FirstOrDefault(r => r.RequesterId == me);
return new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = MapType(statusRow, me),
["Muted"] = mineRow is { Muted: true } ? 1 : 0,
["Ignored"] = mineRow is { Ignored: true } ? 1 : 0,
["Favorited"] = mineRow is { Favorited: true } ? 1 : 0,
};
}
/// <summary>"None" relationship for a target the caller has nothing
/// on file with — used as a no-op success response so the watch's
/// Relationship.Deserialize stays satisfied.</summary>
private static Dictionary<string, object> EmptyRelationship(long other) => new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = RT_None,
["Muted"] = RS_None,
["Ignored"] = RS_None,
["Favorited"] = RS_None,
};
}
@@ -1,90 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.Role;
/// <summary>
/// api.rec.net/api/role/* — role / privilege probes the watch consults
/// when deciding whether to surface dev-only UI (the in-watch debug
/// console button, hidden admin-tool tab, etc).
///
/// URL verified against
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Accounts.txt:5168
/// (<c>"role/developer/{0}"</c>) called from
/// <c>RecNet.Accounts.GetAccountIsDeveloper</c>. Response shape is a
/// bare JSON boolean — the watch wraps the call with
/// <c>PromiseExtensions.ExpectPrimitiveResponse&lt;bool&gt;</c>, so an
/// array or object response throws InvalidCastException and silently
/// disables the dev UI.
///
/// To make a player a developer (and unlock the watch's
/// debug-console button), set <c>Players.IsDeveloper = 1</c> in the
/// SQLite DB or via the admin UI's player editor.
/// </summary>
[ApiController]
public class RoleController(DorkNetDbContext db) : ControllerBase
{
// Service-routed call from Accounts.GetAccountIsDeveloper. The
// 2020 dump's SendRequest carries `Service = 0` (Auth) — verified
// at Cpp2IL_ISIL/.../RecNet/Accounts.txt:5183-5186 against the
// RecNet.Service enum in dump.cs:586523-586538 (Auth=0). Service.Auth
// maps to <c>auth.{apex}</c> in ConfigService's ServiceUrls, and
// the path the watch appends has NO `/api/` prefix — it's just
// <c>role/developer/{id}</c>. The previous <c>/api/role/...</c>-only
// route never matched, so every nameplate probe came back 404 and
// the watch logged "Failed to GetIsDeveloper for accountId X: HTTP
// Error 404" once per remote player per scene load.
[HttpGet("/role/developer/{accountId:long}")]
[HttpGet("/api/role/developer/{accountId:long}")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsDeveloper(long accountId)
{
// OR IsCommunityTeam into the answer so the watch unlocks the
// in-settings "Developer Display Mode" slider for community-team
// members too — the slider's positions render the overhead
// badge as "Community Team" / "Developer" (see
// Cpp2IL_ISIL/.../PlayerUI.txt:9085-9099). The 2020 watch never
// probes a separate community-team role, so collapsing both
// flags into this one endpoint is the only way to reach the
// badge UI without patching the binary.
var isDev = await db.Players
.Where(p => p.Id == accountId)
.Select(p => (bool?)(p.IsDeveloper || p.IsCommunityTeam || p.IsAdmin))
.FirstOrDefaultAsync();
return Ok(isDev ?? false);
}
/// <summary>Mirror endpoint for the admin role — same shape, in case
/// later builds added an <c>/api/role/admin/{id}</c> probe. The 2020
/// dump only references the developer one but admins also get all
/// dev privileges, so the result is unioned.</summary>
[HttpGet("/role/admin/{accountId:long}")]
[HttpGet("/api/role/admin/{accountId:long}")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsAdmin(long accountId)
{
var isAdmin = await db.Players
.Where(p => p.Id == accountId)
.Select(p => (bool?)p.IsAdmin)
.FirstOrDefaultAsync();
return Ok(isAdmin ?? false);
}
/// <summary>Future-proofing for a hypothetical
/// <c>role/communityteam/{id}</c> probe — not referenced by the 2020
/// build, but mirrors the developer shape so a patched watch can
/// distinguish the two roles without a server change.</summary>
[HttpGet("/role/communityteam/{accountId:long}")]
[HttpGet("/api/role/communityteam/{accountId:long}")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsCommunityTeam(long accountId)
{
var isCt = await db.Players
.Where(p => p.Id == accountId)
.Select(p => (bool?)p.IsCommunityTeam)
.FirstOrDefaultAsync();
return Ok(isCt ?? false);
}
}
@@ -1,1100 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.Match;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Rooms.V2;
/// <summary>
/// api.rec.net/api/rooms/* — DB-backed implementation of the watch's
/// Rooms tab.
///
/// Replaces the previous stubs in MiscStubsController for:
/// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms,
/// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent,
/// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id},
/// v1/personaldetails/{id}, v2/personaldetails/{id}.
///
/// All endpoints serialize Rooms via RoomService.ToWireRoom which matches
/// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required
/// fields plus optional VR-low / mobile / mic-mute flags).
/// </summary>
[ApiController]
public class RoomsController(
RoomService rooms,
DorkNetDbContext db,
PlayerPresenceService presence,
OnlinePresenceService onlinePresence,
DomainConfig domain,
NotificationService notifications,
ILogger<RoomsController> logger) : ControllerBase
{
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
// ── Browse / Hot / Search ────────────────────────────────────────────
/// <summary>
/// `Rooms.GetHotRooms(roomScoreType, tags)` — the watch's "Trending" tab.
/// `roomScoreType` is ignored (we always sort by HotScore). `tags` is
/// passed through as a substring filter against the comma-separated
/// TagsCsv column (e.g. `#community`, `#recroomoriginal`).
/// </summary>
[HttpGet("api/rooms/v1/hot")]
public async Task<IActionResult> Hot(
[FromQuery] string? roomScoreType,
[FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// `Rooms.SearchForRooms(query)` — free-text room search.
/// </summary>
[HttpGet("api/rooms/v1/search")]
[HttpGet("api/rooms/v2/search")]
public async Task<IActionResult> Search(
[FromQuery(Name = "query")] string? query,
[FromQuery(Name = "value")] string? value)
=> Ok((await rooms.SearchAsync(query ?? value ?? string.Empty))
.Select(RoomService.ToWireRoom).ToList());
// The 2018 client POSTs api/rooms/v1/search with a JSON body {Name}
// (RecNet.cs:85205) instead of a query param. Same projection.
[HttpPost("api/rooms/v1/search")]
[HttpPost("api/rooms/v2/search")]
public async Task<IActionResult> SearchPost([FromBody] RoomSearch2018? body)
=> Ok((await rooms.SearchAsync(body?.Name ?? string.Empty))
.Select(RoomService.ToWireRoom).ToList());
/// <summary>GET <c>api/rooms/v2/live</c> — currently-active rooms
/// (rooms with at least one player presence). Same shape as Hot
/// — order by HotScore as a proxy for "live activity" since we
/// don't track instance population separately yet.</summary>
[HttpGet("api/rooms/v2/live")]
public async Task<IActionResult> Live([FromQuery] string? roomScoreType)
=> Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// `Rooms.GetBaseRooms` — list of "base" rooms used by Rec Room Originals
/// to seed the room creation UI. Returns the same set as #recroomoriginal.
/// </summary>
[HttpGet("api/rooms/v2/baserooms")]
[HttpGet("api/rooms/v3/baserooms")]
public async Task<IActionResult> BaseRooms()
=> Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// `Rooms.GetFilters` — pinned/popular tag chips shown above the room
/// list in the watch.
/// </summary>
// Tag wire format: NO leading '#'. The watch's RoomFilterChip prepends
// '#' at render time, so sending "#community" produces "##community" in
// the UI. Names stored in the DB also drop the prefix.
[HttpGet("api/rooms/v1/filters")]
public IActionResult Filters() => Ok(new
{
PinnedFilters = new[] { "community", "recroomoriginal", "featured", "quest" },
PopularFilters = new[] { "paintball", "dodgeball", "soccer", "lasertag", "discgolf" },
});
[HttpGet("api/rooms/v1/tags")]
public IActionResult Tags() => Ok(new[] {
"community", "recroomoriginal", "featured", "quest",
"paintball", "dodgeball", "soccer", "lasertag", "discgolf",
});
[HttpGet("api/rooms/v1/pinnedtags")]
public IActionResult PinnedTags() => Ok(new[] { "community", "recroomoriginal", "featured" });
[HttpGet("api/rooms/v1/populartags")]
public IActionResult PopularTags() => Ok(new[] { "paintball", "dodgeball", "soccer" });
// ── Single room lookups ──────────────────────────────────────────────
/// <summary>
/// `Rooms.GetByName(roomName)` — used when the client constructs the
/// share-link target or when /goto/room/{name} runs. Falls back to a
/// synthesised "Room_<name>" for unknown names so the deserializer
/// doesn't crash on 404.
/// </summary>
[HttpGet("api/rooms/v1/name/{roomName}")]
[HttpGet("api/rooms/v2/name/{roomName}")]
[HttpGet("api/rooms/v3/name/{roomName}")]
public async Task<IActionResult> ByName(string roomName)
{
var r = await rooms.GetByNameAsync(roomName);
return Ok(RoomService.ToWireRoom(r ?? Synthetic(roomName)));
}
/// <summary>
/// `Rooms.GetById(roomId)` — same shape as ByName.
/// </summary>
[HttpGet("api/rooms/v1/{roomId:long}")]
[HttpGet("api/rooms/v2/{roomId:long}")]
[HttpGet("api/rooms/v3/{roomId:long}")]
public async Task<IActionResult> ById(long roomId)
{
var r = await rooms.GetByIdAsync(roomId);
return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId)));
}
// ── RoomDetails (the boot-critical one) ─────────────────────────────
//
// /v4/details/{roomId} is hit *during* the matchmaking goto flow.
// RoomDetails.Deserialize at RVA 0x114C580 expects PascalCase keys:
// Room (object), Scenes (list), CoOwners/InvitedCoOwners,
// Moderators/InvitedModerators, Hosts/InvitedHosts (all int lists),
// CheerCount, FavoriteCount, VisitCount, Tags.
//
// Scenes is populated for every room. The persistence coroutine
// (`RoomPersistenceManager+<DownloadRoomDataBlobCoroutine>`) runs
// unconditionally on scene load and dereferences
// `Rooms.LocalRoomScene.DataBlobName` on its first MoveNext — so
// returning Scenes=[] makes LocalRoomScene null and NPEs the boot.
//
// The historical "Scenes=[] short-circuits the persistence manager"
// comment that used to live here was the reverse of what actually
// happens in this build. Confirmed empirically with the [req] log:
// /v4/details/1 fires before scene load for the dorm too, so
// Rooms.LocalRoom is populated and the coroutine just needs a
// populated Scenes[0] to get a non-null LocalRoomScene.
/// <summary>GET api/rooms/v1/datahistory/{roomId} — returns the
/// version history for a room as a list of saves. Drives the
/// watch's "Restore to old version" UI in the room-settings tab.
///
/// The watch sometimes calls this with <c>roomId=0</c>, which we
/// interpret as "the caller's currently-presence-tracked room"
/// (typically their dorm). Anything else is a literal room id.
///
/// Wire shape: <c>List&lt;RoomDataHistoryDTO&gt;</c>. Deserializer
/// reads EXACTLY three PascalCase keys (verified at
/// <c>Cpp2IL_ISIL/.../RecNet/Rooms_NestedType_RoomDataHistoryDTO.txt</c>):
/// <c>RoomDataHistoryId</c> (long), <c>DataBlobName</c> (string),
/// <c>CreatedAt</c> (DateTime). Extra/wrong keys aren't ignored —
/// missing required keys throw <c>KeyNotFoundException</c> in
/// LitJson and the watch shows an empty history list. Don't add
/// helper fields here; pipe them through a separate admin endpoint
/// if needed.</summary>
[HttpGet("api/rooms/v1/datahistory/{roomId:long}")]
[HttpGet("api/rooms/v2/datahistory/{roomId:long}")]
public async Task<IActionResult> DataHistory(long roomId)
{
// 0 is a sentinel — fall back to the caller's current room
// from PlayerPresenceService.
if (roomId == 0 && CurrentPlayerId is long pid &&
presence.GetRoom(pid)?.RoomId is long currentId)
{
roomId = currentId;
}
if (roomId == 0) return Ok(Array.Empty<object>());
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId)
.OrderByDescending(b => b.UploadedAt)
.Take(50)
.Select(b => new
{
RoomDataHistoryId = b.Id,
DataBlobName = b.BlobName,
CreatedAt = b.UploadedAt,
})
.ToListAsync();
return Ok(blobs);
}
/// <summary>POST api/rooms/v4/saveData — the watch's
/// <c>RecNet.Rooms.UploadLocalRoomSceneData</c> save flow.
/// Body shape mirrors <c>RecNet.Rooms+SaveRoomSceneRequest</c>
/// (Cpp2IL_CS RecNet/Rooms.cs:858): RoomSceneId, RoomDataFilename,
/// InventionUsages[], CreatorActionContext, RequestPlayerId.
///
/// Persists the new <c>CurrentDataBlobName</c> on the room
/// (and, for dorms, the per-player DormStateEntity) so the next
/// /v4/details + cdn fetch loads what just got uploaded. Returns
/// a populated <c>RoomScene</c> JSON — that's what the watch's
/// continuation callback (b__1 in DisplayClass124_0) expects;
/// returning an empty body trips a "HTTP Error 404" path in
/// BestHTTP's Future framework even though the HTTP status was
/// fine.</summary>
[HttpPost("api/rooms/v4/saveData")]
[Authorize]
public async Task<IActionResult> SaveData([FromBody] SaveRoomSceneRequest body)
{
var pid = this.RequireCurrentPlayerId();
// Resolve the room. The watch sends RoomSceneId (the scene's
// OrderIndex per BuildRoomDetails above — 0 for single-scene
// rooms). To find the parent room we cross-reference the
// player's current presence (which RecordResponseAsync stamps
// on every /goto). This is the only signal we have without a
// RoomId on the request body.
var current = presence.GetRoom(pid);
if (current is null)
{
// No active room — without it we can't pick a target row.
// Return a degenerate RoomScene so the deserializer doesn't
// throw, but flag CanMatchmakeInto=false so the watch
// doesn't think the save succeeded.
return Ok(new
{
RoomSceneId = body.RoomSceneId,
RoomId = 0L,
RoomSceneLocationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
Name = "Home",
IsSandbox = false,
DataBlobName = body.RoomDataFilename ?? string.Empty,
MaxPlayers = 8,
CanMatchmakeInto = false,
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
});
}
var roomId = current.RoomId;
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
var newBlob = body.RoomDataFilename?.Trim() ?? string.Empty;
// Multi-scene rooms have a RoomScenes row per scene. The 2020
// client usually sends the scene's OrderIndex, but some flows
// send a stale/non-index id while the local room is still a
// single-scene dorm/custom room. In that common single-scene
// case, stamp the only row anyway; otherwise the pushed
// RoomDetails can keep Scenes[0].DataBlobName at v(N) while
// Rooms.CurrentDataBlobName is v(N+1), and the post-save reload
// reads stale bytes.
var sceneRowsForRoom = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var sceneRow = sceneRowsForRoom.FirstOrDefault(s => s.OrderIndex == (int)body.RoomSceneId)
?? (sceneRowsForRoom.Count == 1 ? sceneRowsForRoom[0] : null);
// Dorms: per-player state, keyed by the calling player so two
// people in the same dorm room ID save into separate slots.
if (room.IsDormRoom)
{
if (room.CreatorPlayerId != pid) return Forbid();
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
if (dormState is null)
{
dormState = new DormStateEntity { PlayerId = pid };
db.DormStates.Add(dormState);
}
dormState.CurrentDataBlobName = newBlob;
dormState.UpdatedAt = DateTime.UtcNow;
}
else
{
// Owned rooms: only the creator can save. Admin override
// intentionally not included — admins should use /restore
// when they need to roll a room back.
if (room.CreatorPlayerId != pid) return Forbid();
}
if (sceneRow is not null)
{
sceneRow.DataBlobName = newBlob;
sceneRow.DataModifiedAt = DateTime.UtcNow;
}
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, newBlob)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
await db.SaveChangesAsync();
// The watch's MasterUploadRoomDataBlobCoroutine waits for BOTH
// the /upload + /saveData responses AND a SubscriptionUpdateRoom
// SignalR push before flipping the save state machine out of
// SAVING. Without this push the spinner stays "Saving..." forever
// even though both HTTP calls returned 200 — verified via the
// RoomDetailsUpdatedCallback closure in
// RoomPersistenceManager+<>c__DisplayClass174_0.
//
// For dorms we re-shape with the caller as creator + their own
// DormStateEntity blob name so the watch's set_LocalRoomDetails
// matches LocalRoomDetails.RoomId and triggers
// SafeRaiseRoomDetailsUpdated(roomId).
var pushRoom = room;
if (room.IsDormRoom)
pushRoom = CloneWithCreator(room, pid, newBlob);
else
pushRoom = CloneWithCreator(room, room.CreatorPlayerId, newBlob);
foreach (var scene in sceneRowsForRoom)
{
if (sceneRow is not null && scene.Id == sceneRow.Id)
{
scene.DataBlobName = newBlob;
scene.DataModifiedAt = DateTime.UtcNow;
}
}
var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId;
var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRowsForRoom, savedSceneId, newBlob);
logger.LogInformation(
"[rooms-save] saveData player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} savedScene={SavedSceneId} blob={BlobName} sceneRow={SceneRowFound} scenes={SceneCount}",
pid, roomId, body.RoomSceneId, savedSceneId, newBlob, sceneRow is not null, sceneRowsForRoom.Count);
var playersInInstance = onlinePresence.OnlinePlayerIds()
.Where(playerId =>
{
var playerRoom = presence.GetRoom(playerId);
return playerRoom is not null
&& playerRoom.RoomId == roomId
&& playerRoom.RoomInstanceId == current.RoomInstanceId;
})
.Append(pid)
.Distinct()
.ToArray();
logger.LogInformation(
"[rooms-save] fanout room update player={PlayerId} room={RoomId} instance={InstanceId} blob={BlobName} recipients={RecipientCount}",
pid, roomId, current.RoomInstanceId, newBlob, playersInInstance.Length);
var updatedRoomInstances = new Dictionary<long, RoomInstanceDto>();
foreach (var playerId in playersInInstance)
{
var currentPresence = presence.GetRoom(playerId);
if (currentPresence is null && playerId == pid)
{
currentPresence = current;
}
if (currentPresence is null) continue;
currentPresence.DataBlob = newBlob;
presence.SetRoom(playerId, currentPresence);
updatedRoomInstances[playerId] = currentPresence;
}
// SubscriptionUpdateRoom delivers the new RoomDetails (with
// Scenes[0].DataBlobName = newBlob). Rooms.OnSubscriptionUpdateRoom
// → StoreRoomDetailsInCache → set_LocalRoomDetails iterates
// Scenes for one matching RoomSceneId == LocalSubRoomId and writes
// LocalRoomScene to that scene, which is what DownloadRoomData
// BlobCoroutine reads when PostSaveReloading runs.
foreach (var playerId in playersInInstance)
{
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
}
// RoomInstanceUpdate keeps the matchmaking-side dataBlob and any
// friends-list "in room X" indicators in sync. No nudge — live
// doesn't send one, and the fake sub-room id confuses the watch's
// OnLocalGameSessionUpdated scene re-resolve.
foreach (var (playerId, currentPresence) in updatedRoomInstances)
{
await notifications.NotifyAsync(
playerId,
PushNotificationId.SubscriptionUpdateGameSession,
currentPresence);
}
// PresenceUpdate keeps the next heartbeat aligned with the blob
// applied through the room-details push above.
foreach (var (playerId, currentPresence) in updatedRoomInstances)
{
var presencePayload = new
{
playerId = (int)playerId,
statusVisibility = 1,
deviceClass = 0,
vrMovementMode = 0,
roomInstance = new
{
roomInstanceId = currentPresence.RoomInstanceId,
roomId = currentPresence.RoomId,
subRoomId = currentPresence.SubRoomId,
location = currentPresence.Location,
photonRegionId = currentPresence.PhotonRegionId,
photonRoomId = currentPresence.PhotonRoomId,
maxCapacity = currentPresence.MaxCapacity,
isFull = currentPresence.IsFull,
isPrivate = currentPresence.IsPrivate,
isInProgress = currentPresence.IsInProgress,
dataBlob = currentPresence.DataBlob,
eventId = currentPresence.EventId,
name = currentPresence.Name,
},
};
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload);
}
return Ok(new
{
RoomSceneId = savedSceneId,
RoomId = roomId,
RoomSceneLocationId = sceneRow?.RoomSceneLocationId ?? room.LocationReplicationId,
Name = sceneRow?.Name ?? "Home",
IsSandbox = sceneRow?.IsSandbox ?? false,
DataBlobName = newBlob,
MaxPlayers = sceneRow?.MaxPlayers ?? 8,
CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true,
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
});
}
public class SaveRoomSceneRequest
{
public long RoomSceneId { get; set; }
public string? RoomDataFilename { get; set; }
public List<long>? InventionUsages { get; set; }
public CreatorActionContextDto? CreatorActionContext { get; set; }
public long RequestPlayerId { get; set; }
}
public class CreatorActionContextDto
{
public bool IsTeachableMomentRunning { get; set; }
}
/// <summary>POST api/rooms/v1/restore — switch a room back to a
/// previous save. Body: roomId, blobName (or version id). Updates
/// the player's DormStateEntity (for dorms) or RoomEntity
/// CurrentDataBlobName (for owned rooms) so the next visit loads
/// the historical bytes.</summary>
/// <summary>POST <c>api/rooms/v1/datahistory/restore</c> — restore a
/// room to a previous save.
///
/// Request body: <c>RestoreDataHistoryRequest</c> serialized by
/// Unity's <c>JsonUtility.ToJson</c>, which uses C# field names.
/// Verified at <c>Cpp2IL_ISIL/.../Rooms.txt:13513-14</c> — the only
/// field on the request type is the long history id at offset +16,
/// and Unity's serializer names it <c>roomDataHistoryId</c>. We
/// look up <see cref="RoomDataBlobEntity"/> by that id, find its
/// parent room, and rewrite <c>CurrentDataBlobName</c> (and the
/// dorm-state row for personal dorms) so the next room reload
/// downloads the historical bytes.
///
/// Response: <c>CreateModifyRoomSceneResponse</c> — keys are
/// PascalCase per <c>Cpp2IL_ISIL/.../Rooms_NestedType_CreateModifyRoomSceneResponse.txt</c>:
/// <c>Result</c> (int, CreateModifyRoomStatus: 0=Success), and
/// <c>RoomScene</c> (the restored scene). The watch's
/// <c>ProcessCreateModifyRoomSceneResponse</c> chain expects both;
/// returning a bare <c>{restored: "..."}</c> like the old impl makes
/// the watch's deserializer throw and the restore silently fails.
/// The legacy form-urlencoded path stays under
/// <c>/api/rooms/v1/restore</c> for the admin SPA's restore UI.
/// </summary>
public sealed class RestoreDataHistoryRequest
{
// Unity JsonUtility serializes C# private backing fields by
// their declared name. The watch's request type has a single
// field which Unity emits as "roomDataHistoryId" (camelCase) —
// [JsonPropertyName] here matches that wire key explicitly so
// System.Text.Json picks it up regardless of any future global
// naming policy.
[System.Text.Json.Serialization.JsonPropertyName("roomDataHistoryId")]
public long RoomDataHistoryId { get; set; }
}
[HttpPost("api/rooms/v1/datahistory/restore")]
[Authorize]
public async Task<IActionResult> RestoreDataHistory([FromBody] RestoreDataHistoryRequest body)
{
var pid = this.RequireCurrentPlayerId();
var blob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == body.RoomDataHistoryId);
if (blob is null) return NotFound(new { error = "version_not_found" });
var room = await rooms.GetByIdAsync(blob.RoomId);
if (room is null) return NotFound(new { error = "room_not_found" });
// Ownership: only the dorm owner / room creator can restore.
// Admin override is intentionally NOT included here — restore
// is destructive (overrides the player's chosen state) so we
// require the actual creator.
if (room.CreatorPlayerId != pid)
return Forbid();
if (room.IsDormRoom)
{
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
if (dormState is null)
{
dormState = new DormStateEntity { PlayerId = pid };
db.DormStates.Add(dormState);
}
dormState.CurrentDataBlobName = blob.BlobName;
dormState.UpdatedAt = DateTime.UtcNow;
}
await db.Rooms
.Where(r => r.Id == room.Id)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
await db.SaveChangesAsync();
// Push the same SubscriptionUpdateRoom the save flow sends so
// the watch's StoreRoomDetailsInCache → SafeRaiseRoomDetailsUpdated
// chain fires and any open room-detail panels refresh.
var pushRoom = room.IsDormRoom ? CloneWithCreator(room, pid, blob.BlobName) : CloneWithCreator(room, room.CreatorPlayerId, blob.BlobName);
var allScenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roomDetailsPayload = BuildRoomDetails(pushRoom, allScenes, 0L, blob.BlobName);
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
// CreateModifyRoomSceneResponse shape. RoomScene fields match
// RecNet.RoomScene.Deserialize (PascalCase, RoomSceneId,
// RoomId, RoomSceneLocationId, Name, IsSandbox, DataBlobName,
// MaxPlayers, CanMatchmakeInto, DataModifiedAt).
return Ok(new
{
Result = 0, // CreateModifyRoomStatus.Success
RoomScene = new
{
RoomSceneId = 0L,
RoomId = room.Id,
RoomSceneLocationId = room.LocationReplicationId,
Name = room.Name,
IsSandbox = false,
DataBlobName = blob.BlobName,
MaxPlayers = 8,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
},
});
}
/// <summary>Legacy form-urlencoded restore — kept for the admin SPA's
/// "Restore version" picker which posts {roomId, blobName} fields.
/// New callers (the watch) use the JSON variant above.</summary>
[HttpPost("api/rooms/v1/restore")]
[Authorize]
public async Task<IActionResult> RestoreVersionLegacy(
[FromForm] long roomId,
[FromForm] string? blobName,
[FromForm] long version = 0)
{
var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
var blob = !string.IsNullOrEmpty(blobName)
? await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName && b.RoomId == roomId)
: await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == version && b.RoomId == roomId);
if (blob is null) return NotFound(new { error = "version_not_found" });
if (room.CreatorPlayerId != pid)
return Forbid();
if (room.IsDormRoom)
{
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
if (dormState is null)
{
dormState = new DormStateEntity { PlayerId = pid };
db.DormStates.Add(dormState);
}
dormState.CurrentDataBlobName = blob.BlobName;
dormState.UpdatedAt = DateTime.UtcNow;
}
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
await db.SaveChangesAsync();
return Ok(new { restored = blob.BlobName });
}
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
/// per-caller bookmark/cheer flags for one room. Wire shape verified
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
/// (<c>Cpp2IL_ISIL/.../PersonalRoomDetails.txt:85-91</c>):
/// <c>IsCheering</c> - bool, REQUIRED (Util.GetKey, throws if missing)
/// <c>IsBookmarked</c> - bool, REQUIRED
/// Both keys are PascalCase. Returning the full room-details payload
/// here (which is what we used to do) makes the deserialiser throw
/// KeyNotFoundException for IsCheering, and that blocks the entire
/// /goto/* flow because RecNet.Rooms.GetPersonalRoomDetails is
/// awaited before SessionManager.JoinRoom can run.</summary>
[HttpGet("api/rooms/v2/personaldetails/{roomId:long}")]
[HttpGet("api/rooms/v1/personaldetails/{roomId:long}")]
public async Task<IActionResult> PersonalDetails(long roomId)
{
var pid = CurrentPlayerId ?? 0;
bool cheering = false, bookmarked = false;
if (pid > 0)
{
cheering = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid);
bookmarked = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid);
}
return Ok(new { IsCheering = cheering, IsBookmarked = bookmarked });
}
[HttpGet("api/rooms/v4/details/{roomId:long}")]
[HttpGet("api/rooms/v3/details/{roomId:long}")]
public async Task<IActionResult> Details(long roomId)
{
var r = await rooms.GetByIdAsync(roomId);
var room = r ?? Synthetic($"Room_{roomId}", roomId);
// Dorm-specific shaping: each player has their own first-class
// dorm row. Only the dorm's actual creator should see Creator
// role / Maker Pen. Guests loading someone else's dorm must see
// the owner as CreatorPlayerId, while still receiving the
// owner's DormStateEntity blob so they load the same room state.
if (room.IsDormRoom)
{
var dormBlobName = await db.DormStates
.Where(d => d.PlayerId == room.CreatorPlayerId)
.Select(d => d.CurrentDataBlobName)
.FirstOrDefaultAsync()
?? string.Empty;
room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName);
}
var scenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roomRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
return Ok(BuildRoomDetails(room, scenes, roles: roomRoles));
}
/// <summary>
/// Shallow clone of a RoomEntity with a different CreatorPlayerId
/// and (optionally) a different CurrentDataBlobName. Used to give
/// the per-caller dorm the appearance of being authored by the
/// caller and pointing at the caller's own most recent save.
/// Doesn't touch the DB — purely a response-shaping utility.
/// </summary>
public static RoomEntity CloneWithCreator(
RoomEntity src, long creatorId, string? overrideBlobName = null) => new()
{
Id = src.Id,
Name = src.Name,
Description = src.Description,
CreatorPlayerId = creatorId,
ImageName = src.ImageName,
State = src.State,
Accessibility = src.Accessibility,
SupportsLevelVoting = src.SupportsLevelVoting,
IsAGRoom = src.IsAGRoom,
IsDormRoom = src.IsDormRoom,
CloningAllowed = src.CloningAllowed,
SupportsVRLow = src.SupportsVRLow,
SupportsMobile = src.SupportsMobile,
SupportsScreens = src.SupportsScreens,
SupportsWalkVR = src.SupportsWalkVR,
SupportsTeleportVR = src.SupportsTeleportVR,
AllowsJuniors = src.AllowsJuniors,
RoomWarningMask = src.RoomWarningMask,
CustomRoomWarning = src.CustomRoomWarning,
DisableMicAutoMute = src.DisableMicAutoMute,
LocationReplicationId = src.LocationReplicationId,
TagsCsv = src.TagsCsv,
CheerCount = src.CheerCount,
FavoriteCount = src.FavoriteCount,
VisitCount = src.VisitCount,
HotScore = src.HotScore,
CreatedAt = src.CreatedAt,
UpdatedAt = src.UpdatedAt,
CurrentDataBlobName = overrideBlobName ?? src.CurrentDataBlobName,
};
/// <summary>
/// Wire shape for the RoomDetails object — matches RoomDetails.Deserialize
/// at RVA 0x114C580. JSON key names DO NOT necessarily match the C#
/// property/field names from dump.cs — the deserializer is hand-written
/// and uses different (mostly PascalCase) literal strings. Verified
/// empirically: this exact shape gets past Deserialize successfully.
/// </summary>
public static object BuildRoomDetails(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
long? overrideSceneId = null,
string? overrideDataBlobName = null,
IReadOnlyList<RoomRoleEntity>? roles = null)
{
// Tags is a list of OBJECTS, not strings. RoomDetails.Deserialize
// does Util.DeserializeList<RoomTag>(tags) — each entry is parsed as
// a Dictionary with `Type` (int RoomTagType: 0=Auto, 1=PlayerAdded,
// 2=AG) and `Tag` (string). Sending bare strings crashes:
// InvalidCastException: Unable to cast 'String' to 'Dictionary`2'
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.ToArray();
// Scenes population: every room must produce a Scenes[0] entry
// with RoomSceneId=0 (matching the matchmaking SubRoomId=0) so
// that Rooms.set_LocalRoomDetails actually transitions
// Rooms.LocalRoomScene to the new room's scene reference.
//
// Why every room: the setter (verified in Cpp2IL ISIL of
// RecNet.Rooms.set_LocalRoomDetails) iterates Scenes looking
// for a RoomSceneId match; if none matches (Scenes=[] or
// mismatched), it falls through WITHOUT writing LocalRoomScene
// — so the field stays stale at whatever the previous room set.
// After the dorm fix landed, that stale value started leaking
// into the rec center: the dorm sets LocalRoomScene → user
// goes to rec center → if we send Scenes=[], LocalRoomScene
// STAYS pointing at the dorm scene → DownloadRoomDataBlob
// Coroutine reads scene.DataBlobName from the dorm and
// downloads room_1_v1.dat (smoking gun in the server log)
// during the rec center load. The rec center master init
// blocks → FROSTBITE.
//
// The DataBlobName field is what determines whether the
// persistence coroutine actually downloads anything.
// OnRoomDataBlobNameChanged (RoomPersistenceManager.txt ISIL
// {027-040}) short-circuits when DataBlobName is empty: it
// sets GetRoomDataBlobPromise to a CompletedPromise<byte[]>
// with empty bytes and returns immediately — no HTTP fetch,
// no DeserializeRoomDataBlobCoroutine, the master init
// proceeds with default-zero permissions.
//
// • Dorm / customisable rooms: DataBlobName is either the
// latest uploaded blob name (RoomEntity.CurrentDataBlobName,
// written by StorageController on /upload) or, before any
// save, "room_<id>_v1.dat" — which the catch-all serves
// from RoomDataBlobService (the all-perms default blob).
// • AG-Original rooms: DataBlobName="" → completed-promise
// short-circuit → no download → no leaked stale-blob
// behaviour → master flow proceeds → spawn fires. Maker
// Pen stays locked, which matches public-server semantics
// (you can't edit the rec center).
//
// Heuristic: "customisable" iff it's the dorm OR a user-created
// room (CreatorPlayerId != 1, the seeded system account that
// owns AG-Originals like the rec center). User clones inherit
// the all-perms default blob until their first save replaces
// it via StorageController; the rec center / paintball / etc.
// serve "" so the persistence flow short-circuits.
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: customisable ? $"room_{room.Id}_v1.dat" : "";
// Multi-scene rooms (imported via tools/import-room.ps1) carry one
// RoomSceneEntity row per sub-room. Emit the full Scenes[] array
// when present so cross-scene portals (Lobby→Ch1, etc.) can
// resolve. Single-scene rooms (every legacy room, every dorm,
// every AG-Original) fall back to the synthesised Scenes[0] from
// RoomEntity fields.
object[] scenes;
if (sceneRows is { Count: > 0 })
{
scenes = sceneRows
.Select(s => (object)new
{
RoomSceneId = (long)s.OrderIndex,
RoomId = room.Id,
RoomSceneLocationId = s.RoomSceneLocationId,
Name = s.Name,
IsSandbox = s.IsSandbox,
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
? overrideDataBlobName
: s.DataBlobName,
MaxPlayers = s.MaxPlayers,
CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
})
.ToArray();
}
else
{
scenes = new object[]
{
new
{
RoomSceneId = 0L, // MUST match RoomInstance.SubRoomId
RoomId = room.Id,
RoomSceneLocationId = room.LocationReplicationId,
Name = "Home",
IsSandbox = false,
DataBlobName = dataBlobName,
MaxPlayers = 8,
CanMatchmakeInto = true,
// Use the room's actual UpdatedAt so the watch's
// "is my local cache stale?" check sees an accurate
// freshness timestamp. Hardcoding the build date
// (2020-03-06) made the watch flag every dorm visit
// as "room is not up to date" because the cached
// local copy was always newer than the server's
// declared timestamp.
DataModifiedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
},
};
}
// Owners tile (UIRoomDetails bottom-left) reads CoOwners — the
// 2020 RoomDetails.Deserialize reads keys WITHOUT the "Ids"
// suffix (verified at RoomDetails.txt:1096/1141/1146/1191/1196:
// "CoOwners" / "InvitedCoOwners" / "Moderators" /
// "InvitedModerators" / "Hosts" / "InvitedHosts"). The
// GetListKey calls are strict — wrong names throw
// KeyNotFoundException → "Failed to get room details:
// Malformed Response" → dorm load stalls in a redirect loop.
//
// CreatorPlayerId is always implicitly a CoOwner so RR Originals
// surface Coach (id=1) as the owner instead of rendering blank.
// Additional grants come from RoomRoleEntity rows when supplied;
// callers without role data (clone/myrooms/etc.) ship just the
// implicit creator.
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
int[] PlayersIn(int role, bool accepted) => roleList
.Where(r => r.Role == role && r.Accepted == accepted)
.Select(r => (int)r.PlayerId)
.ToArray();
var coOwnerSet = new HashSet<int> { (int)room.CreatorPlayerId };
foreach (var pid in PlayersIn(0, true)) coOwnerSet.Add(pid);
return new
{
Room = RoomService.ToWireRoom(room),
Scenes = scenes,
CoOwners = coOwnerSet.ToArray(),
InvitedCoOwners = PlayersIn(0, false),
Moderators = PlayersIn(1, true),
InvitedModerators = PlayersIn(1, false),
Hosts = PlayersIn(2, true),
InvitedHosts = PlayersIn(2, false),
CheerCount = room.CheerCount,
FavoriteCount = room.FavoriteCount,
VisitCount = room.VisitCount,
// VisitorCount is the official Rec.Net "unique visitors"
// companion field (see Stats payload at apim.rec.net).
// 2020 RoomDetails.Deserialize doesn't read it but leaving
// it on the wire is harmless and lets the admin UI / any
// future client surface the number.
VisitorCount = room.VisitorCount,
Tags = tags,
};
}
// ── Clone / create ───────────────────────────────────────────────────
/// <summary>
/// `Rooms.CloneRoom(roomIdToClone, name)` — POST api/rooms/v1/clone
/// with body `{"Name":"<new>","RoomId":<sourceId>}`.
///
/// Response shape (CreateModifyRoomResponse.Deserialize, RVA 0xAF1950):
/// Result (int CreateModifyRoomStatus: 0=Success, 1=Unknown,
/// 2=PermissionDenied, 3=RoomNotActive,
/// 4=RoomDoesNotExist, 5=RoomHasNoDataBlob, …)
/// RoomDetails (RoomDetails object, only populated on Result=0)
/// </summary>
[HttpPost("api/rooms/v1/clone")]
[Authorize]
public async Task<IActionResult> Clone([FromBody] CloneRoomRequest req)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
// CloneAsync sets the CreateModifyRoomStatus code itself
// (Success/RoomDoesNotExist/DuplicateName/InappropriateName) so the
// watch can show the right toast instead of a generic "Unknown".
var result = await rooms.CloneAsync(req.RoomId, req.Name ?? string.Empty, pid.Value);
return Ok(new
{
Result = result.Status,
RoomDetails = result.Room is null ? null : BuildRoomDetails(result.Room),
});
}
public class CloneRoomRequest
{
public string? Name { get; set; }
public long RoomId { get; set; }
}
// 2018 POST api/rooms/v1/search body {Name} (RecNet.cs:85205).
public class RoomSearch2018
{
public string? Name { get; set; }
}
// ── My-Rooms tabs ────────────────────────────────────────────────────
[HttpGet("api/rooms/v1/myrooms")]
[HttpGet("api/rooms/v2/myrooms")]
[Authorize]
public async Task<IActionResult> MyRooms()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
}
[HttpGet("api/rooms/v1/mybookmarkedrooms")]
[HttpGet("api/rooms/v2/mybookmarks")]
[HttpGet("api/rooms/v2/mybookmarkedrooms")]
[Authorize]
public async Task<IActionResult> MyBookmarks()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
return Ok((await rooms.BookmarkedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
}
[HttpGet("api/rooms/v2/mymoderated")]
[HttpGet("api/rooms/v1/modrooms")]
[HttpGet("api/rooms/v2/mysubscribed")]
[HttpGet("api/rooms/v1/myrecent")]
[HttpGet("api/rooms/v2/myrecent")]
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
// ── Bookmark mutations ───────────────────────────────────────────────
[HttpPost("api/rooms/v1/bookmark/{roomId:long}")]
[HttpPost("api/rooms/v2/bookmark/{roomId:long}")]
[Authorize]
public async Task<IActionResult> Bookmark(long roomId)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
await rooms.BookmarkAsync(pid.Value, roomId);
return Ok(new { success = true, error = "" });
}
[HttpDelete("api/rooms/v1/bookmark/{roomId:long}")]
[HttpDelete("api/rooms/v2/bookmark/{roomId:long}")]
[Authorize]
public async Task<IActionResult> Unbookmark(long roomId)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
await rooms.UnbookmarkAsync(pid.Value, roomId);
return Ok(new { success = true, error = "" });
}
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
/// seeded AG / RR-Original room id. Used by the watch to bias the
/// room browser toward official rooms first.</summary>
[HttpGet("api/rooms/v1/agRoomIds")]
public async Task<IActionResult> AgRoomIds() =>
Ok(await rooms.AgRoomIdsAsync());
/// <summary>GET <c>api/rooms/v1/featuredRoomGroup</c> +
/// <c>v3/featured</c> — the "Featured" carousel on the watch's
/// rooms tab. Returns the top-12 AG rooms by HotScore.</summary>
[HttpGet("api/rooms/v1/featuredRoomGroup")]
[HttpGet("api/rooms/v3/featured")]
public async Task<IActionResult> Featured() => Ok(new
{
Name = "Featured",
FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12),
});
/// <summary>GET <c>api/rooms/v2/featured</c> — the 2018 client expects a
/// SINGLE Room object here (RecNet.cs:85173), not the 2020 FeaturedRoomGroup.
/// Return the top featured AG room.</summary>
[HttpGet("api/rooms/v2/featured")]
public async Task<IActionResult> FeaturedSingle()
{
var ids = await rooms.FeaturedAgRoomIdsAsync(1);
var r = ids.Count > 0 ? await rooms.GetByIdAsync(ids[0]) : null;
return Ok(RoomService.ToWireRoom(r ?? Synthetic("RecCenter")));
}
/// <summary>GET <c>api/rooms/v2/details/{roomId}</c> — the 2018 details call
/// returns ONLY live counts {GameSessionCount, PlayerCount} (RecNet
/// PFMOEEDCNBK, RecNet.cs:79846), distinct from the 2020 v3/v4 RoomDetails.
/// Counts are derived from current presence; 0/0 when the room is empty.</summary>
[HttpGet("api/rooms/v2/details/{roomId:long}")]
public IActionResult DetailsV2(long roomId)
=> Ok(new { GameSessionCount = 0, PlayerCount = 0 });
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
/// flag (default = add).</summary>
[HttpPost("api/rooms/v1/bookmark")]
[Authorize]
public async Task<IActionResult> BookmarkV1(
[FromForm(Name = "RoomId")] long? roomId,
[FromForm(Name = "Bookmark")] bool? bookmark)
{
if (roomId is not long rid) return Ok(new { success = true });
var pid = this.RequireCurrentPlayerId();
if (bookmark == false) await rooms.UnbookmarkAsync(pid, rid);
else await rooms.BookmarkAsync(pid, rid);
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
/// form). Idempotent per (caller, room) — duplicate taps don't
/// inflate CheerCount.</summary>
[HttpPost("api/rooms/v1/cheer")]
[Authorize]
public async Task<IActionResult> CheerRoom(
[FromForm(Name = "RoomId")] long? roomId,
[FromForm(Name = "Type")] int? type)
{
if (roomId is not long rid) return Ok(new { success = true });
var me = this.RequireCurrentPlayerId();
var (newCount, already) = await rooms.CheerRoomAsync(me, rid, type ?? 0);
return Ok(new { RoomId = rid, CheerCount = newCount, AlreadyCheered = already });
}
/// <summary>POST <c>api/rooms/v1/uncheer</c> — remove a previously
/// applied room cheer.</summary>
[HttpPost("api/rooms/v1/uncheer")]
[HttpPost("api/rooms/v1/uncheer/{roomId:long}")]
[Authorize]
public async Task<IActionResult> UncheerRoom(
long? roomId,
[FromForm(Name = "RoomId")] long? roomIdForm,
[FromForm(Name = "Type")] int? type)
{
var rid = roomId ?? roomIdForm ?? 0;
if (rid == 0) return Ok(new { success = true });
var me = this.RequireCurrentPlayerId();
var already = await rooms.UncheerRoomAsync(me, rid, type ?? 0);
return Ok(new { RoomId = rid, AlreadyUncheered = already });
}
/// <summary>GET <c>api/rooms/v1/cheers/{roomId}</c> — did the
/// caller cheer this room, and what's the current count?</summary>
[HttpGet("api/rooms/v1/cheers/{roomId:long}")]
[Authorize]
public async Task<IActionResult> CheckCheered(long roomId)
{
var me = this.CurrentPlayerId();
var (count, iCheered) = await rooms.GetCheerStateAsync(me ?? 0, roomId);
return Ok(new { RoomId = roomId, CheerCount = count, ICheered = iCheered });
}
/// <summary>
/// Synthesise a placeholder Room when the client asks for one we don't
/// have in the DB. Keeps the deserializer happy — every required key
/// is populated. Safe defaults: public, AG, default DormRoom location.
///
/// Special case: roomId=1 is the canonical DormRoom id used by
/// GoToController when the client visits its own dorm. Make sure the
/// /v4/details/1 follow-up shows "DormRoom" rather than "Room_1".
/// </summary>
private static RoomEntity Synthetic(string name, long? id = null)
{
var isDorm = (id == 1) || name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase);
var displayName = isDorm ? "DormRoom" : name;
return new RoomEntity
{
Id = id ?? Math.Abs((long)name.GetHashCode()),
Name = displayName,
Description = isDorm
? "Your private dorm — yours alone, decorated however you like."
: $"{displayName} room",
CreatorPlayerId = 1,
State = 0,
Accessibility = isDorm ? 0 /* Private */ : 1 /* Public */,
IsAGRoom = !isDorm,
IsDormRoom = isDorm,
LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
};
}
}
@@ -1,319 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Rooms.V2;
/// <summary>
/// api.rec.net/api/rooms/v{1,2}/{modify,modifyPermissions,banfromroom,
/// importroombans,roombans,report,modify/scene,modify/sceneParent,
/// modify/tags,modifyscene} — owner-side mutation surface for rooms.
///
/// Split from <see cref="RoomsController"/> to keep that file
/// (browse / details / clone) at a manageable size. All endpoints
/// are owner-gated (the caller must equal
/// <see cref="RoomEntity.CreatorPlayerId"/>) or admin-bypassed via
/// <see cref="AdminOnlyAttribute"/> on the admin-only sub-routes.
/// </summary>
[ApiController]
[Authorize]
public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
private async Task<RoomEntity?> RequireOwnedRoomAsync(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return null;
return room.CreatorPlayerId == Me ? room : null;
}
// ── /modify (general fields) ─────────────────────────────────────────
public sealed class ModifyRoomRequest
{
public long RoomId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public string? Tags { get; set; }
public int? Accessibility { get; set; }
public bool? SupportsScreens { get; set; }
public bool? SupportsMobile { get; set; }
public bool? SupportsVRLow { get; set; }
public bool? SupportsWalkVR { get; set; }
public bool? SupportsTeleportVR { get; set; }
public bool? DisableMicAutoMute { get; set; }
}
[HttpPost("api/rooms/v2/modify")]
public async Task<IActionResult> Modify([FromBody] ModifyRoomRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (!string.IsNullOrWhiteSpace(req.Name)) room.Name = req.Name.Trim();
if (req.Description is not null) room.Description = req.Description;
if (req.ImageName is not null) room.ImageName = req.ImageName;
if (req.Tags is not null) room.TagsCsv = req.Tags;
if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2);
if (req.SupportsScreens.HasValue) room.SupportsScreens = req.SupportsScreens.Value;
if (req.SupportsMobile.HasValue) room.SupportsMobile = req.SupportsMobile.Value;
if (req.SupportsVRLow.HasValue) room.SupportsVRLow = req.SupportsVRLow.Value;
if (req.SupportsWalkVR.HasValue) room.SupportsWalkVR = req.SupportsWalkVR.Value;
if (req.SupportsTeleportVR.HasValue) room.SupportsTeleportVR = req.SupportsTeleportVR.Value;
if (req.DisableMicAutoMute.HasValue) room.DisableMicAutoMute = req.DisableMicAutoMute.Value;
room.UpdatedAt = DateTime.UtcNow;
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
// Likely a unique-name collision.
return Conflict(new { Result = 2, Message = "name taken" });
}
return Ok(new { Result = 0, Room = Services.RoomService.ToWireRoom(room) });
}
public sealed class ModifyPermissionsRequest
{
public long RoomId { get; set; }
public int? Accessibility { get; set; }
public bool? SupportsLevelVoting { get; set; }
public bool? AllowsJuniors { get; set; }
public bool? CloningAllowed { get; set; }
public int? RoomWarningMask { get; set; }
public string? CustomRoomWarning { get; set; }
}
[HttpPost("api/rooms/v2/modifyPermissions")]
public async Task<IActionResult> ModifyPermissions([FromBody] ModifyPermissionsRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2);
if (req.SupportsLevelVoting.HasValue) room.SupportsLevelVoting = req.SupportsLevelVoting.Value;
if (req.AllowsJuniors.HasValue) room.AllowsJuniors = req.AllowsJuniors.Value;
if (req.CloningAllowed.HasValue) room.CloningAllowed = req.CloningAllowed.Value;
if (req.RoomWarningMask.HasValue) room.RoomWarningMask = req.RoomWarningMask.Value;
if (req.CustomRoomWarning is not null) room.CustomRoomWarning = req.CustomRoomWarning;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(room));
}
// ── Scene mutations (LocationReplicationId) ──────────────────────────
public sealed class ModifySceneRequest
{
public long RoomId { get; set; }
public string? LocationReplicationId { get; set; }
}
[HttpPost("api/rooms/v1/modify/scene")]
[HttpPost("api/rooms/v1/modifyscene")]
public async Task<IActionResult> ModifyScene([FromBody] ModifySceneRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (!string.IsNullOrWhiteSpace(req.LocationReplicationId))
room.LocationReplicationId = req.LocationReplicationId;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(room));
}
public sealed class ModifySceneParentRequest
{
public long RoomId { get; set; }
public long? ParentRoomId { get; set; }
}
/// <summary>Sub-room / nested-scene parent override. We don't
/// model sub-rooms as a real graph, so we accept-and-ignore but
/// still owner-gate to prevent grief.</summary>
[HttpPost("api/rooms/v1/modify/sceneParent")]
public async Task<IActionResult> ModifySceneParent([FromBody] ModifySceneParentRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
return Ok(new { Result = 0 });
}
public sealed class ModifyTagsRequest
{
public long RoomId { get; set; }
public string? Tags { get; set; }
}
[HttpPost("api/rooms/v1/modify/tags")]
public async Task<IActionResult> ModifyTags([FromBody] ModifyTagsRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
room.TagsCsv = req.Tags ?? string.Empty;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(room));
}
// ── Bans ─────────────────────────────────────────────────────────────
public sealed class BanFromRoomRequest
{
public long RoomId { get; set; }
public long PlayerId { get; set; }
public int BanType { get; set; } // 0=Soft (kick), 1=Permanent, 2=Temp
public DateTime? Until { get; set; }
public string? Reason { get; set; }
}
[HttpPost("api/rooms/v2/banfromroom")]
public async Task<IActionResult> BanFromRoom([FromBody] BanFromRoomRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
db.RoomBans.Add(new RoomBanEntity
{
RoomId = req.RoomId,
BannedPlayerId = req.PlayerId,
BannedByPlayerId = Me,
BanType = req.BanType,
Until = req.Until,
Reason = req.Reason ?? string.Empty,
});
await db.SaveChangesAsync();
return Ok(new { Result = 0 });
}
public sealed class ImportRoomBansRequest
{
public long RoomId { get; set; }
public List<BanFromRoomRequest>? Bans { get; set; }
}
[HttpPost("api/rooms/v1/importroombans")]
public async Task<IActionResult> ImportRoomBans([FromBody] ImportRoomBansRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (req.Bans is null || req.Bans.Count == 0) return Ok(new { Imported = 0 });
foreach (var b in req.Bans)
{
db.RoomBans.Add(new RoomBanEntity
{
RoomId = req.RoomId,
BannedPlayerId = b.PlayerId,
BannedByPlayerId = Me,
BanType = b.BanType,
Until = b.Until,
Reason = b.Reason ?? string.Empty,
});
}
await db.SaveChangesAsync();
return Ok(new { Imported = req.Bans.Count });
}
[HttpGet("api/rooms/v1/roombans/{roomId:long}")]
public async Task<IActionResult> RoomBansList(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
// Only owner sees the ban list (admins also via existing
// admin endpoints).
if (room.CreatorPlayerId != Me) return Forbid();
var rows = await db.RoomBans
.Where(b => b.RoomId == roomId && (b.Until == null || b.Until > DateTime.UtcNow))
.OrderByDescending(b => b.CreatedAt)
.ToListAsync();
return Ok(rows.Select(b => new
{
b.Id,
b.RoomId,
b.BannedPlayerId,
b.BannedByPlayerId,
b.BanType,
b.Until,
b.Reason,
b.CreatedAt,
}));
}
// ── Report ───────────────────────────────────────────────────────────
public sealed class RoomReportRequest
{
public long RoomId { get; set; }
public int Category { get; set; }
public string? Message { get; set; }
}
[HttpPost("api/rooms/v2/report")]
public async Task<IActionResult> Report([FromBody] RoomReportRequest req)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return NotFound();
var msg = req.Message ?? string.Empty;
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = Me,
TargetPlayerId = room.CreatorPlayerId,
TargetRoomId = req.RoomId,
RoomId = req.RoomId,
Category = req.Category,
Message = msg[..Math.Min(1000, msg.Length)],
});
await db.SaveChangesAsync();
return Ok(new { Reported = true });
}
public sealed class RoomRolePermissionRequest
{
public long RoomId { get; set; }
public long PlayerId { get; set; }
}
/// <summary>POST <c>api/rooms/v1/roomRolePermissions</c> — watch
/// posts its self-computed role + permission grants for
/// server-side validation. Role enum:
/// 0=Visitor, 5=Member, 10=Mod, 20=Host, 30=Owner. Room creator
/// is always Owner; everyone else defaults to Visitor.</summary>
[HttpPost("api/rooms/v1/roomRolePermissions")]
[AllowAnonymous]
public async Task<IActionResult> RoomRolePermissions(
[FromBody] RoomRolePermissionRequest? body,
[FromForm(Name = "RoomId")] long? roomIdForm,
[FromForm(Name = "PlayerId")] long? playerIdForm)
{
var roomId = body?.RoomId ?? roomIdForm ?? 0;
var playerId = body?.PlayerId ?? playerIdForm ?? (this.CurrentPlayerId() ?? 0);
if (roomId <= 0 || playerId <= 0)
return Ok(new { success = true, error = "" });
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
int role = (room?.CreatorPlayerId == playerId) ? 30 : 0;
long permissions = role switch
{
30 => -1L,
10 => 0x0FFFFFFFL,
5 => 0x000000FFL,
_ => 0x0000000FL,
};
return Ok(new
{
success = true,
error = "",
RoomId = roomId,
PlayerId = (int)playerId,
Role = role,
Permissions = permissions,
});
}
}
@@ -1,176 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Royale;
/// <summary>
/// api.rec.net/api/royale/* — Rec Royale battle-royale stats.
///
/// Per <c>RecNet.RecNetRecRoyaleStats</c> (decompiled at
/// <c>Cpp2IL_CS/.../RecNet/RecNetRecRoyaleStats.cs</c>) the client
/// only calls TWO endpoints:
///
/// • <c>GET api/royale/v1/current</c> → <c>RecRoyalePlayerProgress</c>:
/// <c>TotalXP, Level, RankIdx, RankName, CurrentLevelXPThreshold,
/// NextLevelXPThreshold, NextLevelAcornReward</c>.
/// • <c>POST api/royale/v2/matchcomplete</c> with body
/// <c>MatchCompleteStats</c>: <c>Rank, NumEliminations, SecondsAlive,
/// WalkGame, CustomGame, ChestsOpened, ShieldPotionsConsumed,
/// HealthPotionsConsumed, SecondsInAir</c>; returns <c>StatUpdates</c>:
/// <c>XPAwardStrings, TotalXPAwarded, NewProgress</c>.
///
/// We persist match rows to <see cref="RoyaleMatchEntity"/> +
/// <see cref="RoyaleMatchPlayerEntity"/> for server-side audit, and
/// roll the per-player <see cref="RoyalePlayerProgressEntity"/>.
/// </summary>
[ApiController]
public class RoyaleController(DorkNetDbContext db) : ControllerBase
{
// Rank ladder names (from the watch's RankIdx → RankName mapping).
// Approximated; the client's ladder isn't exposed in the dump but
// these are the standard 2020-era Royale tiers.
private static readonly string[] RankNames =
{
"Recruit", "Cadet", "Sergeant", "Lieutenant", "Captain",
"Major", "Colonel", "Commander", "Champion", "Legend",
};
private static long XpForLevel(int level) => 100L * level * level;
private static (int level, int rankIdx, string rankName, long currentThreshold, long nextThreshold, int reward)
Compute(long totalXp)
{
int lvl = 1;
while (XpForLevel(lvl + 1) <= totalXp) lvl++;
var rankIdx = Math.Min(RankNames.Length - 1, lvl / 5);
var current = XpForLevel(lvl);
var next = XpForLevel(lvl + 1);
var reward = 50 + 10 * lvl;
return (lvl, rankIdx, RankNames[rankIdx], current, next, reward);
}
[HttpGet("api/royale/v1/current")]
public async Task<IActionResult> Current()
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(BlankProgress());
var row = await db.RoyalePlayerProgress.FirstOrDefaultAsync(p => p.PlayerId == me);
if (row is null) return Ok(BlankProgress());
return Ok(ToWireProgress(row));
}
public sealed class MatchCompleteStats
{
public int Rank { get; set; }
public int NumEliminations { get; set; }
public int SecondsAlive { get; set; }
public bool WalkGame { get; set; }
public bool CustomGame { get; set; }
public int ChestsOpened { get; set; }
public int ShieldPotionsConsumed { get; set; }
public int HealthPotionsConsumed { get; set; }
public int SecondsInAir { get; set; }
}
[HttpPost("api/royale/v2/matchcomplete")]
[Authorize]
public async Task<IActionResult> MatchComplete([FromBody] MatchCompleteStats body)
{
var pid = this.RequireCurrentPlayerId();
if (body is null) return BadRequest("missing body");
var match = new RoyaleMatchEntity
{
CompletedAt = DateTime.UtcNow,
Rank = body.Rank,
NumEliminations = body.NumEliminations,
SecondsAlive = body.SecondsAlive,
WalkGame = body.WalkGame,
CustomGame = body.CustomGame,
ChestsOpened = body.ChestsOpened,
ShieldPotionsConsumed = body.ShieldPotionsConsumed,
HealthPotionsConsumed = body.HealthPotionsConsumed,
SecondsInAir = body.SecondsInAir,
};
db.RoyaleMatches.Add(match);
await db.SaveChangesAsync();
db.RoyaleMatchPlayers.Add(new RoyaleMatchPlayerEntity
{
MatchId = match.Id,
PlayerId = pid,
Rank = body.Rank,
NumEliminations = body.NumEliminations,
SecondsAlive = body.SecondsAlive,
});
// Compute XP awards. Match the watch's pattern:
// • Survival: 1 XP per 10 seconds alive
// • Eliminations: 25 XP each
// • Placement: bonus inversely proportional to rank
// • Custom games don't award XP
var awards = new List<(string label, int xp)>();
if (!body.CustomGame)
{
var survivalXp = body.SecondsAlive / 10;
var killXp = body.NumEliminations * 25;
var rankXp = Math.Max(0, 200 - body.Rank * 5);
if (survivalXp > 0) awards.Add(($"Time Alive: {body.SecondsAlive}s", survivalXp));
if (killXp > 0) awards.Add(($"Eliminations: {body.NumEliminations}", killXp));
if (rankXp > 0) awards.Add(($"Rank #{body.Rank}", rankXp));
if (body.Rank == 1) awards.Add(("Victory Royale!", 500));
}
var totalAward = awards.Sum(a => a.xp);
// Update progress.
var row = await db.RoyalePlayerProgress.FirstOrDefaultAsync(p => p.PlayerId == pid);
if (row is null)
{
row = new RoyalePlayerProgressEntity { PlayerId = pid };
db.RoyalePlayerProgress.Add(row);
}
row.TotalXP += totalAward;
var (lvl, rankIdx, rankName, current, next, reward) = Compute(row.TotalXP);
row.Level = lvl;
row.RankIdx = rankIdx;
row.RankName = rankName;
row.CurrentLevelXPThreshold = current;
row.NextLevelXPThreshold = next;
row.NextLevelAcornReward = reward;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new
{
XPAwardStrings = awards.Select(a => $"+{a.xp} XP — {a.label}").ToList(),
TotalXPAwarded = (long)totalAward,
NewProgress = new[] { ToWireProgress(row) },
});
}
private static object ToWireProgress(RoyalePlayerProgressEntity p) => new
{
p.TotalXP,
p.Level,
p.RankIdx,
p.RankName,
p.CurrentLevelXPThreshold,
p.NextLevelXPThreshold,
p.NextLevelAcornReward,
};
private static object BlankProgress() => new
{
TotalXP = 0L,
Level = 1,
RankIdx = 0,
RankName = "Recruit",
CurrentLevelXPThreshold = 0L,
NextLevelXPThreshold = 100L,
NextLevelAcornReward = 50,
};
}
@@ -1,118 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Sanitize;
/// <summary>
/// api.{rec.net,localhost}/api/sanitize/* — server-side profanity filter
/// the watch's text-input UI calls before allowing a room name /
/// invention name / chat message to be submitted. Delegates to
/// <see cref="ProfanityFilter"/> for the actual scrubbing. Response
/// shape: <c>{Text (purified), IsClean (bool)}</c>.
/// </summary>
[ApiController]
public class SanitizeController(ILogger<SanitizeController> logger) : ControllerBase
{
[HttpPost("api/sanitize/v1")]
[HttpPost("api/sanitize/v1/text")]
public async Task<IActionResult> Sanitize()
{
// RecRoom.StringSanitization.PurifyString sends JSON body
// {"Value":"<text>","ReplacementChar":42} — verified from a
// live chat-send error trace ("Received malformed RecNet
// response: ...Text:{\"Value\":...}..."). Earlier we used to
// pass the whole body through as "text", which made Text in
// the response contain the literal JSON string, and the watch's
// LitJson then choked on the malformed wrap.
string text = string.Empty;
try
{
if (Request.HasFormContentType)
text = Request.Form["Text"].ToString();
}
catch { }
if (string.IsNullOrEmpty(text))
{
using var reader = new StreamReader(Request.Body);
var raw = await reader.ReadToEndAsync();
if (!string.IsNullOrEmpty(raw))
{
var first = raw.AsSpan().TrimStart();
if (first.Length > 0 && first[0] == '{')
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(raw);
var root = doc.RootElement;
if (root.ValueKind == System.Text.Json.JsonValueKind.Object)
{
if (root.TryGetProperty("Value", out var vEl) && vEl.ValueKind == System.Text.Json.JsonValueKind.String)
text = vEl.GetString() ?? string.Empty;
else if (root.TryGetProperty("Text", out var tEl) && tEl.ValueKind == System.Text.Json.JsonValueKind.String)
text = tEl.GetString() ?? string.Empty;
else if (root.TryGetProperty("Input", out var iEl) && iEl.ValueKind == System.Text.Json.JsonValueKind.String)
text = iEl.GetString() ?? string.Empty;
}
}
catch { text = raw; }
}
else
{
text = raw;
}
}
}
// StringSanitization.PurifyString_d__9 chains Core.Post to
// ApiCallback<String> via ExpectPrimitiveResponse, so the watch's
// LitJson importer wants a BARE JSON string (e.g. "dwadwa"), not
// an object body. Returning {Text, IsClean} as we did before
// makes LitJson hand the Dictionary to Convert.ChangeType(_, typeof(string))
// which throws "Object must implement IConvertible". Strip the
// envelope — but use Content() with explicit application/json so
// ASP.NET Core's StringOutputFormatter doesn't serialize as
// text/plain (raw string sans quotes), which the watch then sees
// as an empty cleanVersion after LitJson decode.
var purified = ProfanityFilter.Purify(text);
var json = System.Text.Json.JsonSerializer.Serialize(purified);
logger.LogInformation("[sanitize] input=\"{Input}\" purified=\"{Purified}\" json={Json}", text, purified, json);
return Content(json, "application/json");
}
public sealed class SanitizeBody
{
public string? Text { get; set; }
public string? Input { get; set; }
public string? Value { get; set; }
}
/// <summary>POST <c>api/sanitize/v1/purifyString</c> — returns
/// <c>{Text}</c> with profanity replaced. Wire shape matches
/// <c>StringSanitization.PurifyString</c>.</summary>
[HttpPost("api/sanitize/v1/purifyString")]
public IActionResult PurifyString(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromForm(Name = "Input")] string? inputForm,
[FromForm(Name = "Value")] string? valueForm,
[FromQuery(Name = "text")] string? textQuery)
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty;
return Ok(new { Text = ProfanityFilter.Purify(input) });
}
/// <summary>POST <c>api/sanitize/v1/requestIsStringPure</c> —
/// returns a bare JSON bool. Watch's <c>ExpectPrimitiveResponse</c>
/// rejects object bodies here.</summary>
[HttpPost("api/sanitize/v1/requestIsStringPure")]
public IActionResult IsStringPure(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromQuery(Name = "text")] string? textQuery)
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? textQuery ?? string.Empty;
return Ok(ProfanityFilter.IsClean(input));
}
}
@@ -1,101 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using System.Text.Json.Serialization;
namespace DorkNet.Server.Controllers.API.Settings.V2;
/// <summary>
/// api.rec.net/api/settings/v2 — player settings download / set / remove.
/// URL patterns (verified by disassembling RecNet.Settings$$DowloadLocalPlayerSettings
/// at RVA 0xAF55B0 and RecNet.Settings$$StoreLocalPlayerSetting at RVA 0xAF5650):
///
/// GET api/settings/v2/ — returns List&lt;{Key, Value}&gt;
/// POST api/settings/v2/set — body { Key, Value } (set or update one)
/// POST api/settings/v2/remove — body { Key } (delete one)
///
/// The CLIENT side parses the GET response with Util.DeserializeList&lt;Setting&gt;
/// and reads each entry's "Key" and "Value" fields (verified by string literal
/// references in the deserializer body — both are PascalCase).
///
/// Returning a Dictionary&lt;string,string&gt; here serializes as "{}"
/// when empty, which the client casts to a List and dies with
/// InvalidCastException: Unable to cast object of type 'Dictionary`2' to
/// type 'List`1'. Always return a real List.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class SettingsController(DorkNetDbContext db) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpGet]
[HttpGet("get")]
public async Task<IActionResult> GetSettings()
{
var settings = await db.PlayerSettings
.Where(s => s.PlayerId == CurrentPlayerId)
.Select(s => new SettingDto { Key = s.Key, Value = s.Value })
.ToListAsync();
return Ok(settings);
}
[HttpPost("set")]
public async Task<ActionResult> SetSetting([FromBody] SetSettingRequest req)
{
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == req.Key);
if (existing is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = CurrentPlayerId,
Key = req.Key,
Value = req.Value,
});
}
else
{
existing.Value = req.Value;
}
await db.SaveChangesAsync();
return Ok(new SettingDto { Key = req.Key, Value = req.Value });
}
[HttpPost("remove")]
public async Task<ActionResult> RemoveSetting([FromBody] SetSettingRequest req)
{
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == req.Key);
if (existing is not null)
{
db.PlayerSettings.Remove(existing);
await db.SaveChangesAsync();
}
return Ok();
}
}
public class SettingDto
{
[JsonPropertyName("Key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
}
public class SetSettingRequest
{
[JsonPropertyName("Key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
}
@@ -1,143 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
/// <summary>
/// api.rec.net/api/purchase/v1/* — real purchase flow that verifies
/// the player has enough currency, deducts it, and grants the item
/// to their inventory. Replaces the old "ack-everything" stubs in
/// AllEndpointsController that let the watch think it bought
/// something but never actually persisted the transaction.
///
/// The 2020 client follows a three-step flow when the player taps
/// "buy" on a Shop item:
/// 1. POST initiatepurchase — server validates the item exists +
/// player has funds, returns a transaction id (we just echo
/// back the item id; no real reservation needed for a private
/// server's threat model).
/// 2. POST processpurchase — server actually deducts currency,
/// grants the item to the avatar's InventoryJson list, returns
/// the new balance.
/// 3. POST completepurchase — final ack; client uses this to
/// flip its local UI state from "purchasing…" to "owned".
/// We keep all three so the watch's flow doesn't deadlock waiting
/// for one or another, but the actual mutation happens in
/// processpurchase. initiate and complete just validate + ack.
/// </summary>
[ApiController]
[Authorize]
public class PurchaseController(StoreService store, ILogger<PurchaseController> logger) : ControllerBase
{
public sealed record PurchaseRequest(long? ItemId, string? Slug, int? Quantity);
/// <summary>POST initiatepurchase — validate the item exists and
/// the caller has enough currency, but don't mutate yet. Returns
/// the resolved item id so the watch can echo it back on
/// processpurchase.</summary>
[HttpPost("api/purchase/v1/initiatepurchase")]
[HttpPost("api/purchase/v2/initiatepurchase")]
[HttpPost("api/purchase/v3/initiatepurchase")]
public async Task<IActionResult> Initiate([FromForm] PurchaseRequest body)
{
var item = await ResolveItemAsync(body);
if (item is null) return Ok(new { success = false, error = "item_not_found" });
return Ok(new
{
success = true,
error = "",
ItemId = item.Id,
Slug = item.Slug,
// Hand back a token the watch may include in
// processpurchase. We accept anything on the way in, so
// this is informational.
TransactionId = $"txn-{item.Id}-{DateTime.UtcNow.Ticks}",
});
}
/// <summary>POST processpurchase — atomic charge + grant. The
/// real work. Returns success+balance on completion or the
/// specific error code on failure (insufficient_funds,
/// item_not_available, etc.) so the watch can show the right UI
/// message.</summary>
[HttpPost("api/purchase/v1/processpurchase")]
[HttpPost("api/purchase/v2/processpurchase")]
[HttpPost("api/purchase/v3/processpurchase")]
public async Task<IActionResult> Process([FromForm] PurchaseRequest body)
{
var pid = this.RequireCurrentPlayerId();
var item = await ResolveItemAsync(body);
if (item is null) return Ok(new { success = false, error = "item_not_found" });
var result = await store.PurchaseAsync(pid, item.Id);
logger.LogInformation(
"[purchase] player={Pid} item={Item} success={Success} error={Error} balance={Balance}",
pid, item.Slug, result.Success, result.Error ?? "", result.Balance ?? 0);
return Ok(new
{
success = result.Success,
error = result.Error ?? "",
Balance = result.Balance ?? 0,
CurrencyType = item.CurrencyType,
ItemId = item.Id,
Slug = result.Slug ?? item.Slug,
});
}
/// <summary>POST completepurchase — final ack. Client flips its
/// UI to "owned" state and refreshes the avatar selector.</summary>
[HttpPost("api/purchase/v1/completepurchase")]
[HttpPost("api/purchase/v2/completepurchase")]
[HttpPost("api/purchase/v3/completepurchase")]
public IActionResult Complete() =>
Ok(new { success = true, error = "" });
/// <summary>POST cancelpurchase — client retreated mid-flow
/// (closed the panel, network blip, etc.). Nothing to roll back
/// since we don't reserve in initiate.</summary>
[HttpPost("api/purchase/v1/cancelpurchase")]
[HttpPost("api/purchase/v2/cancelpurchase")]
[HttpPost("api/purchase/v3/cancelpurchase")]
public IActionResult Cancel() =>
Ok(new { success = true, error = "" });
/// <summary>POST cleanuppending — client startup probe to release
/// any stale "in-flight" purchases from a crashed previous
/// session. We don't track in-flight state, so always-ack is
/// correct.</summary>
[HttpPost("api/purchase/v1/cleanuppending")]
[HttpPost("api/purchase/v2/cleanuppending")]
[HttpPost("api/purchase/v3/cleanuppending")]
public IActionResult CleanupPending() =>
Ok(new { success = true, error = "" });
/// <summary>POST <c>subscription/v1/cancel</c> — Rec Room+
/// subscription cancel button. Verified URL at
/// Commerce.txt:2188. The private server has no real billing
/// integration (no Stripe, no platform IAP receipts) so the
/// subscription is purely cosmetic — we ack the cancel so the
/// watch flips its UI to "canceled" and stops the recurring
/// renewal nag. If a real subscription entity ever lands, this
/// is where to expire its CurrentPeriodEnd.</summary>
[HttpPost("/subscription/v1")]
[HttpPost("/subscription/v1/cancel")]
[HttpPost("/api/subscription/v1")]
[HttpPost("/api/subscription/v1/cancel")]
public IActionResult CancelSubscription()
{
logger.LogInformation("[subscription] cancel requested by player {Pid}",
this.CurrentPlayerId() ?? 0);
return Ok(new { success = true, error = "" });
}
private async Task<Data.Entities.StoreItemEntity?> ResolveItemAsync(PurchaseRequest body)
{
if (body.ItemId is long id && id > 0)
return await store.GetByIdAsync(id);
if (!string.IsNullOrWhiteSpace(body.Slug))
return await store.GetBySlugAsync(body.Slug);
return null;
}
}
@@ -1,361 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
/// <summary>
/// api.rec.net/api/storefronts/v2/{buyItem,buyTier,buyElite} — the
/// secondary purchase URL family used by the watch's gift-drop
/// tiles and season-pass tier buttons. Wire request matches
/// <c>RecNet.RequestPurchaseItemDTO</c>:
/// <c>StorefrontType, PurchasableItemId, CurrencyType,
/// CouponConsumablePlayerMappingId, Gift</c>.
///
/// Response is <c>PurchaseBalanceUpdateResponseDTO</c>:
/// <c>BalanceUpdates: List&lt;BalanceUpdateDTO&gt;</c>. The client
/// applies these balance deltas optimistically + reads the optional
/// gift package from inventory after.
///
/// The primary purchase URL (<c>api/purchase/v1/*</c>) is handled
/// elsewhere by PurchaseController; this just covers the v2 surface.
/// </summary>
[ApiController]
[Authorize]
public class StorefrontsBuyController(
DorkNetDbContext db,
LevelService level,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
public sealed class RequestPurchaseItemDto
{
public int StorefrontType { get; set; }
public int PurchasableItemId { get; set; }
public int CurrencyType { get; set; } = 2;
public long? CouponConsumablePlayerMappingId { get; set; }
public GiftDto? Gift { get; set; }
}
public sealed class GiftDto
{
public long ToPlayerId { get; set; }
public string? Message { get; set; }
public bool Anonymous { get; set; }
public int GiftContext { get; set; }
}
[HttpPost("api/storefronts/v1/buyItem")]
[HttpPost("api/storefronts/v2/buyItem")]
public async Task<IActionResult> BuyItem([FromBody] RequestPurchaseItemDto req)
{
if (req is null) return BadRequest("missing body");
var pid = Me;
var item = await db.StoreItems.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
if (item is null) return NotFound(BalanceUpdateResponse(0, 2 /* CurrencyType */, null, null));
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
if (balance < item.Price)
{
// Insufficient funds — return current balance, no item granted.
return Ok(BalanceUpdateResponse(balance, req.CurrencyType, null, null));
}
// Gift block (when present) means the BUYER spends the tokens but
// the ITEM goes to Gift.ToPlayerId. Self-gifting is disallowed —
// the watch's gift UI prevents it, but guard server-side too.
var giftTarget = req.Gift is { ToPlayerId: > 0 } g && g.ToPlayerId != pid
? g
: null;
var recipient = giftTarget?.ToPlayerId ?? pid;
var isGift = giftTarget is not null;
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -item.Price,
isGift ? $"giftItem:{item.Slug}->{recipient}" : $"buyItem:{item.Slug}");
// Grant the item to the RECIPIENT (= buyer for self-purchase, gift
// target for gift purchase). Avatar payloads append to
// AvatarEntity.InventoryJson; consumables and everything else
// insert a PlayerInventory row.
if (StoreService.TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc))
{
await GrantWardrobeAsync(recipient, StoreService.InventoryAvatarItemDesc(avatarItemDesc));
}
else
{
await GrantInventoryAsync(recipient, item.Slug);
}
// Insert a real GiftPackageEntity for the gift-box flow. The watch
// shows a gift-box animation after a purchase, then POSTs the
// package Id to /api/avatar/v2/gifts/consume/ to claim it. If the
// Id isn't a real row, that consume returns 404 and the watch
// logs 'Server failed to consume gift … HTTP Error 404' — the
// user's symptom on every successful buy. By writing the row
// synchronously and returning its DB-assigned Id in the
// BalanceUpdate response, the subsequent consume succeeds.
StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out avatarItemDesc);
var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc);
StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc);
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
inventoryAvatarDesc = hairDyeColorGuid;
consumableItemDesc = hairDyeConsumableDesc;
}
var gift = new GiftPackageEntity
{
RecipientPlayerId = recipient,
FromPlayerId = isGift && !giftTarget!.Anonymous ? (int?)pid : null,
AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType,
AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc,
ConsumableItemDesc = consumableItemDesc,
CurrencyType = req.CurrencyType,
Currency = 0,
Xp = 0,
Level = 1,
GiftContext = isGift ? giftTarget!.GiftContext : 0,
GiftRarity = 0,
Message = isGift ? (giftTarget!.Message ?? string.Empty) : string.Empty,
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
Consumed = false,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
// Push the buyer's balance update so their wallet UI refreshes.
await notifications.NotifyAsync(pid,
PushNotificationId.StorefrontBalancePurchase,
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug });
if (isGift)
{
// Tell the recipient a gift is waiting. The watch dispatches
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
// and pop the "you got a gift" toast; the gift box itself
// appears next time the recipient opens their inventory.
await notifications.NotifyAsync(recipient,
PushNotificationId.GiftPackageReceived,
new
{
Id = gift.Id,
Platform = -1,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = gift.GiftContext,
FromPlayerId = gift.FromPlayerId,
AvatarItemType = gift.AvatarItemType,
AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
Message = gift.Message ?? string.Empty,
});
}
// For gift purchases the buyer doesn't get a box — their flow
// ends with the balance update. Returning an empty Data array
// (giftPackageId = null) makes the watch's BuyItem callback
// skip the gift-receive animation on the sender's screen.
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType,
isGift ? null : item.Slug,
isGift ? null : gift.Id));
}
[HttpPost("api/storefronts/v1/buyTier")]
[HttpPost("api/storefronts/v2/buyTier")]
public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req)
{
// Season-pass tier: no per-item table, so we just deduct a
// small fixed cost (1 token) per tier and ack. A full
// season-pass system would have a SeasonProgressEntity.
var pid = Me;
const int TierCost = (int)StoreService.StoreItemPrice;
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
if (balance < TierCost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -TierCost,
$"buyTier:{req.PurchasableItemId}");
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
}
[HttpPost("api/storefronts/v1/buyElite")]
[HttpPost("api/storefronts/v2/buyElite")]
public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
{
// Elite-tier purchase (paid premium track). Without real
// money flow on a private server we just ack.
var pid = Me;
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
}
// ── Helpers ──────────────────────────────────────────────────────────
private async Task GrantWardrobeAsync(long pid, string guid)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(avatar);
}
try
{
var list = System.Text.Json.JsonSerializer
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
if (!list.Contains(guid))
{
list.Add(guid);
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
catch
{
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { guid });
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
private async Task GrantInventoryAsync(long pid, string slug)
{
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null)
{
db.PlayerInventory.Add(new PlayerInventoryEntity
{
PlayerId = pid,
ItemSlug = slug,
Quantity = 1,
});
}
else
{
row.Quantity += 1;
}
await db.SaveChangesAsync();
}
private static object BalanceUpdateResponse(long balance, int currencyType,
string? grantedSlug = null, long? giftPackageId = null)
{
// PurchaseBalanceUpdateResponseDTO<GiftPackage> wire shape, verified
// against Cpp2IL_ISIL/.../RecNet/Avatars_NestedType_GiftPackage.txt:
//
// PurchaseBalanceUpdateResponseDTO EXTENDS BalanceResponseDTO,
// so the watch first runs BalanceResponseDTO.Deserialize which
// does Util.GetKey<long>("Balance") / GetKey<int>("CurrencyType")
// / GetKey<int>("BalanceType") — REQUIRED, KeyNotFoundException
// on miss. Mirror the trio on the OUTER object.
//
// Then it reads BalanceUpdates: List<BalanceUpdateDTO<GiftPackage>>.
// Each inner BalanceUpdateDTO has UpdateResponse + Data, where
// Data is List<GiftPackage>. GiftPackage.Deserialize REQUIRES:
// "Id" (long) | Util.GetKey
// "Platform" (int) | Util.GetKey
// "Xp" (int) | Util.GetKey
// "Level" (int) | Util.GetKey
// "GiftRarity" (int) | Util.GetKey
// "GiftContext" (int) | Util.GetKey
// Plus optional FromPlayerId / AvatarItemType / AvatarItemDesc /
// ConsumableItemDesc / EquipmentPrefabName / EquipmentModificationGuid /
// CurrencyType / Currency / Message.
//
// The previous shape shipped {CurrencyType,Balance,BalanceType,Platform}
// in the Data array — those don't match GiftPackage's required keys,
// so Util.GetKey<long>("Id") threw KeyNotFoundException, the
// ApiCallback received a non-null error string, and StorePurchaseItem
// Screen logged the error as "Null". The user's
// output_log.txt:920 shows that callback path firing on every
// purchase attempt. New shape ships a real (synthetic) GiftPackage
// with all required keys + the granted item's GUID under
// AvatarItemDesc so the watch's gift-receive flow can preview the
// unlocked item.
//
// CRITICAL: the watch follows up every purchase with a POST to
// /api/avatar/v2/gifts/consume/ containing this Id. If the Id
// isn't a real GiftPackageEntity row in the DB, consume returns
// 404 and the watch's <RunConsumeGift> logs 'Server failed to
// consume gift … HTTP Error 404', which the player-destroy
// cascade picks up on (the user's symptom on every buy:
// game crashes after opening the gift box). Caller MUST pass
// <paramref name="giftPackageId"/> = the saved
// GiftPackageEntity.Id. The previous code used
// DateTime.UtcNow.Ticks as a fake id.
var giftId = giftPackageId ?? DateTime.UtcNow.Ticks;
// Pull the GUID off the slug for wardrobe-* purchases so the
// watch's GiftPackage receives the AvatarItem GUID and can
// render the unlock animation. dorknet-* slugs leave the desc
// empty (those are non-wardrobe SKUs).
var avatarItemType = 0;
var avatarItemDesc = string.Empty;
var consumableItemDesc = string.Empty;
if (grantedSlug is not null)
{
StoreService.TryGetAvatarItemPayload(grantedSlug, out avatarItemType, out avatarItemDesc);
StoreService.TryGetConsumableItemDesc(grantedSlug, out consumableItemDesc);
if (StoreService.TryGetHairDyePayload(grantedSlug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
avatarItemDesc = hairDyeColorGuid;
consumableItemDesc = hairDyeConsumableDesc;
}
}
var giftPackage = new
{
// REQUIRED via Util.GetKey — KeyNotFoundException on miss.
Id = giftId,
Platform = 0,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = 0,
// Optional — surfaced to the watch's gift-preview UI.
FromPlayerId = (int?)null,
AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType,
AvatarItemDesc = avatarItemDesc,
AvatarItemDescOrHairDyeDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc),
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
CurrencyType = currencyType,
Currency = 0,
Message = string.Empty,
Consumed = false,
ErrorMessage = string.Empty,
};
return new
{
// BalanceResponseDTO required trio.
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
// List<BalanceUpdateDTO<GiftPackage>>.
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0, // UpdateResponseTypes.Success
Data = grantedSlug is null
? Array.Empty<object>()
: new object[] { giftPackage },
},
},
};
}
}
@@ -1,499 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
/// <summary>
/// api.rec.net/api/storefronts/v* — real implementation backed by the
/// <see cref="StoreService"/> catalog (StoreItems table). Replaces the
/// previous AllEndpointsController stubs that returned empty lists,
/// which made the watch's Shop tab look broken.
///
/// Wire shape: every endpoint returns either a list of items (for
/// catalog endpoints) or a StorefrontDTO with a StoreItems array.
/// </summary>
[ApiController]
public class StorefrontsController(
StoreService store,
DorkNetDbContext db,
IConfiguration config,
LevelService level,
DomainConfig domain) : ControllerBase
{
/// <summary>GET api/storefronts/v3/all — full active catalog. The
/// watch's Shop tab uses this to populate every category.</summary>
[HttpGet("api/storefronts/v1/all")]
[HttpGet("api/storefronts/v2/all")]
[HttpGet("api/storefronts/v3/all")]
[AllowAnonymous]
public async Task<IActionResult> All()
{
var items = await store.GetAllActiveAsync();
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET api/storefronts/v3/skus — alias for the full
/// catalog. Some 2020 client paths call this instead of /all
/// during the watch's "browse all SKUs" flow.</summary>
[HttpGet("api/storefronts/v3/skus")]
[AllowAnonymous]
public async Task<IActionResult> Skus()
{
var items = await store.GetAllActiveAsync();
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET api/storefronts/v3/season — items currently in the
/// season-pass storefront. Empty for now since we don't run
/// seasons; returning an empty list rather than the catch-all so
/// the deserialiser sees the right shape.</summary>
[HttpGet("api/storefronts/v3/season")]
[AllowAnonymous]
public async Task<IActionResult> Season()
{
var items = await store.GetActiveByStorefrontAsync("season:1");
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET api/storefronts/v3/giftdrop — list of available
/// gift-drop storefront ids. The watch then queries each one via
/// /giftdropstore/{id} below to render the rotating shelf.</summary>
[HttpGet("api/storefronts/v3/giftdrop")]
[AllowAnonymous]
public IActionResult GiftDropList() =>
Ok(StoreService.GetStorefrontDefinitions()
.Where(s => s.StorefrontType is > 0)
.Select(s => s.StorefrontType!.Value)
.Distinct()
.OrderBy(i => i)
.ToArray());
/// <summary>GET api/storefronts/v3/giftdropstore/{id} — one
/// storefront shelf. The <c>{id}</c> is a value from the
/// <c>RecNet.StorefrontTypes</c> enum (verified against
/// Cpp2IL_CS/.../RecNet/StorefrontTypes.cs):
/// None=0, LaserTag=1, RecCenter=2, <b>Watch=3</b>,
/// Quest_*=100..103, RecRoyale=200, Cafe=300, Paintball=400..406,
/// Bowling=500, StuntRunner=600, DormMirror=700.
/// The Watch storefront (id=3) is the in-game Shop's Featured /
/// Clothing / Other grid. That grid assumes every gift drop has a
/// renderable AvatarItemDesc, so only wardrobe-backed rows are
/// surfaced there. Other storefronts use the canonical
/// <c>giftdrop:{id}</c> key plus the shared <c>rro</c> and
/// <c>all</c> admin keys.
/// </summary>
[HttpGet("api/storefronts/v3/giftdropstore/{id:int}")]
[AllowAnonymous]
public async Task<IActionResult> GiftDropStore(int id)
{
var (key, items) = id switch
{
3 => ($"watch", await store.GetRenderableGiftDropItemsAsync()),
_ => (StoreService.StorefrontKeyForType(id), await store.GetRoomStorefrontItemsAsync(id)),
};
return Ok(StoreService.ToStorefrontDto(key, items, domain.Apex, id));
}
/// <summary>GET api/storefronts/v1/balance/{type} — currency
/// balance for the caller. Wire shape is a list of
/// {CurrencyType, Balance, BalanceType, Platform} entries. The
/// watch displays the Balance value next to item prices.</summary>
[HttpGet("api/storefronts/v1/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v2/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v3/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v4/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v4/balance")]
[Authorize]
public async Task<IActionResult> Balance(int currencyType = 2,
[FromServices] LevelService level = null!)
{
var pid = this.RequireCurrentPlayerId();
var balance = await level.GetBalanceAsync(pid, currencyType);
// 2018 client (RecNet.cs:94092, KKBGNKJHGJF<BalanceResponseDTO>) parses a
// SINGLE object reading Balance(long)+CurrencyType(int) off the root —
// reading "Balance" off an array root throws. This branch is 2018-only,
// so the per-currency balance returns a single object, not an array.
return Ok(new
{
CurrencyType = currencyType,
BalanceType = 0,
Balance = balance,
Platform = 0,
});
}
/// <summary>GET <c>api/storefronts/v{1-4}/balanceAddType/{type}/{tier}</c>
/// — purchase-tier metadata for a currency. The watch uses this
/// to populate the "add tokens" tier list. Empty for a private
/// server with no IAP.</summary>
[HttpGet("api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v2/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v3/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v4/balanceAddType/{currencyType:int}/{tierId:int}")]
public IActionResult BalanceAddType(int currencyType, int tierId)
=> Ok(BalanceAddConfig(currencyType, tierId));
/// <summary>GET <c>api/storefronts/v2/balance</c> — every currency
/// balance for the caller in a single response. The watch's
/// wallet UI uses this to render all four currency chips at once.
/// CurrencyType per <c>CurrencyType.cs</c>: 0=Token, 1=Cheer,
/// 2=Coin, 3=Premium.</summary>
[HttpGet("api/storefronts/v2/balance")]
[Authorize]
public async Task<IActionResult> AllBalances()
{
var pid = this.RequireCurrentPlayerId();
var coin = await level.GetBalanceAsync(pid, 2);
var token = await level.GetBalanceAsync(pid, 0);
return Ok(new[]
{
new { CurrencyType = 2, BalanceType = 0, Balance = coin, Platform = 0 },
new { CurrencyType = 0, BalanceType = 0, Balance = token, Platform = 0 },
new { CurrencyType = 1, BalanceType = 0, Balance = 0L, Platform = 0 },
new { CurrencyType = 3, BalanceType = 0, Balance = 0L, Platform = 0 },
});
}
/// <summary>GET <c>api/storefronts/v1/current</c> — single active
/// storefront the watch's "today's deals" panel renders. Returns
/// the <c>main</c> storefront's items as a single PurchasableItem
/// list (bounded to keep older watches snappy).</summary>
[HttpGet("api/storefronts/v1/current")]
public async Task<IActionResult> Current()
{
if (!config.GetValue("Store:EnableWatchGiftDrops", true))
return Ok(StoreService.ToStorefrontDto("Main", Array.Empty<StoreItemEntity>(), domain.Apex));
IQueryable<StoreItemEntity> query = db.StoreItems
.Where(s => s.IsActive && s.Storefront == "main")
.OrderByDescending(s => s.UpdatedAt);
var max = config.GetValue("Store:MaxCurrentStorefrontItems", 96);
if (max > 0) query = query.Take(max);
var rows = await query.ToListAsync();
return Ok(StoreService.ToStorefrontDto("Main", rows, domain.Apex));
}
/// <summary>GET <c>api/storefronts/v1/season/{seasonId}</c> —
/// season-pass tier list, sourced from StoreItems tagged
/// <c>Storefront = "season:{id}"</c>.</summary>
[HttpGet("api/storefronts/v1/season/{seasonId:int}")]
public async Task<IActionResult> Season(int seasonId)
{
var key = $"season:{seasonId}";
var tiers = await db.StoreItems
.Where(s => s.IsActive && s.Storefront == key)
.OrderBy(s => s.Price)
.Select(s => new
{
TierId = s.Id,
s.DisplayName,
s.Description,
s.Price,
CurrencyType = s.CurrencyType,
s.ImageName,
})
.ToListAsync();
return Ok(new
{
SeasonId = seasonId,
SeasonType = seasonId,
Active = tiers.Count > 0,
StartsAt = DateTime.UtcNow.AddDays(-30),
EndsAt = DateTime.UtcNow.AddDays(30),
Tiers = tiers,
});
}
/// <summary>GET <c>api/storefronts/v1/objectives</c> — season
/// objective progress for the caller, keyed
/// <c>season:{n}</c>.</summary>
[HttpGet("api/storefronts/v1/objectives")]
public async Task<IActionResult> Objectives()
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(Array.Empty<object>());
var rows = await db.ObjectiveProgress
.Where(o => o.PlayerId == me && o.Key.StartsWith("season:"))
.Select(o => new { Key = o.Key, IsCompleted = o.IsCompleted, ClearedAt = o.ClearedAt })
.ToListAsync();
return Ok(rows);
}
/// <summary>POST <c>api/storefronts/v1/objectives</c> —
/// <c>Storefronts.CompleteObjectives</c> uploads completion
/// records and expects a <c>BalanceUpdateResponseDTO</c> back.
/// We ship the wrapper with the required Balance/CurrencyType/
/// BalanceType trio so <c>BalanceResponseDTO.Deserialize</c>
/// doesn't throw, plus an empty <c>BalanceUpdates</c>.</summary>
[HttpPost("api/storefronts/v1/objectives")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
[Authorize]
public async Task<IActionResult> CompleteObjectives()
{
var pid = this.RequireCurrentPlayerId();
var records = await ReadStorefrontObjectiveRecordsAsync();
var total = 0;
foreach (var record in records ?? new())
{
if (record.CompletionPercentage <= 0f) continue;
var rewardKey = $"storefront-objective:{record.ObjectiveType}:{DateTime.UtcNow:yyyyMMdd}";
if (await MarkRewardOnceAsync(pid, rewardKey))
{
var award = record.ObjectiveType switch
{
10 or 11 or 12 => 25,
13 => 150,
14 => 50,
15 => 75,
4000 or 4001 => 50,
_ => Math.Max(5, (int)Math.Round(25 * Math.Clamp(record.CompletionPercentage, 0f, 1f))),
};
total += award;
}
}
if (total > 0)
{
await level.AwardXpAsync(pid, total, "storefront-objectives");
await level.GrantCurrencyAsync(pid, 2, total, "storefront-objectives");
}
var balance = await level.GetBalanceAsync(pid, 2);
return Ok(BalanceUpdateResponse(balance, 2, new[]
{
RewardModification(100, total, total, currentCount: total > 0 ? 1 : 0),
}));
}
public sealed class StorefrontObjectiveCompletionRecord
{
public int ObjectiveType { get; set; }
public float CompletionPercentage { get; set; }
public long? RoomId { get; set; }
}
public sealed class GrantBalanceRewardDto
{
public int CurrencyType { get; set; } = 2;
public List<GrantBalanceRequest> BalanceAdds { get; set; } = new();
}
public sealed class GrantBalanceRequest
{
public float Multiplier { get; set; } = 1f;
public int BalanceAddType { get; set; } = 1;
}
[HttpPost("api/storefronts/v2/balance")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
[Authorize]
public async Task<IActionResult> ModifyBalance()
{
var body = await ReadGrantBalanceRewardAsync();
var pid = this.RequireCurrentPlayerId();
var currencyType = body.CurrencyType == 0 ? 2 : body.CurrencyType;
var modifications = new List<object>();
var total = 0;
foreach (var add in body.BalanceAdds ?? new())
{
var config = BalanceAddConfigValues(currencyType, add.BalanceAddType);
var multiplier = Math.Clamp(add.Multiplier <= 0 ? 1f : add.Multiplier, 0f, config.MaxPartialMultiplier);
var baseAward = config.IgnorePartialMultiplier
? config.BaseAward
: (int)Math.Round(config.BaseAward * multiplier);
baseAward = Math.Max(0, baseAward);
if (baseAward == 0) continue;
total += baseAward;
modifications.Add(RewardModification(add.BalanceAddType, baseAward, baseAward, currentCount: 1));
}
if (total > 0)
{
await level.GrantCurrencyAsync(pid, currencyType, total, "storefront-balance-reward");
await level.AwardXpAsync(pid, Math.Max(10, total / 2), "storefront-balance-reward");
}
var balance = await level.GetBalanceAsync(pid, currencyType);
return Ok(BalanceUpdateResponse(balance, currencyType, modifications));
}
public sealed class BalanceAddTypeRequest { public long Amount { get; set; } }
/// <summary>POST <c>api/storefronts/v1/balanceAddType/{type}/{playerId}</c>
/// — admin-only currency credit. Grants via
/// <see cref="LevelService.GrantCurrencyAsync"/>.</summary>
[HttpPost("api/storefronts/v1/balanceAddType/{type:int}/{playerId:long}")]
[Authorize]
public async Task<IActionResult> BalanceAddTypeAdmin(int type, long playerId, [FromBody] BalanceAddTypeRequest? body)
{
var me = this.RequireCurrentPlayerId();
var isAdmin = await db.Players.Where(p => p.Id == me).Select(p => p.IsAdmin).FirstOrDefaultAsync();
if (!isAdmin) return Forbid();
var amount = body?.Amount ?? 0L;
if (amount == 0) return Ok(new { Added = 0L });
var newBalance = await level.GrantCurrencyAsync(playerId, type, amount, $"balanceAddType:{me}");
return Ok(new
{
CurrencyType = type,
Balance = newBalance,
BalanceType = 0,
Platform = 0,
});
}
private static object BalanceAddConfig(int currencyType, int balanceAddType)
{
var cfg = BalanceAddConfigValues(currencyType, balanceAddType);
return new
{
CurrencyType = currencyType,
BalanceAddType = balanceAddType,
cfg.BaseAward,
BonusAwardMin = 0,
BonusAwardMax = 0,
RateLimitType = 0,
IgnorePartialMultiplier = cfg.IgnorePartialMultiplier,
MaxPartialMultiplier = (int)Math.Ceiling(cfg.MaxPartialMultiplier),
RateLimit = 0,
BalanceInGiftBox = false,
};
}
private static (int BaseAward, float MaxPartialMultiplier, bool IgnorePartialMultiplier)
BalanceAddConfigValues(int currencyType, int balanceAddType) => balanceAddType switch
{
1 => (100, 10f, false), // DirectBalanceWithMultiplier: Stunt Runner / banked loot.
10 => (25, 1f, true), // NUXChallenge.
11 => (100, 1f, true), // AllNUXChallenges.
100 => (25, 1f, true), // DailyChallenge.
101 => (150, 1f, true), // AllDailyChallenges.
200 => (50, 1f, true), // FinishActivity.
1000 => (100, 1f, true), // WonGame.
1001 => (25, 1f, true), // LostGame.
_ => (25, 1f, true),
};
private async Task<GrantBalanceRewardDto> ReadGrantBalanceRewardAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var dto = new GrantBalanceRewardDto
{
CurrencyType = int.TryParse(form["CurrencyType"], out var ct) ? ct : 2,
};
if (int.TryParse(form["BalanceAddType"], out var bat) || int.TryParse(form["balanceAddType"], out bat))
{
dto.BalanceAdds.Add(new GrantBalanceRequest
{
BalanceAddType = bat,
Multiplier = float.TryParse(form["Multiplier"], out var mult) ? mult : 1f,
});
}
return dto;
}
try
{
var dto = await JsonSerializer.DeserializeAsync<GrantBalanceRewardDto>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return dto ?? new GrantBalanceRewardDto();
}
catch
{
return new GrantBalanceRewardDto();
}
}
private async Task<List<StorefrontObjectiveCompletionRecord>> ReadStorefrontObjectiveRecordsAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (!int.TryParse(form["objectiveType"], out var objectiveType)
&& !int.TryParse(form["ObjectiveType"], out objectiveType))
{
return new();
}
return new()
{
new StorefrontObjectiveCompletionRecord
{
ObjectiveType = objectiveType,
CompletionPercentage = float.TryParse(form["completionPercentage"], out var pct)
|| float.TryParse(form["CompletionPercentage"], out pct)
? pct
: 1f,
RoomId = long.TryParse(form["roomId"], out var roomId)
|| long.TryParse(form["RoomId"], out roomId)
? roomId
: null,
},
};
}
try
{
var rows = await JsonSerializer.DeserializeAsync<List<StorefrontObjectiveCompletionRecord>>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return rows ?? new();
}
catch
{
return new();
}
}
private async Task<bool> MarkRewardOnceAsync(long playerId, string rewardKey)
{
var key = $"reward:{rewardKey}";
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == playerId && o.Key == key && o.IsCompleted))
return false;
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = playerId,
Key = key,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return true;
}
private static object RewardModification(int balanceAddType, int baseAward, int total, int currentCount) => new
{
BalanceAddType = balanceAddType,
BaseAward = baseAward,
BonusAward = 0,
RateLimit = 0,
CurrentCount = currentCount,
Total = total,
BalanceType = 0,
BalanceInGiftBox = false,
};
private static object BalanceUpdateResponse(long balance, int currencyType, IEnumerable<object> modifications) => new
{
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0,
Data = modifications.Where(m => m is not null).ToArray(),
},
},
};
}
@@ -1,195 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.TestCaseManagement;
/// <summary>
/// api.rec.net/api/testcasemanagement/* — internal QA tooling. URL
/// surface verified against
/// <c>Cpp2IL_ISIL/.../RecNet/TestCaseManagement.txt</c>:
///
/// GET api/testcasemanagement/v1/testpasssummary → List&lt;TestPass&gt;
/// GET api/testcasemanagement/v1/testpass/{id} → TestPass
/// GET api/testcasemanagement/v1/testcase/{id} → TestCase
/// POST api/testcasemanagement/v1/testcase/{id}/claim
/// POST api/testcasemanagement/v1/testcase/{id}/unclaim
/// POST api/testcasemanagement/v1/testcase/{id}/status
///
/// Wire DTOs match <c>RecNet.TestCase</c> + <c>RecNet.TestPass</c>
/// exactly:
///
/// • TestCase: <c>Id(string), Key, Title, Description, RoomName,
/// Status(TestCaseStatus enum 0=NotYetTested/1=Claimed/2=Failed/3=Passed),
/// MinNumAssignedPlayers, AssignedPlayerIds(List&lt;int&gt;),
/// AssignedPlayerNames, Tags, JiraUrl, JiraBugUrl</c>.
/// • TestPass: <c>Id(uint), Name, Description, StartDate, EndDate?,
/// WasManuallyClosed, TestCases(List&lt;TestCase&gt;), Tags,
/// NumTestCases, NumPassedTestCases, NumFailedTestCases</c>.
///
/// Routes are reachable by anyone (the watch only renders this tab
/// when the player has the developer role; client-side gate). The
/// claim/status mutations no-op if the caller isn't authenticated.
/// </summary>
[ApiController]
public class TestCaseManagementController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/testcasemanagement/v1/testpasssummary")]
public async Task<IActionResult> TestPassSummaries()
{
var passes = await db.TestPasses.ToListAsync();
var caseCountsByPass = await db.TestCases
.Where(c => c.TestPassId != null)
.GroupBy(c => c.TestPassId!.Value)
.Select(g => new PassCounts(g.Key, g.Count(),
g.Count(c => c.Status == 3), g.Count(c => c.Status == 2)))
.ToDictionaryAsync(x => x.PassId);
return Ok(passes.Select(p => ToWirePass(p, new List<TestCaseEntity>(), caseCountsByPass)));
}
[HttpGet("api/testcasemanagement/v1/testpass/{id:long}")]
public async Task<IActionResult> TestPass(long id)
{
if (id < 0 || id > uint.MaxValue) return NotFound();
var pid = (uint)id;
var pass = await db.TestPasses.FirstOrDefaultAsync(p => p.Id == pid);
if (pass is null) return NotFound();
var cases = await db.TestCases.Where(c => c.TestPassId == pid).ToListAsync();
var counts = new Dictionary<uint, PassCounts>
{
[pid] = new(pid, cases.Count, cases.Count(c => c.Status == 3), cases.Count(c => c.Status == 2)),
};
return Ok(ToWirePass(pass, cases, counts));
}
private record PassCounts(uint PassId, int Total, int Passed, int Failed);
[HttpGet("api/testcasemanagement/v1/testcase/{id}")]
public async Task<IActionResult> TestCase(string id)
{
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
return Ok(ToWireCase(tc));
}
[HttpPost("api/testcasemanagement/v1/testcase/{id}/claim")]
[Authorize]
public async Task<IActionResult> Claim(string id)
{
var pid = this.RequireCurrentPlayerId();
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
var player = await db.Players.Where(p => p.Id == pid).Select(p => p.Username).FirstOrDefaultAsync();
var ids = ParseIntList(tc.AssignedPlayerIdsJson);
var names = ParseStringList(tc.AssignedPlayerNamesJson);
if (!ids.Contains((int)pid))
{
ids.Add((int)pid);
names.Add(player ?? $"Player {pid}");
tc.AssignedPlayerIdsJson = JsonSerializer.Serialize(ids);
tc.AssignedPlayerNamesJson = JsonSerializer.Serialize(names);
}
tc.Status = 1; // Claimed
tc.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWireCase(tc));
}
[HttpPost("api/testcasemanagement/v1/testcase/{id}/unclaim")]
[Authorize]
public async Task<IActionResult> Unclaim(string id)
{
var pid = this.RequireCurrentPlayerId();
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
var ids = ParseIntList(tc.AssignedPlayerIdsJson);
var names = ParseStringList(tc.AssignedPlayerNamesJson);
var idx = ids.IndexOf((int)pid);
if (idx >= 0)
{
ids.RemoveAt(idx);
if (idx < names.Count) names.RemoveAt(idx);
tc.AssignedPlayerIdsJson = JsonSerializer.Serialize(ids);
tc.AssignedPlayerNamesJson = JsonSerializer.Serialize(names);
}
if (ids.Count == 0 && tc.Status == 1) tc.Status = 0; // back to NotYetTested
tc.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWireCase(tc));
}
public sealed class StatusUpdateRequest
{
public int NewStatus { get; set; }
}
[HttpPost("api/testcasemanagement/v1/testcase/{id}/status")]
[Authorize]
public async Task<IActionResult> UpdateStatus(string id, [FromBody] StatusUpdateRequest req)
{
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
if (req.NewStatus is < 0 or > 3) return BadRequest("invalid status");
tc.Status = req.NewStatus;
tc.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWireCase(tc));
}
// ── Helpers ──────────────────────────────────────────────────────────
private static List<int> ParseIntList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try { return JsonSerializer.Deserialize<List<int>>(json) ?? new(); }
catch { return new(); }
}
private static List<string> ParseStringList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new(); }
catch { return new(); }
}
private static object ToWireCase(TestCaseEntity c) => new
{
c.Id,
c.Key,
c.Title,
c.Description,
c.RoomName,
c.Status,
c.MinNumAssignedPlayers,
AssignedPlayerIds = ParseIntList(c.AssignedPlayerIdsJson),
AssignedPlayerNames = ParseStringList(c.AssignedPlayerNamesJson),
Tags = ParseStringList(c.TagsJson),
c.JiraUrl,
c.JiraBugUrl,
};
private static object ToWirePass(TestPassEntity p, List<TestCaseEntity> cases,
IDictionary<uint, PassCounts> counts)
{
var c = counts.TryGetValue(p.Id, out var v) ? v : new PassCounts(p.Id, 0, 0, 0);
return new
{
p.Id,
p.Name,
p.Description,
p.StartDate,
p.EndDate,
p.WasManuallyClosed,
TestCases = cases.Select(ToWireCase),
Tags = ParseStringList(p.TagsJson),
NumTestCases = c.Total,
NumPassedTestCases = c.Passed,
NumFailedTestCases = c.Failed,
};
}
}
@@ -1,31 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Version;
[ApiController]
public class VersionController : ControllerBase
{
// VersionStatus enum: ValidForPlay=0, ValidForMenu=1, UpdateRequired=2
//
// Dual-shape response so a single endpoint satisfies both client eras:
// • 2020 build reads the int "VersionStatus" (0 == ValidForPlay).
// • 2018.06 build (api/versioncheck/v3) reads the bool "ValidVersion"
// via a case-sensitive dict index (RecNet.cs BPPMLPMDHCI.Deserialize
// -> Util.GetKey<bool>("ValidVersion")). A MISSING key throws
// KeyNotFoundException -> the connect coroutine shows "Rec Room
// update required" and aborts boot before login. Emitting both keys
// keeps each era reading only the field it knows.
private static readonly object ValidForPlay = new { VersionStatus = 0, ValidVersion = true };
[HttpGet("api/version/v1")]
[HttpGet("api/version")]
public ActionResult<object> GetV1() => Ok(ValidForPlay);
// Actual path the 2020 build uses
[HttpGet("api/versioncheck/v4")]
[HttpGet("api/versioncheck/v3")]
[HttpGet("api/versioncheck/v2")]
[HttpGet("api/versioncheck/v1")]
[HttpGet("api/versioncheck")]
public ActionResult<object> GetCheck() => Ok(ValidForPlay);
}
@@ -1,573 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Accounts;
/// <summary>
/// accounts.rec.net — account management.
/// Shapes match dump.cs:
/// RecNet.Account { AccountId, RawUsername, Username, DisplayName,
/// ProfileImage, TreatAsJunior, HasBirthday, Platforms }
/// RecNet.SelfAccount Account + { Email, Phone, Birthday, JuniorState,
/// ParentAccountId }
/// RecNet.RecNetResult { Success, Error }
/// Accounts.CreateAccountResponse RecNetResult + { Account }
/// </summary>
[ApiController]
public class AccountsController(
PlayerService playerService,
AuthService authService,
OrphanAccountTracker orphans,
DorkNetDbContext db,
NotificationService notifications,
ServerSettingsService settings,
ILogger<AccountsController> logger) : ControllerBase
{
[HttpGet("/account/v1/savedlogins")]
[HttpGet("/account/v2/savedlogins")]
[HttpGet("/account/v1/cachedlogins")]
public async Task<IActionResult> GetSavedLogins(
[FromQuery] int platform = 0,
[FromQuery] string? platformId = null)
{
var players = await playerService.GetCachedLoginsAsync(platform, platformId);
return Ok(players.Select(p => new CachedLogin
{
Platform = platform,
PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
}).ToList());
}
// Accounts.GetLocalAccount → SelfAccount shape.
// The 2020 client calls the unversioned /account/me path (observed in
// server logs); v1/v2 forms are kept for older builds and api.rec.net
// bridging. Returns SelfAccount, NOT base Account (this is the *self*
// endpoint — RecNet.Login.<>c.<LoginHelper>b__35_4 expects a SelfAccount).
[HttpGet("/account/me")]
[HttpGet("/account/v1/me")]
[HttpGet("/account/v2/me")]
[HttpGet("/account/v1/account")]
public async Task<IActionResult> GetMe()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
}
/// <summary>
/// Resolves the player tied to the bearer token on the request. Returns
/// null when no/invalid bearer is supplied — the caller decides whether
/// that is a 401 (every "self" endpoint should be) or a noisier
/// fallback. Anonymous-account creation was previously the fallback
/// path; that's gone now because every caller has gone through
/// `/account/create` (deviceId-bound) before reaching us.
/// </summary>
private async Task<Data.Entities.PlayerEntity?> GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return null;
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return null;
return await playerService.GetByIdAsync(playerId);
}
// Accounts.GetAccountById → Account shape. The 2020 watch posts to
// the BARE <c>/account/{id}</c> path (no <c>v1</c> prefix) when
// resolving a room's creator from RoomDetails — verified in the
// output_log "Failed to GetAccountById: HTTP Error 404" trace where
// PlayerDetailsWatchUIFlow.SetAccount NREs because the GetAccountById
// promise rejected with 404. Accept both shapes.
[HttpGet("/account/{accountId:long}")]
[HttpGet("/account/v1/{accountId:long}")]
[HttpGet("/account/v1/account/{accountId:long}")]
public async Task<IActionResult> GetAccountById(long accountId)
{
var player = await playerService.GetByIdAsync(accountId);
if (player is null) return NotFound();
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
}
// Accounts.GetAccountByUsername
[HttpGet("/account/v1/username/{username}")]
public async Task<IActionResult> GetAccountByUsername(string username)
{
var player = await playerService.GetByUsernameAsync(username);
if (player is null) return NotFound();
return Ok(BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
}
// Accounts.CreateAccount — actual URL observed in server logs:
// POST accounts.rec.net/account/create
// body: platform=0&platformId=<steamId>&deviceId=<hash>
// Game expects CreateAccountResponse {Success, Error, Account}. Lambda
// <CreateAccount>b__29_0 NPEs if response.Account is null after deserializing.
// Clean PascalCase here — multi-case keys appear to confuse LitJson's typed
// path so its TypeMap.Add throws "Key: none" or leaves Account unpopulated.
[HttpPost("/account/create")]
[HttpPost("/account/v1/create")]
[HttpPost("/account/v1")]
[HttpPost("/account/v2/create")]
public async Task<IActionResult> CreateAccount(
[FromForm] int platform,
[FromForm] string? platformId,
[FromForm] string? deviceId)
{
// Admin kill-switch. Watch's <CreateAccount>b__29_0 lambda
// (Accounts_NestedType___c.txt:311) branches on Success: false
// → ErroredPromise<Account> with the Error string surfaced to
// the player; true → reads response.Account. Account stays a
// non-null stub even on refusal so LitJson's typed Deserialize
// path doesn't fault before the Success check runs.
if (await settings.AreSignupsDisabledAsync())
{
logger.LogInformation(
"[accounts] account/create refused — signups disabled (device={Device} platform={Platform} platformId={PlatformId})",
deviceId, platform, platformId);
return Ok(new CreateAccountResponse
{
Success = false,
Error = "Account creation is currently disabled by the server admin.",
});
}
// The 2020 watch's "Create new account" UI and its boot-time
// "ensure my account exists" probe BOTH hit this endpoint with
// identical request bodies. To support multi-account-per-device
// we always create a brand-new row; the boot-flow case (where
// the user actually wants their cached account) is handled by
// the OrphanAccountTracker — when the next cached_login picks
// a DIFFERENT account_id, that handler deletes this just-
// created row.
var player = await playerService.CreateNewAccountAsync(
deviceId: deviceId,
platform: platform,
platformId: platformId);
orphans.TrackCreation(deviceId, platformId, player.Id);
logger.LogInformation(
"[accounts] account/create created {Id} ({Username}) for device {Device} platform={Platform} platformId={PlatformId}",
player.Id, player.Username, deviceId, platform, platformId);
return Ok(new CreateAccountResponse
{
Success = true,
Error = string.Empty,
Account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName),
});
}
// Accounts.SearchForAccounts — used by the watch's typeahead AND the
// localhost public site's account search. Anonymous-safe (no JWT
// required) so the front-facing site can call it directly.
[HttpGet("/account/v1/search")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
{
if (string.IsNullOrWhiteSpace(query)) return Ok(Array.Empty<object>());
var clamped = Math.Clamp(take, 1, 50);
var matches = await playerService.SearchAsync(query.Trim(), clamped);
return Ok(matches
.Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName))
.ToList());
}
// Accounts.GetAccountsBulk / GetAccountsFromPlatformIds / FromEmails / FromPhones
// Form body: Ids=1,2,3 (BestHTTP HTTPFormBase.AddField). Accept both
// GET and POST and any content type so route matching succeeds even
// when the client sends multipart vs urlencoded.
[HttpPost("/account/v1/bulk")]
[HttpGet("/account/v1/bulk")]
[HttpPost("/account/bulk")]
[HttpGet("/account/bulk")]
[HttpPost("/account/v1/platformids")]
[HttpPost("/account/v1/emails")]
[HttpPost("/account/v1/phones")]
[HttpPost("/account/v1/byplatformid")]
public async Task<IActionResult> Bulk()
{
// The watch calls this route family with payloads keyed by
// either Ids (account ids), Emails (email addresses), or
// Phones (E.164 strings) depending on which Accounts.GetXxx
// helper fired it. Read all three plus the lowercase / singular
// variants the cached-account hydration uses.
string? rawIds = null;
string? rawEmails = null;
string? rawPhones = null;
try
{
if (Request.HasFormContentType)
{
rawIds = NonEmptyOrNull(Request.Form["Ids"].ToString())
?? NonEmptyOrNull(Request.Form["ids"].ToString())
?? NonEmptyOrNull(Request.Form["Id"].ToString())
?? NonEmptyOrNull(Request.Form["id"].ToString());
rawEmails = NonEmptyOrNull(Request.Form["Emails"].ToString())
?? NonEmptyOrNull(Request.Form["emails"].ToString())
?? NonEmptyOrNull(Request.Form["Email"].ToString())
?? NonEmptyOrNull(Request.Form["email"].ToString());
rawPhones = NonEmptyOrNull(Request.Form["Phones"].ToString())
?? NonEmptyOrNull(Request.Form["phones"].ToString())
?? NonEmptyOrNull(Request.Form["Phone"].ToString())
?? NonEmptyOrNull(Request.Form["phone"].ToString());
}
}
catch { /* form parse may fail on non-form bodies */ }
rawIds ??= NonEmptyOrNull(Request.Query["Ids"].ToString())
?? NonEmptyOrNull(Request.Query["ids"].ToString())
?? NonEmptyOrNull(Request.Query["Id"].ToString())
?? NonEmptyOrNull(Request.Query["id"].ToString());
static string? NonEmptyOrNull(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
// Build the set of player ids to hydrate from whatever the
// caller sent. Each source contributes additively — the watch
// never mixes (Ids + Emails) in one call, but the union is
// harmless when it happens.
var ids = new HashSet<long>();
if (rawIds is not null)
{
foreach (var n in rawIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(n, out var v) && v > 0) ids.Add(v);
}
if (rawEmails is not null)
{
var emails = rawEmails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(e => e.ToLowerInvariant())
.Where(e => e.Length > 0)
.ToList();
if (emails.Count > 0)
{
var matched = await db.Players
.Where(p => p.Email != null && emails.Contains(p.Email.ToLower()))
.Select(p => p.Id)
.ToListAsync();
foreach (var id in matched) ids.Add(id);
}
}
if (rawPhones is not null)
{
var phones = rawPhones.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(p => p.Length > 0)
.ToList();
if (phones.Count > 0)
{
var matched = await db.Players
.Where(p => p.Phone != null && phones.Contains(p.Phone))
.Select(p => p.Id)
.ToListAsync();
foreach (var id in matched) ids.Add(id);
}
}
if (ids.Count == 0) return Ok(Array.Empty<object>());
// Strip ids the caller has blocked, and ids that have blocked the
// caller. This prevents `bulk` from being used as a cheap oracle
// ("who blocked me?"). When the caller is anonymous (no JWT) we
// skip the filter entirely — `bulk` is also used during boot for
// public profile hydration before the player has authenticated.
var me = this.CurrentPlayerId();
if (me is long callerId)
{
var blocked = await db.Relationships
.Where(r => r.Status == RelationshipStatus.Blocked &&
((r.RequesterId == callerId && ids.Contains(r.TargetId)) ||
(r.TargetId == callerId && ids.Contains(r.RequesterId))))
.Select(r => r.RequesterId == callerId ? r.TargetId : r.RequesterId)
.ToListAsync();
foreach (var b in blocked) ids.Remove(b);
}
var accounts = new List<object>();
foreach (var id in ids)
{
var p = await playerService.GetByIdAsync(id);
if (p is not null)
accounts.Add(BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName));
}
return Ok(accounts);
}
// Accounts.GetNameGenerationOptions → NameGenDTO {Nouns, Adjectives}
[HttpGet("/account/v1/namegen")]
[HttpGet("/account/v1/namegeneration")]
public IActionResult NameGen() => Ok(new
{
Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" },
Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" },
});
// Phone-confirm is the one mutation we don't persist — there's no
// SMS provider in the loop. Acknowledge silently.
[HttpPost("/account/v1/confirmphone")]
public IActionResult ConfirmPhone() =>
Ok(new RecNetResult { Success = true, Error = string.Empty });
/// <summary>POST /account/v1/birthday — sets the player's
/// birthday and updates IsJunior accordingly. Rec Room's junior
/// cutoff is &lt;13 years old; we recompute it on every birthday
/// write so a freshly turned 13 player loses the junior flag the
/// next time they update.</summary>
[HttpPost("/account/v1/birthday")]
[HttpPost("/account/me/birthday")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetBirthday([FromForm(Name = "birthday")] string? birthday)
{
if (string.IsNullOrWhiteSpace(birthday) ||
!DateTime.TryParse(birthday, out var bday))
return Ok(new RecNetResult { Success = false, Error = "invalid_date" });
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
player.Birthday = bday.Date;
player.IsJunior = AgeYears(bday) < 13;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
/// <summary>POST /account/v1/email — set or clear contact email.
/// Persisted verbatim; format isn't validated server-side, the
/// in-game email keyboard does it.</summary>
[HttpPost("/account/v1/email")]
[HttpPost("/account/me/email")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetEmail([FromForm(Name = "email")] string? email)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
player.Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
/// <summary>POST /account/v1/phone — set or clear contact phone.</summary>
[HttpPost("/account/v1/phone")]
[HttpPost("/account/me/phone")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetPhone([FromForm(Name = "phone")] string? phone)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
player.Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim();
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
/// <summary>POST /account/v1/profileimage — body: imageName=...
/// or fileName=... The client first POSTs the bytes to
/// <c>storage.rec.net/upload</c> with FileType=Image, gets back a
/// Filename, then sends that filename here. We persist it on
/// PlayerEntity.ProfileImageName; reads from /account/me +
/// /account/v1/{id} surface it.</summary>
[HttpPost("/account/v1/profileimage")]
[HttpPost("/account/me/profileimage")]
[HttpPost("/profileimage")]
[HttpPut("/account/v1/profileimage")]
[HttpPut("/account/me/profileimage")]
[HttpPut("/profileimage")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetProfileImage(
[FromForm(Name = "imageName")] string? imageName,
[FromForm(Name = "fileName")] string? fileName,
[FromForm(Name = "image")] string? imageAlt)
{
// Fall back to query string for the rare client paths that
// send these fields as URL params instead of form fields.
var name = imageName ?? fileName ?? imageAlt
?? Request.Query["imageName"].ToString()
?? Request.Query["fileName"].ToString();
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
player.ProfileImageName = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
await db.SaveChangesAsync();
logger.LogInformation(
"[accounts] profile image set: player={PlayerId} → {Name}",
player.Id, player.ProfileImageName ?? "<cleared>");
await NotifyAccountImageChangedAsync(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = BuildAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName);
var selfAccount = BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName);
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
private static int AgeYears(DateTime birthday)
{
var today = DateTime.UtcNow.Date;
var age = today.Year - birthday.Year;
if (birthday.Date > today.AddYears(-age)) age--;
return age;
}
// Everyone is a dev for now — exposes the in-game debug console + dev
// tools. Per user request: easier debugging while iterating on the
// private server. Flip back to false later if you want shipping behavior.
[HttpGet("/account/v1/{accountId:long}/isdeveloper")]
[HttpGet("/account/me/isdeveloper")]
public IActionResult IsDeveloper(long accountId = 0) => Ok(new { IsDeveloper = true });
/// <summary>
/// PUT /account/me/username — body: username=NewName
/// Real handler that persists the change to the DB. Was previously
/// going to GlobalCatchAllController and returning the shotgun, which
/// meant username changes didn't actually save.
/// </summary>
[HttpPut("/account/me/username")]
[HttpPost("/account/me/username")]
[HttpPost("/account/v1/username")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ChangeUsername([FromForm(Name = "username")] string? username)
{
if (string.IsNullOrWhiteSpace(username))
return Ok(new { success = false, error = "Username required" });
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
await playerService.UpdateUsernameAsync(player.Id, username.Trim());
var updated = await playerService.GetByIdAsync(player.Id);
return Ok(new
{
success = true,
error = "",
account = new
{
accountId = (int)player.Id,
username = updated?.Username ?? username,
displayName = updated?.DisplayName ?? username,
},
});
}
/// <summary>
/// PUT /account/me/displayname — same flow as username but only updates
/// the DisplayName field (Username stays as-is).
/// </summary>
[HttpPut("/account/me/displayname")]
[HttpPost("/account/me/displayname")]
[HttpPost("/account/v1/displayname")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ChangeDisplayName([FromForm(Name = "displayName")] string? displayName)
{
if (string.IsNullOrWhiteSpace(displayName))
return Ok(new { success = false, error = "DisplayName required" });
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
await playerService.UpdateDisplayNameAsync(player.Id, displayName.Trim());
return Ok(new { success = true, error = "" });
}
/// <summary>
/// PUT /account/me/bio — body: bio=...
/// </summary>
[HttpPut("/account/me/bio")]
[HttpPost("/account/me/bio")]
[HttpPost("/account/v1/bio")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ChangeBio([FromForm(Name = "bio")] string? bio)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
await playerService.UpdateBioAsync(player.Id, bio ?? string.Empty);
return Ok(new { success = true, error = "" });
}
// ParentalControl.Deserialize at RVA 0x14543A0 — required keys:
// accountId (int, GetKey)
// disallowInAppPurchases (bool, GetKey)
// Returning {} crashes the client with
// "Failed to GetLocalAccountParentalControl: Malformed Response".
[HttpGet("/parentalcontrol/me")]
[HttpGet("/parentalcontrol/v1/me")]
public async Task<IActionResult> ParentalControlMe()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
return Ok(new
{
accountId = (int)player.Id,
disallowInAppPurchases = false,
});
}
[HttpGet("/parentalcontrol/{accountId:long}")]
[HttpGet("/parentalcontrol/v1/{accountId:long}")]
public IActionResult ParentalControlById(long accountId) => Ok(new
{
accountId = (int)accountId,
disallowInAppPurchases = false,
});
[HttpGet("/account/v1/me/disallowiap")]
public IActionResult DisallowIap() => Ok(new { Disallow = false });
/// <summary>GET /account/{id}/bio — public bio lookup. Watch URL
/// from <c>Cpp2IL_ISIL/.../RecNet/Accounts.txt</c> line 5686:
/// <c>"account/{0}/bio"</c> (no v1 prefix). Wire shape from
/// BioDTO.txt: required keys <c>"accountId"</c> (int, Util.GetKey)
/// and <c>"bio"</c> (string, optional via the GetKeyOrDefault-
/// flavoured 0x180EE6770 helper). Returning the previous
/// <c>{ Bio = ... }</c> PascalCase shape made the watch's
/// <c>BioDTO.Deserialize</c> KeyNotFoundException on
/// <c>accountId</c>, which surfaced as "Failed to GetBio for
/// accountId N: Malformed Response" in the client log.</summary>
[HttpGet("/account/{accountId:long}/bio")]
[HttpGet("/account/v1/{accountId:long}/bio")]
public async Task<IActionResult> GetBio(long accountId)
{
var p = await playerService.GetByIdAsync(accountId);
return Ok(new { accountId = (int)accountId, bio = p?.Bio ?? string.Empty });
}
[HttpGet("/account/v1/{accountId:long}/platformid")]
public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L });
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
// unknown accounts URLs now 404 so we notice them in logs and wire
// them properly rather than silently masking missing endpoints.
private static RecNetAccount BuildAccount(long id, string username, string? displayName, string? profileImage = null)
=> new()
{
AccountId = (int)id,
RawUsername = username,
Username = username,
DisplayName = displayName ?? username,
ProfileImage = profileImage ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
};
private static RecNetSelfAccount BuildSelfAccount(long id, string username, string? displayName, string? profileImage = null)
=> new()
{
AccountId = (int)id,
RawUsername = username,
Username = username,
DisplayName = displayName ?? username,
ProfileImage = profileImage ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
Email = string.Empty,
Phone = string.Empty,
Birthday = new DateTime(2000, 1, 1),
JuniorState = 0,
ParentAccountId = null,
};
}
@@ -1,3203 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Hubs;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// api.rec.net/api/admin/v1/* — privileged moderation endpoints. Every
/// action is gated by <see cref="AdminOnlyAttribute"/>, which checks
/// <see cref="PlayerEntity.IsAdmin"/>; every state mutation is logged
/// to <see cref="AdminActionEntity"/> for audit replay.
///
/// The first account created on a fresh DB gets <c>IsAdmin = true</c>
/// (see <see cref="PlayerService.GetOrCreateByDeviceAsync"/>) so a
/// freshly-bootstrapped private server always has at least one root
/// admin without manual SQL.
/// </summary>
[ApiController]
[Route("api/admin/v1")]
[Authorize]
[AdminOnly]
public class AdminController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level,
CommunityBoardService communityBoard,
RoomBlobNormalizerService roomBlobNormalizer,
HtrAssetMirrorService htrMirror,
OnlinePresenceService onlinePresence,
PlayerPresenceService playerPresence,
PlayerLogService playerLog,
ServerSettingsService serverSettings,
SignupCodeService signupCodes,
IObjectStorage storage,
DomainConfig domain,
IdentityServerSigningKeyProvider signingKeys,
ILogger<AdminController> adminLogger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
// ── Player request logs ──────────────────────────────────────────────
/// <summary>Most recent HTTP requests made by a single player, newest
/// first. Backed by <see cref="PlayerLogService"/>'s Redis ring buffer
/// (in-process fallback for dev) — capped at
/// <see cref="PlayerLogService.MaxEntriesPerPlayer"/> entries per
/// player with a 7-day key TTL. Used by the admin UI's "Player logs"
/// tab to spot what API call a player was making when they hit a bug.</summary>
[HttpGet("players/{id:long}/logs")]
public ActionResult PlayerLogs(long id, [FromQuery] int take = 200)
{
var entries = playerLog.GetRecent(id, take);
return Ok(entries);
}
// ── Players ──────────────────────────────────────────────────────────
/// <summary>List players with online status, paginated. Used by the
/// admin watch tab. Default page size 50, max 200 to keep responses
/// reasonable.</summary>
[HttpGet("players")]
public async Task<ActionResult> ListPlayers(
[FromQuery] string? query, [FromQuery] int take = 50, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
IQueryable<PlayerEntity> q = db.Players.OrderByDescending(p => p.LastSeenAt);
if (!string.IsNullOrWhiteSpace(query))
q = q.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query));
var page = await q.Skip(skip).Take(take).ToListAsync();
var onlineSet = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(page.Select(p => new
{
p.Id,
p.Username,
p.DisplayName,
p.Email,
p.IsAdmin,
p.IsDeveloper,
p.IsCommunityTeam,
p.IsVerified,
p.IsJunior,
p.BannedUntil,
p.LastIp,
p.LastSeenAt,
p.CreatedAt,
p.Level,
p.XP,
p.ProfileImageName,
Online = onlineSet.Contains(p.Id),
}));
}
/// <summary>Single-player detail — bundles the player record plus
/// their currency balances so the admin UI can render an edit page
/// in one round trip.</summary>
[HttpGet("players/{id:long}")]
public async Task<ActionResult> GetPlayer(long id)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
var balances = await db.CurrencyBalances
.Where(c => c.PlayerId == id)
.Select(c => new { c.CurrencyType, c.Balance })
.ToListAsync();
var avatar = await db.Avatars
.Where(a => a.PlayerId == id)
.Select(a => new { a.OutfitSelections, a.HairColor, a.SkinColor, a.FaceFeatures })
.FirstOrDefaultAsync();
return Ok(new
{
p.Id,
p.Username,
p.DisplayName,
p.Bio,
p.Email,
p.IsAdmin,
p.IsDeveloper,
p.IsCommunityTeam,
p.IsVerified,
p.IsJunior,
p.BannedUntil,
p.LastIp,
p.LastSeenAt,
p.CreatedAt,
p.Level,
p.XP,
p.ProfileImageName,
Online = onlinePresence.OnlinePlayerIds().Contains(p.Id),
Balances = balances,
Avatar = avatar,
});
}
public sealed record BanRequest(int DurationDays, string? Reason);
/// <summary>Ban a player for the given number of days. The
/// <see cref="BanCheckMiddleware"/> 401s any subsequent request
/// from the player until <c>BannedUntil</c> elapses; we also push
/// a <c>ModerationKick</c> notification so the watch sees the ban
/// immediately.</summary>
[HttpPost("players/{id:long}/ban")]
public async Task<ActionResult> BanPlayer(long id, [FromBody] BanRequest body)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
if (player.IsAdmin) return BadRequest("Cannot ban an admin. Demote first.");
var days = Math.Clamp(body.DurationDays, 1, 3650);
player.BannedUntil = DateTime.UtcNow.AddDays(days);
await LogAsync("ban_player", "player", id, body.Reason ?? "");
await db.SaveChangesAsync();
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
new { Reason = body.Reason ?? "", Until = player.BannedUntil });
return Ok(new { player.Id, player.BannedUntil });
}
[HttpPost("players/{id:long}/unban")]
public async Task<ActionResult> UnbanPlayer(long id, [FromBody] BanRequest? body)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.BannedUntil = null;
await LogAsync("unban_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync();
return Ok(new { player.Id, player.BannedUntil });
}
[HttpPost("players/{id:long}/promote")]
public async Task<ActionResult> Promote(long id)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.IsAdmin = true;
await LogAsync("promote_admin", "player", id, "");
await db.SaveChangesAsync();
return Ok(new { player.Id, player.IsAdmin });
}
[HttpPost("players/{id:long}/demote")]
public async Task<ActionResult> Demote(long id)
{
// Refuse to demote yourself — guards against the last admin
// accidentally dropping privileges and being unable to recover.
if (id == CurrentAdminId)
return BadRequest("Cannot demote yourself. Have another admin do it.");
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.IsAdmin = false;
await LogAsync("demote_admin", "player", id, "");
await db.SaveChangesAsync();
return Ok(new { player.Id, player.IsAdmin });
}
// ── Rooms ────────────────────────────────────────────────────────────
public sealed record DeleteRoomRequest(string? Reason);
/// <summary>Soft-delete a room by setting <c>State = 1</c>
/// (Archived). The room stops appearing in browse/search results;
/// its data is still retrievable for an undelete if needed.</summary>
/// <summary>GET <c>api/admin/v1/diagnostics/room-images</c> —
/// emergency diagnostic for "room tile shows smiley" symptoms.
/// Returns one row per seeded room with: the DB ImageName, whether
/// the corresponding file exists in <c>data/images/</c>, and the
/// fully-qualified URL the client would fetch. Lets us tell at a
/// glance whether the bug is (a) DB never got real CDN names from
/// the backfill, (b) PNG missing on disk, or (c) wrong URL host
/// being served by the config endpoint.</summary>
[HttpGet("diagnostics/room-images")]
public async Task<IActionResult> DiagnosticsRoomImages()
{
var imgDir = Path.Combine(AppContext.BaseDirectory, "data", "images");
var rooms = await db.Rooms
.Where(r => r.Id >= 100 && r.Id < 1000)
.OrderBy(r => r.Id)
.Select(r => new { r.Id, r.Name, r.ImageName })
.ToListAsync();
var rows = rooms.Select(r =>
{
var path = Path.Combine(imgDir, r.ImageName ?? string.Empty);
return new
{
r.Id,
r.Name,
r.ImageName,
FileExists = !string.IsNullOrEmpty(r.ImageName) && System.IO.File.Exists(path),
FullPath = path,
FetchUrl = $"https://{domain.Sub("img")}/{r.ImageName}",
};
}).ToList();
return Ok(new
{
ImageDirOnDisk = imgDir,
ImageDirExists = Directory.Exists(imgDir),
ImageFileCount = Directory.Exists(imgDir) ? Directory.GetFiles(imgDir).Length : 0,
RoomImagesJsonExists = System.IO.File.Exists(
Path.Combine(AppContext.BaseDirectory, "data", "room_images.json")),
Rooms = rows,
});
}
/// <summary>GET <c>api/admin/v1/players/{id}/clothing-debug</c> —
/// inspect a single player's wardrobe state. Shows the raw
/// AvatarEntity columns the watch reads (OutfitSelections,
/// InventoryJson, HairColor, SkinColor), the parsed-and-filtered
/// list of items the player will see in their wardrobe drawer
/// (intersection of InventoryJson with the bundled safelist),
/// and any GUIDs in InventoryJson that AREN'T in the safelist
/// (those would crash the watch's parser if shipped — silently
/// dropped by AvatarItemsController.Get(), but worth knowing
/// they're sitting in the inventory).
///
/// Mostly here as a one-call sanity check after the avatar
/// pipeline overhaul: hit it against the player ID, eyeball the
/// drawer-count, confirm the ratio matches what's in the DB.</summary>
[HttpGet("players/{id:long}/clothing-debug")]
public async Task<ActionResult> ClothingDebug(long id)
{
var avatar = await db.Avatars
.Where(a => a.PlayerId == id)
.Select(a => new
{
a.OutfitSelections,
a.InventoryJson,
a.HairColor,
a.SkinColor,
a.FaceFeatures,
a.UpdatedAt,
})
.FirstOrDefaultAsync();
if (avatar is null) return NotFound(new { Message = $"No AvatarEntity for player {id}" });
var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids;
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
// Parse the player's inventory + intersect against the bundled
// safelist. Items that pass through end up in
// /api/avatar/v4/items as renderable wardrobe entries; items
// that fail are silently dropped at request time.
List<string> inventoryGuids;
try
{
inventoryGuids = System.Text.Json.JsonSerializer
.Deserialize<List<string>>(avatar.InventoryJson ?? "[]") ?? new();
}
catch { inventoryGuids = new(); }
var owned = inventoryGuids.Where(g => safe.Contains(g)).ToList();
var unsafeGuids = inventoryGuids.Where(g => !safe.Contains(g)).ToList();
// Parse OutfitSelections (the equipped loadout) the same way
// the watch does — comma-separated, each segment a GUID. Show
// which slot each one resolves to + whether it's in the safe
// catalog.
var equipped = (avatar.OutfitSelections ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(g => g.Trim())
.Where(g => g.Length > 0)
.Select(g => new
{
Guid = g,
InSafelist = safe.Contains(g),
FriendlyName = catalog.TryGetValue(g, out var c) ? c.FriendlyName : null,
})
.ToList();
return Ok(new
{
PlayerId = id,
avatar.UpdatedAt,
avatar.HairColor,
avatar.SkinColor,
avatar.FaceFeatures,
OutfitSelections = avatar.OutfitSelections,
EquippedItems = equipped,
InventoryRaw = avatar.InventoryJson,
InventoryCount = inventoryGuids.Count,
OwnedAndRenderable = owned.Count,
OwnedAndRenderableGuids = owned,
UnsafeOwnedGuids = unsafeGuids,
CatalogTotal = catalog.Count,
SafelistTotal = safe.Count,
});
}
/// <summary>GET <c>api/admin/v1/rooms</c> — flat list of every
/// room in the DB (id + name + creator + blob count) for the admin
/// UI's room-pickers. Custom (AG) rooms first, then RR Originals;
/// each ordered by name. The blob count helps the .htr mirror
/// dropdown skip rooms that have no blobs to scan.</summary>
[HttpGet("rooms")]
public async Task<ActionResult> ListAllRooms()
{
var rooms = await db.Rooms
.OrderByDescending(r => r.IsAGRoom)
.ThenBy(r => r.Name)
.Select(r => new
{
r.Id,
r.Name,
r.IsAGRoom,
r.IsDormRoom,
r.CreatorPlayerId,
BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id),
})
.ToListAsync();
return Ok(rooms);
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/visitors</c> — most
/// recent unique visitors of a room. Joins
/// <see cref="RoomVisitEntity"/> against <see cref="PlayerEntity"/>
/// so the admin UI can show "who's been here lately" with
/// usernames + per-player visit counts.</summary>
[HttpGet("rooms/{id:long}/visitors")]
public async Task<ActionResult> RoomVisitors(long id, [FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 200);
var rows = await (from v in db.RoomVisits
join p in db.Players on v.PlayerId equals p.Id
where v.RoomId == id
orderby v.LastVisitAt descending
select new
{
PlayerId = v.PlayerId,
p.Username,
p.DisplayName,
v.VisitCount,
v.FirstVisitAt,
v.LastVisitAt,
})
.Take(take)
.ToListAsync();
return Ok(rows);
}
[HttpDelete("rooms/{id:long}")]
public async Task<ActionResult> DeleteRoom(long id, [FromBody] DeleteRoomRequest? body)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
room.State = 1; // Archived
await LogAsync("delete_room", "room", id, body?.Reason ?? "");
await db.SaveChangesAsync();
return Ok(new { room.Id, room.State });
}
// The bulk "purge every custom room" endpoint was removed: a single
// wrong click could wipe every imported map in one shot. Per-room
// purge (below) is the only hard-delete path, gated by two
// independent room-name typed confirmations to make accidental
// wipes effectively impossible.
public sealed record HardDeleteRoomRequest(
string? Reason,
// The admin UI prompts for the room name TWICE in separate
// inputs — both have to match the room's actual Name (exactly,
// case-sensitive) before the request will fire. Same pattern
// GitHub uses for repository deletion.
string? ConfirmName1,
string? ConfirmName2);
/// <summary>POST <c>api/admin/v1/rooms/{id}/purge</c> — hard-delete a
/// single custom room. Refuses to act on TRUE RR Originals (owned
/// by the system seed account) or dorms; for those, the soft-delete
/// via DELETE /rooms/{id} is the only supported path so a fat
/// finger doesn't wipe canonical content.
///
/// A user-cloned room often inherits <c>IsAGRoom=true</c> from its
/// source — that doesn't make it canonical, it just means the
/// matchmaker uses the AG-room join path. Gate purely on
/// <c>CreatorPlayerId == SystemAccountId</c>, which is the only
/// reliable "is this actually a seeded RR Original?" signal.
///
/// Requires BOTH <c>ConfirmName1</c> and <c>ConfirmName2</c> in the
/// body to exactly match the room's current Name. Both must match
/// independently — the SPA renders two separate inputs and the
/// admin types the name twice. Returns 400 with
/// <c>confirm_name_mismatch</c> if either is missing/wrong so the
/// SPA can surface the error inline without leaking which one
/// failed.</summary>
[HttpPost("rooms/{id:long}/purge")]
public async Task<ActionResult> HardDeleteRoom(long id, [FromBody] HardDeleteRoomRequest? body)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
if (room.IsDormRoom)
return BadRequest(new { error = "refuses_to_hard_delete_dorm", note = "Dorms are per-player state; never purge." });
if (room.IsAGRoom && room.CreatorPlayerId == PlayerService.SystemAccountId)
return BadRequest(new { error = "refuses_to_hard_delete_seeded_room", note = "This row is owned by the system seed account — soft-archive via DELETE /rooms/{id} instead." });
var confirm1 = body?.ConfirmName1 ?? string.Empty;
var confirm2 = body?.ConfirmName2 ?? string.Empty;
if (!string.Equals(confirm1, room.Name, StringComparison.Ordinal)
|| !string.Equals(confirm2, room.Name, StringComparison.Ordinal))
{
return BadRequest(new
{
error = "confirm_name_mismatch",
note = "Both ConfirmName1 and ConfirmName2 must exactly equal the room's current Name (case-sensitive).",
});
}
var scenes = await db.RoomScenes.Where(s => s.RoomId == id).ToListAsync();
var roomBlobs = await db.RoomDataBlobs.Where(b => b.RoomId == id).ToListAsync();
RoomDataBlobEntity? imageBlob = null;
if (!string.IsNullOrEmpty(room.ImageName))
{
imageBlob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.RoomId == 0 && b.BlobName == room.ImageName);
}
db.RoomScenes.RemoveRange(scenes);
db.RoomDataBlobs.RemoveRange(roomBlobs);
if (imageBlob is not null) db.RoomDataBlobs.Remove(imageBlob);
db.Rooms.Remove(room);
await LogAsync("hard_delete_room", "room", id,
$"name={room.Name} reason={body?.Reason ?? ""} blobs={roomBlobs.Count} scenes={scenes.Count}");
await db.SaveChangesAsync();
return Ok(new
{
deleted = id,
name = room.Name,
blobs = roomBlobs.Count,
scenes = scenes.Count,
thumbnail = imageBlob is not null,
});
}
// ── Leaderboards ─────────────────────────────────────────────────────
/// <summary>Verified stat-channel metadata. The 2020 watch's
/// <c>ConditionalSetStat_ReturningNewValue</c> handles the
/// high-vs-low-is-better decision locally, so the server doesn't
/// enforce direction — this is purely for the admin SPA to render
/// channels sensibly.
///
/// Only entries cross-referenced against decompilation live here.
/// Channel 7 is verified by
/// <c>StuntRunnerManager.get_TotalTimeStatChannelForLeaderboard</c>
/// (StuntRunnerManager.txt:220-228 returns the literal 7); the
/// stored value is milliseconds, hence <c>ValueFormat = "time-ms"</c>.
/// Other channels intentionally stay un-named — they used to ship
/// with best-effort guesses ("Paintball wins", "Dodgeball wins",
/// etc.) that were almost certainly wrong, so the admin UI now
/// renders unknown channels as "Channel N" rather than misleading
/// labels. Add new entries here only when you've verified the
/// channel mapping in the decompiled client.</summary>
private static readonly Dictionary<int, StatChannelMeta> KnownStatChannels = new()
{
[7] = new("Stunt Runner total time", true, "time-ms"),
};
private sealed record StatChannelMeta(string Name, bool LowerIsBetter, string ValueFormat);
/// <summary>GET <c>api/admin/v1/leaderboards/channels</c> — summary
/// of every stat channel that has at least one row. Used by the
/// Leaderboards SPA tab to populate the channel picker.</summary>
[HttpGet("leaderboards/channels")]
public async Task<ActionResult> ListChannels()
{
var rows = await db.LeaderboardStats
.GroupBy(s => s.StatChannel)
.Select(g => new
{
Channel = g.Key,
EntryCount = g.Count(),
MinValue = g.Min(x => x.Value),
MaxValue = g.Max(x => x.Value),
})
.OrderBy(r => r.Channel)
.ToListAsync();
// DB-defined meta beats hardcoded KnownStatChannels so admins
// can rename / reassign channels live without a code change.
var dbMeta = await db.LeaderboardChannelMeta.ToDictionaryAsync(c => c.Channel);
var labelled = rows.Select(r =>
{
dbMeta.TryGetValue(r.Channel, out var live);
KnownStatChannels.TryGetValue(r.Channel, out var fallback);
return new
{
r.Channel,
Name = live?.Name is { Length: > 0 } n ? n : (fallback?.Name ?? $"Channel {r.Channel}"),
RoomId = live?.RoomId ?? 0,
LowerIsBetter = live?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false,
ValueFormat = live?.ValueFormat ?? fallback?.ValueFormat ?? "count",
Verified = live is not null || fallback is not null,
r.EntryCount,
r.MinValue,
r.MaxValue,
};
}).ToList();
return Ok(new
{
Channels = labelled,
// Surface the known set as well so the SPA can offer
// "create a row in channel X" even when the channel is
// currently empty.
Known = KnownStatChannels.Select(kv => new
{
Channel = kv.Key,
kv.Value.Name,
kv.Value.LowerIsBetter,
kv.Value.ValueFormat,
}),
});
}
/// <summary>GET <c>api/admin/v1/leaderboards/{channel}</c> — every
/// stat row in a channel with the player's username, sorted by
/// the channel's natural direction (lower-is-better channels
/// sorted ascending, everything else descending).</summary>
[HttpGet("leaderboards/{channel:int}")]
public async Task<ActionResult> ChannelDetail(int channel, [FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 1000);
var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
KnownStatChannels.TryGetValue(channel, out var fallback);
var name = liveMeta?.Name is { Length: > 0 } ln ? ln : (fallback?.Name ?? $"Channel {channel}");
var lowerIsBetter = liveMeta?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false;
var valueFormat = liveMeta?.ValueFormat ?? fallback?.ValueFormat ?? "count";
var query = db.LeaderboardStats
.Where(s => s.StatChannel == channel);
query = lowerIsBetter
? query.OrderBy(s => s.Value)
: query.OrderByDescending(s => s.Value);
var rows = await (from s in query.Take(take)
join p in db.Players on s.PlayerId equals p.Id into pj
from p in pj.DefaultIfEmpty()
select new
{
s.Id,
s.PlayerId,
Username = p == null ? null : p.Username,
DisplayName = p == null ? null : p.DisplayName,
s.Value,
s.UpdatedAt,
}).ToListAsync();
return Ok(new
{
Channel = channel,
Name = name,
RoomId = liveMeta?.RoomId ?? 0,
LowerIsBetter = lowerIsBetter,
ValueFormat = valueFormat,
Verified = liveMeta is not null || fallback is not null,
Entries = rows.Select((r, i) => new
{
Rank = i + 1,
r.Id,
r.PlayerId,
r.Username,
r.DisplayName,
r.Value,
r.UpdatedAt,
}),
});
}
public sealed record SetLeaderboardScoreRequest(long PlayerId, int Value);
/// <summary>POST <c>api/admin/v1/leaderboards/{channel}/score</c>
/// — upsert a single player's score in a channel.</summary>
[HttpPost("leaderboards/{channel:int}/score")]
public async Task<ActionResult> SetScore(int channel, [FromBody] SetLeaderboardScoreRequest body)
{
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == body.PlayerId && s.StatChannel == channel);
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = body.PlayerId,
StatChannel = channel,
Value = body.Value,
};
db.LeaderboardStats.Add(row);
}
else
{
row.Value = body.Value;
row.UpdatedAt = DateTime.UtcNow;
}
await LogAsync("set_leaderboard_score", "player", body.PlayerId, $"channel={channel} value={body.Value}");
await db.SaveChangesAsync();
return Ok(new { row.PlayerId, row.StatChannel, row.Value });
}
[HttpDelete("leaderboards/{channel:int}/score/{playerId:long}")]
public async Task<ActionResult> DeleteScore(int channel, long playerId)
{
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == playerId && s.StatChannel == channel);
if (row is null) return NotFound();
db.LeaderboardStats.Remove(row);
await LogAsync("delete_leaderboard_score", "player", playerId, $"channel={channel}");
await db.SaveChangesAsync();
return Ok(new { deleted = playerId, channel });
}
/// <summary>DELETE <c>api/admin/v1/leaderboards/{channel}</c> —
/// wipe every entry in a stat channel. Used for season resets or
/// to clear test data.</summary>
[HttpDelete("leaderboards/{channel:int}")]
public async Task<ActionResult> WipeChannel(int channel)
{
var rows = await db.LeaderboardStats.Where(s => s.StatChannel == channel).ToListAsync();
if (rows.Count == 0) return Ok(new { wiped = 0, channel });
db.LeaderboardStats.RemoveRange(rows);
await LogAsync("wipe_leaderboard_channel", "system", channel, $"entries={rows.Count}");
await db.SaveChangesAsync();
return Ok(new { wiped = rows.Count, channel });
}
// ── Rec Room Originals (RROs) ────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/rooms/originals</c> — the canonical
/// Rec Room Originals (rooms whose <c>CreatorPlayerId ==
/// PlayerService.SystemAccountId</c>). Backs the RRO admin tab
/// which lets admins flip per-room properties (cloning, max
/// players, description) without scrolling through the global
/// rooms list.</summary>
[HttpGet("rooms/originals")]
public async Task<ActionResult> ListOriginals()
{
var rooms = await db.Rooms
.Where(r => r.CreatorPlayerId == PlayerService.SystemAccountId)
.OrderBy(r => r.Name)
.Select(r => new
{
r.Id,
r.Name,
r.Description,
r.ImageName,
r.Accessibility,
r.CloningAllowed,
r.IsAGRoom,
r.IsDormRoom,
r.State,
r.TagsCsv,
r.HotScore,
r.VisitCount,
r.VisitorCount,
r.CheerCount,
r.CurrentDataBlobName,
SceneCount = db.RoomScenes.Count(s => s.RoomId == r.Id),
BlobCount = db.RoomDataBlobs.Count(b => b.RoomId == r.Id),
})
.ToListAsync();
return Ok(rooms);
}
public sealed record UpdateRoomPropsRequest(
string? Description,
bool? CloningAllowed,
int? Accessibility,
string? TagsCsv,
double? HotScore,
int? State,
string? ImageName);
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
/// update of room properties. Only the non-null fields in the
/// body get written; the rest are left untouched. Backs the
/// RRO edit form and is also usable for regular custom rooms.</summary>
[HttpPost("rooms/{id:long}/props")]
public async Task<ActionResult> UpdateRoomProps(long id, [FromBody] UpdateRoomPropsRequest body)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var changes = new List<string>();
if (body.Description is string desc)
{
room.Description = desc;
changes.Add($"description={desc.Length} chars");
}
if (body.CloningAllowed is bool c) { room.CloningAllowed = c; changes.Add($"cloning={c}"); }
if (body.Accessibility is int a) { room.Accessibility = a; changes.Add($"accessibility={a}"); }
if (body.TagsCsv is string tags) { room.TagsCsv = tags; changes.Add($"tags={tags}"); }
if (body.HotScore is double h) { room.HotScore = h; changes.Add($"hot={h}"); }
if (body.State is int st) { room.State = st; changes.Add($"state={st}"); }
if (body.ImageName is string img) { room.ImageName = img; changes.Add($"image={img}"); }
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
await LogAsync("update_room_props", "room", id, string.Join(", ", changes));
await db.SaveChangesAsync();
return Ok(new { room.Id, room.Name, applied = changes });
}
// ── Room detail (unified per-room view backing the admin SPA) ─────────
/// <summary>GET <c>api/admin/v1/rooms/{id}</c> — every column on the
/// room plus owner/co-owner/mod/host display info, blob count, scene
/// count, current data blob name, and the latest CheerCount. The
/// admin SPA's per-room detail page consumes this in one shot rather
/// than fanning out to the legacy <c>/rooms</c>, <c>/rooms/originals</c>,
/// <c>/playerReputation</c> endpoints separately.</summary>
[HttpGet("rooms/{id:long}")]
public async Task<ActionResult> GetRoomDetail(long id)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
var sceneCount = await db.RoomScenes.CountAsync(s => s.RoomId == id);
var blobCount = await db.RoomDataBlobs.CountAsync(b => b.RoomId == id);
var roles = await db.RoomRoles
.Where(r => r.RoomId == id)
.OrderBy(r => r.Role).ThenBy(r => r.GrantedAt)
.ToListAsync();
// Hydrate display names for creator + every role grant in one
// hit so the UI doesn't have to follow up with /players/{id}.
var playerIds = roles.Select(r => r.PlayerId)
.Append(room.CreatorPlayerId)
.Distinct()
.ToList();
var players = await db.Players
.Where(p => playerIds.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName })
.ToListAsync();
object PlayerInfo(long pid) =>
players.FirstOrDefault(p => p.Id == pid) is { } p
? new { id = p.Id, username = p.Username, displayName = p.DisplayName, profileImageName = p.ProfileImageName }
: new { id = pid, username = $"#{pid}", displayName = "", profileImageName = (string?)null };
return Ok(new
{
room.Id,
room.Name,
room.Description,
room.ImageName,
room.State,
room.Accessibility,
room.IsAGRoom,
room.IsDormRoom,
room.CloningAllowed,
room.SupportsLevelVoting,
room.SupportsVRLow,
room.SupportsMobile,
room.SupportsScreens,
room.SupportsWalkVR,
room.SupportsTeleportVR,
room.AllowsJuniors,
room.DisableMicAutoMute,
room.RoomWarningMask,
room.CustomRoomWarning,
room.TagsCsv,
room.CheerCount,
room.FavoriteCount,
room.VisitCount,
room.VisitorCount,
room.HotScore,
room.LocationReplicationId,
room.CurrentDataBlobName,
room.CreatedAt,
room.UpdatedAt,
sceneCount,
blobCount,
owner = PlayerInfo(room.CreatorPlayerId),
roles = roles.Select(r => new
{
r.Id,
r.PlayerId,
r.Role,
r.Accepted,
r.GrantedByPlayerId,
r.GrantedAt,
player = PlayerInfo(r.PlayerId),
}).ToList(),
});
}
public sealed record AddRoleRequest(long PlayerId, int Role, bool Accepted = true);
/// <summary>POST <c>api/admin/v1/rooms/{id}/roles</c> — grant a
/// (PlayerId, Role) pair on the room. Role: 0=CoOwner, 1=Moderator,
/// 2=Host. Idempotent — re-granting the same role flips Accepted
/// only.</summary>
[HttpPost("rooms/{id:long}/roles")]
public async Task<ActionResult> AddRoomRole(long id, [FromBody] AddRoleRequest body)
{
if (body is null) return BadRequest("missing body");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
if (body.PlayerId <= 0) return BadRequest("missing player");
if (body.Role is < 0 or > 2) return BadRequest("invalid role");
var existing = await db.RoomRoles.FirstOrDefaultAsync(r =>
r.RoomId == id && r.PlayerId == body.PlayerId && r.Role == body.Role);
if (existing is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = id,
PlayerId = body.PlayerId,
Role = body.Role,
Accepted = body.Accepted,
GrantedByPlayerId = CurrentAdminId,
});
}
else
{
existing.Accepted = body.Accepted;
}
await LogAsync("room_role_add", "room", id, $"player={body.PlayerId} role={body.Role}");
await db.SaveChangesAsync();
return Ok(new { ok = true });
}
/// <summary>DELETE <c>api/admin/v1/rooms/{id}/roles/{playerId}/{role}</c>
/// — revoke a specific role grant. No-op if the row doesn't exist.</summary>
[HttpDelete("rooms/{id:long}/roles/{playerId:long}/{role:int}")]
public async Task<ActionResult> RemoveRoomRole(long id, long playerId, int role)
{
var row = await db.RoomRoles.FirstOrDefaultAsync(r =>
r.RoomId == id && r.PlayerId == playerId && r.Role == role);
if (row is null) return Ok(new { ok = true, deleted = 0 });
db.RoomRoles.Remove(row);
await LogAsync("room_role_remove", "room", id, $"player={playerId} role={role}");
await db.SaveChangesAsync();
return Ok(new { ok = true, deleted = 1 });
}
public sealed record TransferOwnerRequest(long NewCreatorPlayerId);
/// <summary>POST <c>api/admin/v1/rooms/{id}/owner</c> — reassign the
/// room's CreatorPlayerId. The previous owner is automatically added
/// as a CoOwner so the original creator keeps a foothold on their
/// own room. Reject self-transfers.</summary>
[HttpPost("rooms/{id:long}/owner")]
public async Task<ActionResult> TransferOwner(long id, [FromBody] TransferOwnerRequest body)
{
if (body is null || body.NewCreatorPlayerId <= 0) return BadRequest("missing new owner");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id);
if (room is null) return NotFound();
if (room.CreatorPlayerId == body.NewCreatorPlayerId) return Ok(new { ok = true, unchanged = true });
var previousOwner = room.CreatorPlayerId;
room.CreatorPlayerId = body.NewCreatorPlayerId;
room.UpdatedAt = DateTime.UtcNow;
// Auto-grant the former owner CoOwner (id=0) so they don't lose
// access to a room they built. Idempotent — re-running this in
// a fast undo-redo flow won't accumulate dupes.
if (previousOwner > 0 && !await db.RoomRoles.AnyAsync(r =>
r.RoomId == id && r.PlayerId == previousOwner && r.Role == 0))
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = id,
PlayerId = previousOwner,
Role = 0,
Accepted = true,
GrantedByPlayerId = CurrentAdminId,
});
}
await LogAsync("room_transfer_owner", "room", id, $"from={previousOwner} to={body.NewCreatorPlayerId}");
await db.SaveChangesAsync();
return Ok(new { ok = true });
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/instances</c> — active
/// Photon instances for one specific room. Subset of the global
/// <c>/instances</c> endpoint pre-filtered to one room; the admin
/// SPA's room detail page embeds these so admins don't have to
/// scroll a global instance list looking for matches.
///
/// The <c>masterPlayerId</c> field is best-effort: Photon's master
/// is whoever has the lowest <c>ActorNumber</c> in the room (i.e.
/// the first to join). The server doesn't see ActorNumbers, but
/// the player with the lowest DorkNet player id among current
/// participants is a reasonable proxy on dorms (creator joined
/// first) and pretty good on user rooms. For non-dorm rooms with
/// the creator absent, the value just identifies a stable "lead"
/// participant — the room won't fall apart if it's not the actual
/// Photon master, and admins primarily use the badge to know
/// which row to click "Pull in" against to land in the right
/// shard.</summary>
[HttpGet("rooms/{id:long}/instances")]
public async Task<ActionResult> GetRoomInstances(long id)
{
var room = await db.Rooms.Where(r => r.Id == id)
.Select(r => new { r.Id, r.CreatorPlayerId, r.IsDormRoom })
.FirstOrDefaultAsync();
var onlinePids = onlinePresence.OnlinePlayerIds().ToList();
var byInstance = new Dictionary<long, (long roomInstanceId, long roomId, long subRoomId, string roomName, string photonRoomId, string photonRegionId, string location, int maxCapacity, bool isPrivate, List<long> pids)>();
foreach (var pid in onlinePids)
{
var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id) continue;
if (!byInstance.TryGetValue(r.RoomInstanceId, out var slot))
{
slot = (r.RoomInstanceId, r.RoomId, r.SubRoomId, r.Name ?? "", r.PhotonRoomId ?? "", r.PhotonRegionId ?? "", r.Location ?? "", r.MaxCapacity, r.IsPrivate, new List<long>());
}
slot.pids.Add(pid);
byInstance[r.RoomInstanceId] = slot;
}
if (byInstance.Count == 0) return Ok(Array.Empty<object>());
var allPids = byInstance.Values.SelectMany(v => v.pids).Distinct().ToList();
var players = await db.Players
.Where(p => allPids.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName })
.ToDictionaryAsync(p => p.Id);
return Ok(byInstance.Values.Select(i =>
{
// Master heuristic: dorm owner if present, else lowest pid.
long master;
if (room is { IsDormRoom: true } && i.pids.Contains(room.CreatorPlayerId))
master = room.CreatorPlayerId;
else
master = i.pids.OrderBy(p => p).FirstOrDefault();
return new
{
roomInstanceId = i.roomInstanceId,
roomId = i.roomId,
subRoomId = i.subRoomId,
roomName = i.roomName,
photonRoomId = i.photonRoomId,
photonRegionId = i.photonRegionId,
location = i.location,
maxCapacity = i.maxCapacity,
isPrivate = i.isPrivate,
masterPlayerId = master,
participants = i.pids.Select(pid => players.TryGetValue(pid, out var p)
? new { id = p.Id, username = p.Username, displayName = p.DisplayName, isMaster = pid == master }
: new { id = pid, username = $"#{pid}", displayName = "", isMaster = pid == master }).ToList(),
};
}));
}
public sealed record PullIntoInstanceRequest(long PlayerId);
/// <summary>POST <c>api/admin/v1/rooms/{id}/instances/{instanceId}/pull</c>
/// — pull a specific player into one already-active instance of this
/// room. Wraps the existing player force-join flow but pre-fills the
/// instance and validates that the target instance is actually live
/// in this room. Useful for "drop me into the shard the streamer is
/// already in" without having to dig the instance id out by hand.</summary>
[HttpPost("rooms/{id:long}/instances/{instanceId:long}/pull")]
public async Task<ActionResult> PullIntoInstance(long id, long instanceId, [FromBody] PullIntoInstanceRequest body)
{
if (body is null || body.PlayerId <= 0) return BadRequest("missing player");
// Find any participant currently in the target instance — we
// need their RoomInstanceDto to know which Photon shard to
// route the pulled player into. If nobody's online in the
// instance anymore the instance has effectively died; reject.
DorkNet.Server.Controllers.Match.RoomInstanceDto? sample = null;
foreach (var pid in onlinePresence.OnlinePlayerIds())
{
var r = playerPresence.GetRoom(pid);
if (r is not null && r.RoomId == id && r.RoomInstanceId == instanceId)
{
sample = r;
break;
}
}
if (sample is null) return NotFound(new { error = "instance_not_active" });
// Re-stamp presence on the target so their next heartbeat /
// goto sees us pointing at this instance. Push a SignalR
// SubscriptionUpdateGameSession so the watch's matchmaking
// updates LocalRoomInstance immediately.
playerPresence.SetRoom(body.PlayerId, sample);
await notifications.NotifyAsync(body.PlayerId,
PushNotificationId.SubscriptionUpdateGameSession,
sample);
await LogAsync("instance_pull", "instance", instanceId, $"player={body.PlayerId} room={id}");
await db.SaveChangesAsync();
return Ok(new { ok = true, instanceId, roomInstanceId = sample.RoomInstanceId });
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/instances/{instanceId}/close</c>
/// — kick every player currently in the instance via ModerationKick
/// pushes and clear their presence so the server-side view matches.
/// The Photon room itself isn't directly destroyed (we don't have
/// Photon admin credentials); it dies on its own once the last
/// participant disconnects. In practice every kick fires within
/// the same second, so the room is empty seconds later.</summary>
[HttpPost("rooms/{id:long}/instances/{instanceId:long}/close")]
public async Task<ActionResult> CloseInstance(long id, long instanceId, [FromBody] KickRequest? body)
{
var reason = body?.Reason ?? "Instance closed by admin";
var kicked = new List<long>();
foreach (var pid in onlinePresence.OnlinePlayerIds().ToList())
{
var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
await notifications.NotifyAsync(pid, PushNotificationId.ModerationKick, new { Reason = reason });
playerPresence.Clear(pid);
kicked.Add(pid);
}
if (kicked.Count > 0)
{
await LogAsync("instance_close", "instance", instanceId, $"room={id} kicked={kicked.Count}");
await db.SaveChangesAsync();
}
return Ok(new { ok = true, kicked });
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/leaderboards</c> — every
/// stat channel mapped to this room via
/// <see cref="LeaderboardChannelMetaEntity"/>, plus the row count
/// per channel and the top scorer. Channels that report scores but
/// haven't been mapped yet show up in
/// <see cref="LeaderboardOrphans"/> instead.</summary>
[HttpGet("rooms/{id:long}/leaderboards")]
public async Task<ActionResult> GetRoomLeaderboards(long id)
{
var metas = await db.LeaderboardChannelMeta
.Where(c => c.RoomId == id)
.OrderBy(c => c.Channel)
.ToListAsync();
if (metas.Count == 0) return Ok(Array.Empty<object>());
var channels = metas.Select(m => m.Channel).ToList();
var counts = await db.LeaderboardStats
.Where(s => channels.Contains(s.StatChannel))
.GroupBy(s => s.StatChannel)
.Select(g => new { Channel = g.Key, Count = g.Count(), Best = g.Max(x => x.Value), Worst = g.Min(x => x.Value) })
.ToListAsync();
return Ok(metas.Select(m =>
{
var stat = counts.FirstOrDefault(c => c.Channel == m.Channel);
return new
{
m.Channel,
m.Name,
m.LowerIsBetter,
m.ValueFormat,
EntryCount = stat?.Count ?? 0,
BestValue = stat is null ? (long?)null : (m.LowerIsBetter ? stat.Worst : stat.Best),
};
}));
}
public sealed record UpsertChannelMetaRequest(int Channel, long RoomId, string Name, bool LowerIsBetter, string? ValueFormat);
/// <summary>POST <c>api/admin/v1/leaderboards/meta</c> — register or
/// rename a stat channel + assign it to a room. Idempotent; updates
/// the existing row when the channel already has metadata. Does NOT
/// touch the value rows themselves — admins can register a channel
/// before any scores are reported.</summary>
[HttpPost("leaderboards/meta")]
public async Task<ActionResult> UpsertChannelMeta([FromBody] UpsertChannelMetaRequest body)
{
if (body is null) return BadRequest("missing body");
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == body.Channel);
if (row is null)
{
row = new LeaderboardChannelMetaEntity { Channel = body.Channel };
db.LeaderboardChannelMeta.Add(row);
}
row.RoomId = body.RoomId;
row.Name = body.Name ?? string.Empty;
row.LowerIsBetter = body.LowerIsBetter;
row.ValueFormat = string.IsNullOrWhiteSpace(body.ValueFormat) ? "count" : body.ValueFormat;
row.UpdatedAt = DateTime.UtcNow;
await LogAsync("leaderboard_meta_upsert", "channel", body.Channel, $"room={body.RoomId} name={row.Name}");
await db.SaveChangesAsync();
return Ok(new { row.Channel, row.RoomId, row.Name, row.LowerIsBetter, row.ValueFormat });
}
/// <summary>DELETE <c>api/admin/v1/leaderboards/meta/{channel}</c> —
/// remove the metadata row. The channel goes back to showing as
/// "Channel N" / unscoped. Existing score rows are kept.</summary>
[HttpDelete("leaderboards/meta/{channel:int}")]
public async Task<ActionResult> DeleteChannelMeta(int channel)
{
var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
if (row is null) return Ok(new { ok = true, deleted = 0 });
db.LeaderboardChannelMeta.Remove(row);
await LogAsync("leaderboard_meta_remove", "channel", channel, "");
await db.SaveChangesAsync();
return Ok(new { ok = true, deleted = 1 });
}
/// <summary>GET <c>api/admin/v1/leaderboards/orphans</c> — every
/// stat channel id that has at least one score row but no meta row.
/// Used by the admin Leaderboards UI to surface "you have data for
/// channel 11 — want to give it a name and assign it to a room?".
/// Critical for figuring out which channels Stunt Runner courses
/// actually use: load a course, drive it, then orphans surfaces
/// the channel id the game just reported.</summary>
[HttpGet("leaderboards/orphans")]
public async Task<ActionResult> LeaderboardOrphans()
{
var mapped = await db.LeaderboardChannelMeta.Select(c => c.Channel).ToListAsync();
var rows = await db.LeaderboardStats
.Where(s => !mapped.Contains(s.StatChannel))
.GroupBy(s => s.StatChannel)
.Select(g => new { Channel = g.Key, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) })
.OrderByDescending(r => r.LastSeen)
.ToListAsync();
return Ok(rows);
}
// ── Server settings (runtime toggles) ────────────────────────────────
[HttpGet("identityserver")]
public ActionResult GetIdentityServerSettings()
{
var issuer = domain.AuthIssuer.TrimEnd('/');
var client = RecRoomIdentityServerConfig.Clients[0];
var certPath = signingKeys.CertificatePath;
var fullCertPath = Path.GetFullPath(certPath);
var certificateExists = signingKeys.LoadedFromBase64 || System.IO.File.Exists(fullCertPath);
return Ok(new
{
Issuer = issuer,
DiscoveryUrl = $"{issuer}/.well-known/openid-configuration",
TokenEndpoint = $"{issuer}/connect/token",
UserInfoEndpoint = $"{issuer}/connect/userinfo",
Client = new
{
client.ClientId,
client.ClientName,
AllowedGrantTypes = client.AllowedGrantTypes.ToArray(),
AllowedScopes = client.AllowedScopes.ToArray(),
client.AllowOfflineAccess,
client.AccessTokenLifetime,
client.AbsoluteRefreshTokenLifetime,
client.SlidingRefreshTokenLifetime,
RefreshTokenUsage = client.RefreshTokenUsage.ToString(),
RefreshTokenExpiration = client.RefreshTokenExpiration.ToString(),
},
Signing = new
{
signingKeys.CertificateSource,
signingKeys.LoadedFromBase64,
CertificatePath = fullCertPath,
CertificateExists = certificateExists,
signingKeys.Certificate.Thumbprint,
NotBeforeUtc = signingKeys.Certificate.NotBefore.ToUniversalTime(),
NotAfterUtc = signingKeys.Certificate.NotAfter.ToUniversalTime(),
Algorithm = SecurityAlgorithms.RsaSha256,
},
PersistedGrants = new
{
Store = "in-memory",
Warning = "Refresh tokens are process-local until an operational store is configured.",
},
});
}
/// <summary>GET <c>api/admin/v1/settings</c> — current server-wide
/// toggle state. Single row, single round trip.</summary>
[HttpGet("settings")]
public async Task<ActionResult> GetServerSettings()
{
var row = await serverSettings.GetAsync();
return Ok(new
{
row.SignupsDisabled,
row.GlobalFriendsEnabled,
row.WeeklyChallengesCompletedRequired,
row.UpdatedAt,
});
}
public sealed record SignupsToggleRequest(bool Disabled);
public sealed record GlobalFriendsToggleRequest(bool Enabled);
/// <summary>POST <c>api/admin/v1/settings/global-friends</c> — flip the
/// "everyone is friends" toggle. No relationship rows are written; the
/// friend graph is synthesized at read time (see
/// <see cref="RelationshipQueries"/>). After flipping, broadcast
/// <c>RelationshipsInvalid</c> to every connected watch so it re-fetches
/// <c>api/relationships/v2/get</c> and shows the new state live — no
/// relog (the 2020 watch handles that push via
/// <c>Relationships.RefreshList</c>).</summary>
[HttpPost("settings/global-friends")]
public async Task<ActionResult> SetGlobalFriends([FromBody] GlobalFriendsToggleRequest body)
{
var row = await serverSettings.SetGlobalFriendsEnabledAsync(body.Enabled);
await LogAsync(body.Enabled ? "global_friends_enabled" : "global_friends_disabled", "system", 0, "");
await db.SaveChangesAsync();
// Live refresh: every connected watch re-pulls its relationship list.
await notifications.BroadcastAsync(DorkNet.Models.Notification.PushNotificationId.RelationshipsInvalid);
return Ok(new
{
row.SignupsDisabled,
row.GlobalFriendsEnabled,
row.WeeklyChallengesCompletedRequired,
row.UpdatedAt,
});
}
/// <summary>POST <c>api/admin/v1/settings/signups</c> — flip the
/// server-wide account-creation kill switch. While disabled, both
/// <c>POST /account/create</c> and <c>POST /api/account/v1/create</c>
/// reply with <c>Success=false</c> + a user-facing error string and
/// the watch surfaces that to whoever tried to sign up. Existing
/// logins are untouched.</summary>
[HttpPost("settings/signups")]
public async Task<ActionResult> SetSignupsDisabled([FromBody] SignupsToggleRequest body)
{
var row = await serverSettings.SetSignupsDisabledAsync(body.Disabled);
await LogAsync(body.Disabled ? "signups_disabled" : "signups_enabled", "system", 0, "");
await db.SaveChangesAsync();
return Ok(new
{
row.SignupsDisabled,
row.GlobalFriendsEnabled,
row.WeeklyChallengesCompletedRequired,
row.UpdatedAt,
});
}
// ── Weekly challenges ────────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/settings/weekly-challenges</c> — the
/// current weekly slate + reward the watch's challenge map is built
/// from (<see cref="ProgressionApi.ProgressionController"/>).</summary>
[HttpGet("settings/weekly-challenges")]
public async Task<ActionResult> GetWeeklyChallenges()
{
var weekly = await serverSettings.GetWeeklyChallengesAsync();
return Ok(weekly);
}
/// <summary>GET
/// <c>api/admin/v1/settings/weekly-challenges/reward-options</c> —
/// the store items that can be assigned as the weekly gift. Only
/// avatar outfits and consumables are offered: the 2020.03
/// <see cref="StoreService"/> exposes
/// <c>TryGetAvatarItemPayload</c> / <c>TryGetConsumableItemDesc</c>
/// but has no equipment payload helper, so equipment gifts aren't
/// assignable on this build. Each option carries the descriptors the
/// watch's <c>ChallengeGift</c> render needs plus the
/// <c>Slug</c> the server grants on completion.</summary>
[HttpGet("settings/weekly-challenges/reward-options")]
public async Task<ActionResult> GetWeeklyChallengeRewardOptions()
{
var rows = await db.StoreItems
.Where(i => i.IsActive)
.OrderBy(i => i.DisplayName)
.ToListAsync();
static object Option(
string kind,
StoreItemEntity item,
string avatarItemDesc = "",
string consumableItemDesc = "") => new
{
Kind = kind,
Label = $"{item.DisplayName} ({item.Slug})",
Slug = item.Slug,
GiftDropId = (int)(item.Id & 0x7fffffff),
AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = consumableItemDesc,
};
var avatarItems = rows
.Select(i => StoreService.TryGetAvatarItemPayload(i.Slug, out _, out var desc)
? Option("avatar", i, avatarItemDesc: desc)
: null)
.Where(o => o is not null)
.ToArray();
var consumables = rows
.Select(i => StoreService.TryGetConsumableItemDesc(i.Slug, out var desc)
? Option("consumable", i, consumableItemDesc: desc)
: null)
.Where(o => o is not null)
.ToArray();
return Ok(new
{
AvatarItems = avatarItems,
Consumables = consumables,
});
}
public sealed record WeeklyChallengeSettingsRequest(
bool? CompletedRequired,
List<WeeklyChallengeTemplate>? Challenges,
WeeklyChallengeReward? Reward);
/// <summary>POST <c>api/admin/v1/settings/weekly-challenges</c> —
/// replace the weekly slate + reward. Normalisation (indexing,
/// trimming, the Take(10) cap) happens in
/// <see cref="ServerSettingsService.SetWeeklyChallengesAsync"/>.</summary>
[HttpPost("settings/weekly-challenges")]
public async Task<ActionResult> SetWeeklyChallenges([FromBody] WeeklyChallengeSettingsRequest body)
{
var completedRequired = body.CompletedRequired ?? true;
var challenges = body.Challenges ?? ServerSettingsService.DefaultWeeklyChallenges();
var weekly = await serverSettings.SetWeeklyChallengesAsync(completedRequired, challenges, body.Reward);
await LogAsync("weekly_challenges_updated", "system", 0, "");
await db.SaveChangesAsync();
return Ok(weekly);
}
// ── Signup codes ─────────────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
/// with its status (unused / redeemed-by-whom / revoked / expired)
/// for the admin invite tracker.</summary>
[HttpGet("signup-codes")]
public async Task<ActionResult> GetSignupCodes([FromQuery] int take = 200)
{
var codes = await signupCodes.ListAsync(take);
var redeemerIds = codes.Where(c => c.RedeemedByPlayerId is not null)
.Select(c => c.RedeemedByPlayerId!.Value).Distinct().ToList();
var redeemers = redeemerIds.Count == 0
? new Dictionary<long, string>()
: await db.Players.Where(p => redeemerIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id, p => p.Username);
var now = DateTime.UtcNow;
return Ok(codes.Select(c => new
{
c.Id,
c.Code,
c.Descriptor,
c.CreatedAt,
c.ExpiresAt,
c.Revoked,
c.RedeemedAt,
c.RedeemedByPlayerId,
RedeemedByUsername = c.RedeemedByPlayerId is { } rid && redeemers.TryGetValue(rid, out var u) ? u : null,
Status = c.RedeemedByPlayerId is not null ? "redeemed"
: c.Revoked ? "revoked"
: c.ExpiresAt is { } e && e <= now ? "expired"
: "active",
}));
}
public sealed record GenerateSignupCodeRequest(string? Descriptor, DateTime? ExpiresAt);
/// <summary>POST <c>api/admin/v1/signup-codes</c> — mint a new
/// single-use code with an optional descriptor + expiry.</summary>
[HttpPost("signup-codes")]
public async Task<ActionResult> GenerateSignupCode([FromBody] GenerateSignupCodeRequest body)
{
var expires = body.ExpiresAt is { } e ? DateTime.SpecifyKind(e, DateTimeKind.Utc) : (DateTime?)null;
var code = await signupCodes.GenerateAsync(body.Descriptor ?? string.Empty, expires, CurrentAdminId);
await LogAsync("signup_code_created", "signup_code", code.Id, code.Descriptor);
await db.SaveChangesAsync();
return Ok(new { code.Id, code.Code, code.Descriptor, code.CreatedAt, code.ExpiresAt });
}
/// <summary>POST <c>api/admin/v1/signup-codes/{id}/revoke</c> — kill an
/// unused code. Already-redeemed codes can't be revoked.</summary>
[HttpPost("signup-codes/{id:long}/revoke")]
public async Task<ActionResult> RevokeSignupCode(long id)
{
var ok = await signupCodes.RevokeAsync(id);
if (!ok) return BadRequest(new { Error = "cannot_revoke" });
await LogAsync("signup_code_revoked", "signup_code", id, "");
await db.SaveChangesAsync();
return Ok(new { Revoked = true });
}
// ── Audit log ────────────────────────────────────────────────────────
[HttpGet("audit")]
public async Task<ActionResult> GetAuditLog([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 500);
var rows = await db.AdminActions
.OrderByDescending(a => a.Timestamp)
.Take(take)
.ToListAsync();
return Ok(rows);
}
// ── Reports moderation ───────────────────────────────────────────────
/// <summary>List unresolved player reports, oldest-first.
/// Admins triage from this queue.</summary>
[HttpGet("reports")]
public async Task<ActionResult> ListReports([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.Reports
.Where(r => r.ResolvedAt == null)
.OrderBy(r => r.CreatedAt)
.Take(take)
.ToListAsync();
return Ok(rows);
}
public sealed record ResolveReportRequest(string? Note);
/// <summary>Mark a report as resolved with an optional note.
/// The actual moderation action (ban, warn, dismiss) is the
/// admin's call; this endpoint only closes the report row.</summary>
[HttpPost("reports/{id:long}/resolve")]
public async Task<ActionResult> ResolveReport(long id, [FromBody] ResolveReportRequest? body)
{
var rep = await db.Reports.FirstOrDefaultAsync(r => r.Id == id);
if (rep is null) return NotFound();
if (rep.ResolvedAt is not null) return Ok(new { already_resolved = true });
rep.ResolvedAt = DateTime.UtcNow;
rep.ResolverAdminId = CurrentAdminId;
rep.ResolutionNote = (body?.Note ?? string.Empty).Trim();
await LogAsync("resolve_report", "report", id, rep.ResolutionNote);
await db.SaveChangesAsync();
return Ok(new { rep.Id, rep.ResolvedAt });
}
// ── Kick / IP ban ────────────────────────────────────────────────────
public sealed record KickRequest(string? Reason);
/// <summary>Force-disconnect a player from the SignalR hub and
/// signal them to reload via a ModerationKick push. Doesn't ban
/// — the player can immediately reconnect — but boots them out
/// of whatever room/session they're in. For temporary
/// moderation actions where a full ban is too heavy.</summary>
[HttpPost("players/{id:long}/kick")]
public async Task<ActionResult> KickPlayer(long id, [FromBody] KickRequest? body)
{
await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
new { Reason = body?.Reason ?? "" });
await LogAsync("kick_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync();
return Ok(new { kicked = id });
}
public sealed record IpBanRequest(string Cidr, string? Reason, int? DurationDays);
/// <summary>Add an IP-level ban. Optional duration in days
/// (null = permanent until removed). Format: dotted IPv4 or
/// CIDR like <c>1.2.3.0/24</c>.</summary>
[HttpPost("ipbans")]
public async Task<ActionResult> AddIpBan([FromBody] IpBanRequest body)
{
if (string.IsNullOrWhiteSpace(body.Cidr))
return BadRequest("missing cidr");
var until = body.DurationDays is int days
? DateTime.UtcNow.AddDays(Math.Clamp(days, 1, 3650))
: (DateTime?)null;
var entry = new IpBanEntity
{
Cidr = body.Cidr.Trim(),
Reason = (body.Reason ?? string.Empty).Trim(),
BannedByAdminId = CurrentAdminId,
Until = until,
};
db.IpBans.Add(entry);
await LogAsync("ipban_add", "ip", 0, $"{body.Cidr} until {until:o}");
await db.SaveChangesAsync();
return Ok(entry);
}
[HttpDelete("ipbans/{id:long}")]
public async Task<ActionResult> RemoveIpBan(long id)
{
var ban = await db.IpBans.FirstOrDefaultAsync(b => b.Id == id);
if (ban is null) return NotFound();
db.IpBans.Remove(ban);
await LogAsync("ipban_remove", "ip", id, ban.Cidr);
await db.SaveChangesAsync();
return Ok();
}
[HttpGet("ipbans")]
public async Task<ActionResult> ListIpBans()
{
var rows = await db.IpBans
.OrderByDescending(b => b.BannedAt)
.ToListAsync();
return Ok(rows);
}
// ── Stats ────────────────────────────────────────────────────────────
/// <summary>Real-time server snapshot for the admin dashboard.
/// Every aggregate is one cheap query — designed to be hit by
/// the admin watch tab on a 5-second timer without melting the
/// SQLite write path.</summary>
[HttpGet("stats")]
public async Task<ActionResult> Stats()
{
var totalPlayers = await db.Players.CountAsync();
var totalRooms = await db.Rooms.CountAsync();
var totalInventions = await db.Inventions.CountAsync();
var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null);
var bannedNow = await db.Players.CountAsync(p =>
p.BannedUntil != null && p.BannedUntil > DateTime.UtcNow);
var activeIpBans = await db.IpBans.CountAsync(b =>
b.Until == null || b.Until > DateTime.UtcNow);
var online = onlinePresence.OnlinePlayerIds().ToArray();
var topRooms = await db.Rooms
.OrderByDescending(r => r.VisitCount)
.Take(10)
.Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount })
.ToListAsync();
var recentJoins = await db.Players
.OrderByDescending(p => p.CreatedAt)
.Take(10)
.Select(p => new { p.Id, p.Username, p.CreatedAt })
.ToListAsync();
return Ok(new
{
Players = new
{
Total = totalPlayers,
OnlineNow = online.Length,
BannedNow = bannedNow,
},
Rooms = new { Total = totalRooms, TopByVisits = topRooms },
Inventions = totalInventions,
Moderation = new
{
OpenReports = openReports,
ActiveIpBans = activeIpBans,
},
RecentJoins = recentJoins,
ServerTime = DateTime.UtcNow,
});
}
// ── Avatar / outfit / inventory grants ──────────────────────────────
public sealed record OutfitRequest(string? OutfitSelections, string? FaceFeatures, string? HairColor, string? SkinColor);
/// <summary>Get a player's current avatar so the admin UI can
/// pre-fill its edit form. Returns the same DTO shape as
/// <c>GET api/avatar/v2/equipped/{id}</c>.</summary>
[HttpGet("players/{id:long}/avatar")]
public async Task<ActionResult> GetAvatar(long id)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null) return NotFound();
return Ok(new
{
avatar.PlayerId,
avatar.OutfitSelections,
avatar.FaceFeatures,
avatar.HairColor,
avatar.SkinColor,
avatar.UpdatedAt,
});
}
/// <summary>POST <c>api/admin/v1/players/{id}/avatar/reset</c> —
/// wipe a player's equipped outfit + inventory back to the canonical
/// 2020 starter values. Recovery path for the
/// "watch crashes after ~10 minutes" symptom where a player has an
/// item GUID (e.g. <c>Eye_MirrorShades_PinkClear</c>) whose
/// outfits_assets bundle isn't shipped with this build, so
/// <see cref="OutfitManager"/>.SpawnOutfitImposter throws a
/// Dependency Exception from Addressables and the engine eventually
/// tears down the local player. Resetting clears those entries
/// from <see cref="AvatarEntity.OutfitSelections"/> and
/// <see cref="AvatarEntity.InventoryJson"/> so the next avatar
/// load only references items the local build has on disk.</summary>
[HttpPost("players/{id:long}/avatar/reset")]
public async Task<ActionResult> ResetAvatar(long id)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = id };
db.Avatars.Add(avatar);
}
avatar.OutfitSelections = PlayerService.StarterOutfitSelections;
avatar.InventoryJson = PlayerService.StarterInventoryJson;
avatar.HairColor = PlayerService.StarterHairColor;
avatar.SkinColor = PlayerService.StarterSkinColor;
avatar.UpdatedAt = DateTime.UtcNow;
await LogAsync("reset_avatar", "player", id, "");
await db.SaveChangesAsync();
// Push a profile update so the affected watch re-fetches its
// avatar without waiting for the next scene load.
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminAvatarReset" });
return Ok(new
{
avatar.PlayerId,
avatar.OutfitSelections,
avatar.HairColor,
avatar.SkinColor,
});
}
/// <summary>Replace another player's outfit / hair / skin colour.
/// Pushes <c>SubscriptionUpdateProfile</c> so the affected watch
/// re-fetches its avatar and the new clothes propagate to other
/// players in the same room.</summary>
[HttpPost("players/{id:long}/avatar")]
public async Task<ActionResult> SetAvatar(long id, [FromBody] OutfitRequest body)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = id };
db.Avatars.Add(avatar);
}
if (body.OutfitSelections is not null) avatar.OutfitSelections = body.OutfitSelections;
if (body.FaceFeatures is not null) avatar.FaceFeatures = body.FaceFeatures;
if (body.HairColor is not null) avatar.HairColor = body.HairColor;
if (body.SkinColor is not null) avatar.SkinColor = body.SkinColor;
avatar.UpdatedAt = DateTime.UtcNow;
await LogAsync("set_avatar", "player", id, body.OutfitSelections ?? "");
await db.SaveChangesAsync();
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminAvatarUpdate" });
return Ok(new { avatar.PlayerId, avatar.OutfitSelections, avatar.HairColor, avatar.SkinColor });
}
public sealed record GrantItemRequest(string ItemId, int Quantity);
/// <summary>Append an item GUID to the player's inventory JSON list.
/// The client treats <c>InventoryJson</c> as the truth source for
/// what they own; once granted, the next avatar fetch shows the
/// item available in the customisation menu.</summary>
[HttpPost("players/{id:long}/inventory/grant")]
public async Task<ActionResult> GrantItem(long id, [FromBody] GrantItemRequest body)
{
if (string.IsNullOrWhiteSpace(body.ItemId)) return BadRequest("missing itemId");
var qty = Math.Clamp(body.Quantity, 1, 1000);
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = id };
db.Avatars.Add(avatar);
}
// Inventory wire format: JSON array of { itemId, quantity }. Merge
// by itemId so repeat grants don't duplicate rows.
var list = ParseInventory(avatar.InventoryJson);
var existing = list.FirstOrDefault(e => e.ItemId == body.ItemId);
if (existing is null)
list.Add(new InventoryEntry { ItemId = body.ItemId, Quantity = qty });
else
existing.Quantity += qty;
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await LogAsync("grant_item", "player", id, $"{body.ItemId} x{qty}");
await db.SaveChangesAsync();
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "InventoryGrant", body.ItemId, Quantity = qty });
return Ok(new { granted = body.ItemId, quantity = qty });
}
public sealed record CurrencyRequest(int CurrencyType, long Amount, string? Reason);
/// <summary>Adjust a player's currency wallet. Positive amounts
/// grant; negative deduct (clamped to zero by
/// <see cref="LevelService.GrantCurrencyAsync"/>). The client's
/// next storefronts/balance fetch returns the new value.</summary>
[HttpPost("players/{id:long}/currency")]
public async Task<ActionResult> GrantCurrency(long id, [FromBody] CurrencyRequest body)
{
var newBalance = await level.GrantCurrencyAsync(
id, body.CurrencyType, body.Amount, body.Reason ?? "admin_grant");
await LogAsync("grant_currency", "player", id,
$"type={body.CurrencyType} amount={body.Amount}");
await db.SaveChangesAsync();
return Ok(new { id, body.CurrencyType, balance = newBalance });
}
public sealed record XpRequest(int Amount, string? Reason);
/// <summary>Grant XP to a player. Triggers level-up rewards via
/// <see cref="LevelService.AwardXpAsync"/> if the new XP crosses a
/// level threshold.</summary>
[HttpPost("players/{id:long}/xp")]
public async Task<ActionResult> GrantXp(long id, [FromBody] XpRequest body)
{
var (lvl, xp) = await level.AwardXpAsync(id, body.Amount, body.Reason ?? "admin_grant");
await LogAsync("grant_xp", "player", id, $"amount={body.Amount}");
await db.SaveChangesAsync();
return Ok(new { id, level = lvl, xp });
}
public sealed record ProfileFlagsRequest(
bool? IsVerified,
bool? IsDeveloper,
bool? IsJunior,
bool? IsCommunityTeam);
/// <summary>Toggle the verified / developer / community-team /
/// junior flags on a player profile. Verified shows up as the
/// blue-checkmark badge on the watch's profile card; IsDeveloper
/// and IsCommunityTeam both unlock the watch's overhead-badge
/// slider (see <see cref="DorkNet.Server.Controllers.API.Role.RoleController.IsDeveloper"/>)
/// — once unlocked, the player picks "Community Team" or
/// "Developer" themselves from the in-watch settings menu and the
/// label renders above their head for every other player in the
/// room. We expose both as separate admin flags so the audit log
/// preserves the distinction even though the 2020 watch can't
/// enforce one badge over the other.</summary>
[HttpPost("players/{id:long}/flags")]
public async Task<ActionResult> SetFlags(long id, [FromBody] ProfileFlagsRequest body)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
if (body.IsVerified is bool v) p.IsVerified = v;
if (body.IsDeveloper is bool d) p.IsDeveloper = d;
if (body.IsJunior is bool j) p.IsJunior = j;
if (body.IsCommunityTeam is bool ct) p.IsCommunityTeam = ct;
await LogAsync("set_flags", "player", id,
$"verified={p.IsVerified} dev={p.IsDeveloper} communityteam={p.IsCommunityTeam} junior={p.IsJunior}");
await db.SaveChangesAsync();
return Ok(new { p.Id, p.IsVerified, p.IsDeveloper, p.IsCommunityTeam, p.IsJunior });
}
public sealed record DisplayNameRequest(string DisplayName);
/// <summary>Force-set a player's display name (admin override of
/// the normal username-uniqueness flow).</summary>
[HttpPost("players/{id:long}/displayName")]
public async Task<ActionResult> SetDisplayName(long id, [FromBody] DisplayNameRequest body)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
if (string.IsNullOrWhiteSpace(body.DisplayName)) return BadRequest("empty");
p.DisplayName = body.DisplayName.Trim();
await LogAsync("set_display_name", "player", id, p.DisplayName);
await db.SaveChangesAsync();
return Ok(new { p.Id, p.DisplayName });
}
public sealed record UsernameRequest(string Username);
/// <summary>Force-set a player's username (the unique @handle).
/// Unlike the normal account flow this changes ONLY the username, not
/// the display name, and rejects a collision outright rather than
/// auto-suffixing — the admin types the exact handle they want.
/// Username rules mirror the account API: 2–24 chars, letters /
/// digits / underscore / hyphen.</summary>
[HttpPost("players/{id:long}/username")]
public async Task<ActionResult> SetUsername(long id, [FromBody] UsernameRequest body)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
var name = (body.Username ?? string.Empty).Trim();
if (name.Length is < 2 or > 24
|| !name.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-'))
return BadRequest(new { Error = "Username must be 2–24 chars: letters, numbers, _ or -." });
if (await db.Players.AnyAsync(x => x.Username == name && x.Id != id))
return BadRequest(new { Error = "That username is already taken." });
var previous = p.Username;
p.Username = name;
await LogAsync("set_username", "player", id, $"{previous} -> {name}");
await db.SaveChangesAsync();
return Ok(new { p.Id, p.Username });
}
private sealed class InventoryEntry
{
[System.Text.Json.Serialization.JsonPropertyName("itemId")]
public string ItemId { get; set; } = string.Empty;
[System.Text.Json.Serialization.JsonPropertyName("quantity")]
public int Quantity { get; set; }
}
private static List<InventoryEntry> ParseInventory(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<InventoryEntry>>(json) ?? new();
}
catch
{
return new();
}
}
// ── Avatar catalog (gifting picker source) ───────────────────────────
/// <summary>One giftable avatar item for the Gift picker. <c>Desc</c> is
/// the exact RecNet item string to gift — a bare base GUID for the
/// default colour, or a <c>{guid},{swatch},{mask},</c> string for a
/// colour variant. <c>Slot</c> is the real OutfitType (head/torso/…),
/// not the always-Outfit AvatarItemType.</summary>
private sealed record GiftItemRow(
string Desc, string Guid, int Slot, string FriendlyName, string? Color, bool IsVariant);
/// <summary>GET <c>api/admin/v1/avatar-items</c> — every giftable avatar
/// item: each base item (default colour) PLUS every colour variant the
/// store seeds from <c>data/color_variants.json</c>. Backs the Gift
/// page's item picker so admins can browse by friendly name + colour
/// instead of pasting descs by hand. Read-only. Base catalog source is
/// <c>data/avatar_item_lookup.json</c>, parsed once at startup by
/// <see cref="DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController"/>;
/// variants come from <see cref="DorkNet.Server.Services.StoreService.ColorVariantCatalog"/>.</summary>
[HttpGet("avatar-items")]
public ActionResult ListAvatarItems()
{
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids;
var rows = new List<GiftItemRow>(catalog.Count + DorkNet.Server.Services.StoreService.ColorVariantCatalog.Count);
// Base items (default colour). Real slot from the bundled lookup —
// the DTO's AvatarItemType is always Outfit, which is useless as a
// slot filter, so resolve the actual OutfitType per GUID.
foreach (var kv in catalog)
{
if (!safe.Contains(kv.Key)) continue; // GUIDs the watch can't render
var slot = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController
.TryGetOutfitType(kv.Key, out var s) ? s : 0;
rows.Add(new GiftItemRow(kv.Key, kv.Key, slot, kv.Value.FriendlyName, null, false));
}
// Colour variants — the "dozens of headphones" the admin sees in the
// store but not here. Each gifts as "{item},{swatch},{mask}," so the
// recipient gets the exact swatch, not the default colour.
foreach (var v in DorkNet.Server.Services.StoreService.ColorVariantCatalog.Values)
{
if (string.IsNullOrEmpty(v.item_guid) || string.IsNullOrEmpty(v.swatch_guid)) continue;
if (!safe.Contains(v.item_guid)) continue; // base must be renderable
var baseName = catalog.TryGetValue(v.item_guid, out var bd) && !string.IsNullOrEmpty(bd.FriendlyName)
? bd.FriendlyName
: v.item_name;
var desc = $"{v.item_guid},{v.swatch_guid},{v.mask_guid},";
rows.Add(new GiftItemRow(desc, v.item_guid,
v.item_outfit_type ?? 0, $"{baseName} — {v.color}", v.color, true));
}
return Ok(rows
.OrderBy(r => r.Slot)
.ThenBy(r => r.FriendlyName, StringComparer.OrdinalIgnoreCase)
.ToList());
}
// ── Send gift package (in-game gift box popup) ────────────────────────
public sealed record SendGiftRequest(
string? AvatarItemGuid,
int? AvatarItemType, // 0 = Outfit, 1 = HairDye (per RecNet.Avatars+AvatarItemType)
int? CurrencyType, // 1 = Tokens, 2 = Coins (0 = none)
int? Currency,
int? Xp,
int? Level,
string? Message,
int? Rarity); // 0 Common, 10 Uncommon, 20 Rare, 30 Epic, 50 Legendary
/// <summary>POST <c>api/admin/v1/players/{id}/gift</c> — drop a
/// <see cref="GiftPackageEntity"/> into the recipient's gift inbox.
/// The watch's <c>RecNet.Avatars.DowloadGiftPackages</c> loop picks
/// it up on the next poll and shows the standard gift-box popup;
/// the player taps "open" to fire <c>LocalConsumeGiftPackage</c>,
/// which hits <see cref="DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ConsumeViaBody"/>
/// and finally writes the avatar item into <c>InventoryJson</c>.
/// We DO NOT touch InventoryJson here — that bypasses the gift
/// flow and the player never sees the popup.
///
/// Also pushes a <see cref="PushNotificationId.GiftPackageReceived"/>
/// over SignalR so an online player sees the toast immediately
/// without waiting for the next /gifts poll.</summary>
[HttpPost("players/{id:long}/gift")]
public async Task<ActionResult> SendGift(long id, [FromBody] SendGiftRequest body)
{
var recipient = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (recipient is null) return NotFound();
var hasItem = !string.IsNullOrWhiteSpace(body.AvatarItemGuid);
var hasCurrency = body.CurrencyType is int ct && ct > 0 && body.Currency is int c && c > 0;
var hasXp = body.Xp is int xp && xp > 0;
var hasLevel = body.Level is int lvl && lvl > 0;
if (!hasItem && !hasCurrency && !hasXp && !hasLevel)
return BadRequest(new { error = "empty_gift", message = "Provide at least one of: avatar item, currency, XP, level." });
// Wire shape MUST match the working /api/avatar/v2/gifts/generate
// flow exactly. The watch's ParseGiftPackageReceived
// (Avatars.txt:616) gates on IsValid + SupportsCurrentPlatform,
// then runs the gift through GiftManager. Two deviations from
// /generate were breaking us:
// 1. FromPlayerId set to the admin's id — the watch's P2P-gift
// rendering path looks up sender info; if that lookup
// doesn't return what the popup renderer expects, the toast
// still fires from the SignalR push but the box never
// materializes. /generate uses null; we match.
// 2. IsGifted = true tagged it as a player-to-player gift,
// which routes through a different code path entirely
// (separate inbox / different popup). /generate leaves it
// at the default false; we match.
// CRITICAL invariants for AvatarItemDescOrHairDyeDesc, both
// verified against the in-game crash trace:
//
// 1. AvatarItemType MUST be null when no avatar item is in the
// gift. Setting it to 0 (Outfit) with an empty desc routes
// GiftManager.DequeueGift past the HasAvatarItemOrHairDye
// gate into OutfitManager.MarkGiftPackageItemAvailableOnCurrentPlatform("")
// → AvatarItem.FromRecNetString("") → "empty RecNet string"
// AvatarParseException.
//
// 2. When there IS an item, AvatarItemDescOrHairDyeDesc must
// have at least 4 comma-separated parts. The client's
// FromRecNetString(string) splits on `,` then constructs
// ArraySegment(parts, 1, 3) — parts.Length < 4 triggers
// ArgumentException at ArraySegmentEnumerator.get_Current
// (output_log.txt:1567). Bare GUIDs crash; we suffix `,,,`
// for three empty default customisations. See
// AvatarItemsController.cs:31-40 for the canonical comment.
var gift = new GiftPackageEntity
{
RecipientPlayerId = id,
FromPlayerId = null,
AvatarItemType = hasItem ? (body.AvatarItemType ?? 0) : (int?)null,
AvatarItemDescOrHairDyeDesc = hasItem
? NormalizeAvatarItemDesc(body.AvatarItemGuid!.Trim())
: string.Empty,
CurrencyType = hasCurrency ? body.CurrencyType!.Value : 0,
Currency = hasCurrency ? body.Currency!.Value : 0,
Xp = hasXp ? body.Xp!.Value : 0,
Level = hasLevel ? body.Level!.Value : 0,
GiftContext = 0,
GiftRarity = body.Rarity ?? 0,
Message = string.IsNullOrWhiteSpace(body.Message)
? "A gift from the admins."
: body.Message.Trim(),
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await LogAsync("send_gift", "player", id,
$"item={(hasItem ? gift.AvatarItemDescOrHairDyeDesc : "-")} " +
$"currency={(hasCurrency ? $"{gift.CurrencyType}×{gift.Currency}" : "-")} " +
$"xp={gift.Xp} level={gift.Level}");
await db.SaveChangesAsync();
// GiftPackageReceivedImmediate (31), NOT GiftPackageReceived (30).
// GiftManager.OnGiftPackageReceivedEvent (GiftManager.txt:501) takes
// a `showImmediately` bool — true for id 31, false for id 30. When
// false the handler returns *without* calling ReceiveGifts, so no
// box pops. Admin gifts want the box now, not "next time you open
// the gift inbox", so push 31. The watch's RecNet.Avatars handler
// registers BOTH ids (Avatars.txt:177 + 192), and 31 is the only
// one that ends up invoking the GiftManager popup path.
await notifications.NotifyAsync(id,
PushNotificationId.GiftPackageReceivedImmediate,
new
{
gift.Id,
gift.FromPlayerId,
gift.AvatarItemType,
AvatarItemDesc = gift.AvatarItemDescOrHairDyeDesc,
gift.AvatarItemDescOrHairDyeDesc,
gift.EquipmentPrefabName,
gift.EquipmentModificationGuid,
gift.CurrencyType,
gift.Currency,
gift.Xp,
gift.Level,
gift.GiftContext,
gift.GiftRarity,
gift.Message,
gift.Platform,
gift.PackageMaterial,
gift.PackageVariant,
gift.Consumed,
gift.IsValid,
gift.ErrorMessage,
gift.SupportsCurrentPlatform,
gift.IsGifted,
});
return Ok(new { gift.Id, gift.RecipientPlayerId });
}
/// <summary>Pad a bare avatar item GUID with three empty default
/// customisations so the watch's <c>AvatarItem.FromRecNetString</c>
/// doesn't crash on <c>ArraySegment(parts, 1, 3)</c> when the
/// string only contains the GUID. Idempotent — descs that already
/// have at least 4 comma-separated parts are returned untouched.</summary>
private static string NormalizeAvatarItemDesc(string descOrGuid)
{
if (string.IsNullOrWhiteSpace(descOrGuid)) return string.Empty;
var commaCount = 0;
foreach (var ch in descOrGuid) if (ch == ',') commaCount++;
return commaCount >= 3 ? descOrGuid : descOrGuid + new string(',', 3 - commaCount);
}
// ── Pending gift inbox (clean-up) ────────────────────────────────────
/// <summary>GET <c>api/admin/v1/players/{id}/gifts</c> — list the
/// recipient's pending (unconsumed) gift packages. Used by the admin
/// Gift page to surface broken / abandoned gifts that the watch
/// can't render so we can delete them. Includes consumed ones too
/// in case we want to audit what's been opened.</summary>
[HttpGet("players/{id:long}/gifts")]
public async Task<ActionResult> ListPlayerGifts(long id, [FromQuery] bool includeConsumed = false)
{
IQueryable<GiftPackageEntity> q = db.GiftPackages.Where(g => g.RecipientPlayerId == id);
if (!includeConsumed) q = q.Where(g => !g.Consumed);
var rows = await q
.OrderByDescending(g => g.CreatedAt)
.Take(200)
.Select(g => new
{
g.Id,
g.RecipientPlayerId,
g.FromPlayerId,
g.AvatarItemType,
g.AvatarItemDescOrHairDyeDesc,
g.CurrencyType,
g.Currency,
g.Xp,
g.Level,
g.GiftContext,
g.GiftRarity,
g.Message,
g.Consumed,
g.ConsumedAt,
g.IsValid,
g.IsGifted,
g.CreatedAt,
})
.ToListAsync();
return Ok(rows);
}
/// <summary>DELETE <c>api/admin/v1/gifts/{id}</c> — hard-delete a
/// gift package row. Use when a gift is broken (wrong shape, won't
/// render its box in the watch) or was a mis-fire from a test send.
/// Does not affect the player's actual inventory if they'd already
/// consumed it — that write happened during consume and is final.</summary>
[HttpDelete("gifts/{id:long}")]
public async Task<ActionResult> DeleteGift(long id)
{
var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id);
if (gift is null) return NotFound();
var recipient = gift.RecipientPlayerId;
db.GiftPackages.Remove(gift);
await LogAsync("delete_gift", "gift", id, $"recipient={recipient} consumed={gift.Consumed}");
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
/// <summary>POST <c>api/admin/v1/players/{id}/gifts/clear</c> —
/// nuke every unconsumed gift for a player. Useful after a burst of
/// broken admin-test gifts to reset the inbox to a clean state.
/// Consumed gifts are left alone (they're history, not state).</summary>
[HttpPost("players/{id:long}/gifts/clear")]
public async Task<ActionResult> ClearPlayerGifts(long id)
{
var pending = await db.GiftPackages
.Where(g => g.RecipientPlayerId == id && !g.Consumed)
.ToListAsync();
db.GiftPackages.RemoveRange(pending);
await LogAsync("clear_gifts", "player", id, $"removed={pending.Count}");
await db.SaveChangesAsync();
return Ok(new { removed = pending.Count });
}
// ── Password reset (admin override) ──────────────────────────────────
public sealed record SetPasswordRequest(string NewPassword);
/// <summary>Admin-set a new password for any account. Hashes with
/// BCrypt and persists onto <see cref="PlayerEntity.PasswordHash"/>.
/// The audit row records the action but never the plaintext.
/// Refuses passwords shorter than 8 chars — same floor as the
/// self-service flow.</summary>
[HttpPost("players/{id:long}/password")]
public async Task<ActionResult> SetPassword(long id, [FromBody] SetPasswordRequest body)
{
if (string.IsNullOrWhiteSpace(body.NewPassword) || body.NewPassword.Length < 8)
return BadRequest(new { error = "password_too_short", min = 8 });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(body.NewPassword);
await LogAsync("set_password", "player", id, "");
await db.SaveChangesAsync();
// Push a logout so any active sessions on the old password get
// dropped — the watch re-authenticates with the new password.
await notifications.NotifyAsync(id, PushNotificationId.Logout,
new { Reason = "AdminPasswordReset" });
return Ok(new { id });
}
// ── Live instance viewer ─────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/instances</c> — list every active room
/// instance an online player is currently sitting in. Joins the live
/// online-player set from <see cref="OnlinePresenceService"/> against
/// <see cref="PlayerPresenceService"/> (which holds the last-known
/// <see cref="RoomInstanceDto"/> per player, TTL'd to 45 seconds) and
/// groups by <c>RoomInstanceId</c>. Empty list when no one is online.</summary>
[HttpGet("instances")]
public async Task<ActionResult> ListInstances()
{
var onlineIds = onlinePresence.OnlinePlayerIds().ToHashSet();
if (onlineIds.Count == 0) return Ok(Array.Empty<object>());
// Bulk-pull names so we can render usernames per participant.
var nameMap = await db.Players
.Where(p => onlineIds.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName })
.ToDictionaryAsync(p => p.Id, p => new { p.Username, p.DisplayName });
var roomNameMap = await db.Rooms
.Select(r => new { r.Id, r.Name })
.ToDictionaryAsync(r => r.Id, r => r.Name);
var grouped = new Dictionary<long, InstanceGroup>();
foreach (var pid in onlineIds)
{
var room = playerPresence.GetRoom(pid);
if (room is null) continue;
if (!grouped.TryGetValue(room.RoomInstanceId, out var g))
{
g = new InstanceGroup
{
RoomInstanceId = room.RoomInstanceId,
RoomId = room.RoomId,
SubRoomId = room.SubRoomId,
RoomName = roomNameMap.GetValueOrDefault(room.RoomId, $"#{room.RoomId}"),
PhotonRoomId = room.PhotonRoomId,
PhotonRegionId = room.PhotonRegionId,
Location = room.Location,
MaxCapacity = room.MaxCapacity,
IsPrivate = room.IsPrivate,
Participants = new(),
};
grouped[room.RoomInstanceId] = g;
}
nameMap.TryGetValue(pid, out var nm);
g.Participants.Add(new InstanceParticipant
{
Id = pid,
Username = nm?.Username ?? $"#{pid}",
DisplayName = nm?.DisplayName ?? $"#{pid}",
});
}
return Ok(grouped.Values
.OrderByDescending(g => g.Participants.Count)
.ThenBy(g => g.RoomName));
}
private sealed class InstanceGroup
{
public long RoomInstanceId { get; set; }
public long RoomId { get; set; }
public long SubRoomId { get; set; }
public string RoomName { get; set; } = "";
public string PhotonRoomId { get; set; } = "";
public string PhotonRegionId { get; set; } = "";
public string Location { get; set; } = "";
public int MaxCapacity { get; set; }
public bool IsPrivate { get; set; }
public List<InstanceParticipant> Participants { get; set; } = new();
}
private sealed class InstanceParticipant
{
public long Id { get; set; }
public string Username { get; set; } = "";
public string DisplayName { get; set; } = "";
}
// ── Force-join (experimental) ────────────────────────────────────────
/// <summary>
/// Force-join body. <c>RoomInstanceId</c> + <c>PhotonRoomId</c> are
/// optional — when omitted, the watch lands in a fresh matchmade
/// instance of the room. When provided (from the Instances browser
/// → "send into this match" flow), we target that exact Photon room
/// so the admin can drop a player into a specific public or private
/// instance other players are already in.
/// </summary>
public sealed record ForceJoinRequest(
long RoomId,
long? SubRoomId,
long? RoomInstanceId,
string? PhotonRoomId,
string? PhotonRegionId);
/// <summary>POST <c>api/admin/v1/players/{id}/forcejoin</c> — rewrite
/// the target player's <see cref="PlayerPresenceService"/> entry to
/// the destination room (and optionally a specific Photon instance),
/// then push <c>SubscriptionUpdateGameSession</c> + <c>SubscriptionUpdateRoom</c>
/// so the watch's cached RoomInstance + RoomDetails match the new
/// server-side state. The watch's
/// <c>OnPresenceHeartbeatResponse</c> detects the change on its
/// next heartbeat and refreshes its cache; pair with
/// <c>POST players/{id}/kick</c> to also drop the player out of
/// their current Photon match so they re-matchmake into the new
/// target on reconnect.
///
/// When <c>RoomInstanceId</c> + <c>PhotonRoomId</c> are supplied,
/// the player lands in that EXACT instance (public or private). Use
/// the Instances browser to enumerate available Photon rooms and
/// pick one. With both fields omitted, a fresh deterministic
/// instance id is used so the player matchmakes normally.</summary>
[HttpPost("players/{id:long}/forcejoin")]
public async Task<ActionResult> ForceJoin(long id, [FromBody] ForceJoinRequest body)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == body.RoomId);
if (room is null) return NotFound(new { error = "room_not_found" });
// Build the destination RoomInstance. When the admin picked a
// specific instance from the Instances browser, RoomInstanceId
// + PhotonRoomId arrive together — use them verbatim so the
// player lands in THAT match, not a fresh one. Otherwise mint
// deterministic defaults that match what /goto/room would
// produce for a first join (roomId*100k + subId base; bare
// ^roomname_id photon room name).
var subRoomId = body.SubRoomId ?? 0;
var photonRoomId = !string.IsNullOrEmpty(body.PhotonRoomId)
? body.PhotonRoomId
: $"^{room.Name.ToLowerInvariant()}_{room.Id}";
var roomInstanceId = body.RoomInstanceId
?? (room.Id * 100_000L + subRoomId);
var photonRegionId = !string.IsNullOrEmpty(body.PhotonRegionId)
? body.PhotonRegionId
: "us";
var roomInstance = new DorkNet.Server.Controllers.Match.RoomInstanceDto
{
RoomInstanceId = roomInstanceId,
RoomId = room.Id,
SubRoomId = subRoomId,
Location = room.LocationReplicationId,
PhotonRegionId = photonRegionId,
PhotonRoomId = photonRoomId,
Name = room.Name,
MaxCapacity = 8,
IsFull = false,
IsPrivate = false,
IsInProgress = true,
};
playerPresence.SetRoom(id, roomInstance);
// Two pushes back-to-back: GameSession update changes the
// watch's cached RoomInstance (OnSubscriptionUpdateRoomInstance
// → UpdateCachedRoomInstance), and SubscriptionUpdateRoom
// refreshes the watch's RoomDetails cache so the room-info
// panel doesn't show stale data after the warp.
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateGameSession, roomInstance);
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateRoom,
new
{
Room = new { Id = room.Id, Name = room.Name },
RoomId = room.Id,
SubRoomId = subRoomId,
Reason = "AdminForceJoin",
});
await LogAsync("force_join", "player", id,
$"room={body.RoomId} sub={subRoomId} instance={roomInstanceId} photon={photonRoomId}");
await db.SaveChangesAsync();
return Ok(new
{
id,
roomId = body.RoomId,
subRoomId,
roomInstanceId,
photonRoomId,
});
}
// ── System ───────────────────────────────────────────────────────────
public sealed record SetMotdRequest(string Message);
/// <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)
{
if (string.IsNullOrWhiteSpace(body.Message)) return BadRequest("empty message");
await notifications.BroadcastAsync(PushNotificationId.ServerMaintenance,
new { Message = body.Message });
await LogAsync("broadcast", "system", 0, body.Message);
await db.SaveChangesAsync();
return Ok();
}
// ── Store catalog management ─────────────────────────────────────────
/// <summary>Canonical storefront keys the admin UI can assign to
/// store items. Exact <c>giftdrop:N</c> keys target a single
/// in-room shelf; <c>rro</c> and <c>all</c> are shared groups.</summary>
[HttpGet("storefronts")]
public ActionResult ListStorefronts() =>
Ok(StoreService.GetStorefrontDefinitions().Select(s => new
{
s.Key,
s.StorefrontType,
s.DisplayName,
s.Scope,
}));
/// <summary>List every item in the catalog (including inactive
/// rows) so the admin UI can render the full catalog and let an
/// admin flip flags / edit prices.</summary>
[HttpGet("storeitems")]
public async Task<ActionResult> ListStoreItems(
[FromQuery] string? storefront, [FromQuery] string? category,
[FromQuery] int take = 200, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 500);
skip = Math.Max(0, skip);
IQueryable<StoreItemEntity> q = db.StoreItems;
if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront);
if (!string.IsNullOrWhiteSpace(category)) q = q.Where(i => i.Category == category);
var rows = await q.OrderBy(i => i.Storefront).ThenBy(i => i.Category).ThenBy(i => i.Price)
.Skip(skip).Take(take).ToListAsync();
return Ok(rows);
}
public sealed record StoreItemUpsertRequest(
string? Slug, string? DisplayName, string? Description,
string? Category, string? ImageName, int? CurrencyType,
long? Price, bool? IsActive, bool? IsLimitedTime,
DateTime? AvailableUntil, string? Storefront);
/// <summary>Create a new catalog item.</summary>
[HttpPost("storeitems")]
public async Task<ActionResult> CreateStoreItem([FromBody] StoreItemUpsertRequest body)
{
if (string.IsNullOrWhiteSpace(body.Slug)) return BadRequest("missing_slug");
if (string.IsNullOrWhiteSpace(body.DisplayName)) return BadRequest("missing_display_name");
var existing = await db.StoreItems.AnyAsync(i => i.Slug == body.Slug);
if (existing) return Conflict(new { error = "slug_taken" });
var item = new StoreItemEntity
{
Slug = body.Slug.Trim(),
DisplayName = body.DisplayName.Trim(),
Description = (body.Description ?? string.Empty).Trim(),
Category = (body.Category ?? "accessory").Trim().ToLowerInvariant(),
ImageName = body.ImageName ?? string.Empty,
CurrencyType = body.CurrencyType ?? 2,
Price = Math.Max(0, body.Price ?? 0),
IsActive = body.IsActive ?? true,
IsLimitedTime = body.IsLimitedTime ?? false,
AvailableUntil = body.AvailableUntil,
Storefront = (body.Storefront ?? "main").Trim(),
};
db.StoreItems.Add(item);
await LogAsync("create_store_item", "storeitem", 0, item.Slug);
await db.SaveChangesAsync();
return Ok(item);
}
/// <summary>Patch an existing catalog item — any null field is
/// left untouched so the admin UI can do partial edits.</summary>
[HttpPost("storeitems/{id:long}")]
[HttpPut("storeitems/{id:long}")]
public async Task<ActionResult> UpdateStoreItem(long id, [FromBody] StoreItemUpsertRequest body)
{
var item = await db.StoreItems.FirstOrDefaultAsync(i => i.Id == id);
if (item is null) return NotFound();
if (body.DisplayName is not null) item.DisplayName = body.DisplayName.Trim();
if (body.Description is not null) item.Description = body.Description.Trim();
if (body.Category is not null) item.Category = body.Category.Trim().ToLowerInvariant();
if (body.ImageName is not null) item.ImageName = body.ImageName;
if (body.CurrencyType is int ct) item.CurrencyType = ct;
if (body.Price is long p) item.Price = Math.Max(0, p);
if (body.IsActive is bool a) item.IsActive = a;
if (body.IsLimitedTime is bool lt) item.IsLimitedTime = lt;
if (body.AvailableUntil.HasValue) item.AvailableUntil = body.AvailableUntil;
if (body.Storefront is not null) item.Storefront = body.Storefront.Trim();
item.UpdatedAt = DateTime.UtcNow;
await LogAsync("update_store_item", "storeitem", id, item.Slug);
await db.SaveChangesAsync();
return Ok(item);
}
/// <summary>Delete a catalog item. Soft-flag is recommended via
/// IsActive=false; hard delete is here for cleanup of test
/// items.</summary>
[HttpDelete("storeitems/{id:long}")]
public async Task<ActionResult> DeleteStoreItem(long id)
{
var item = await db.StoreItems.FirstOrDefaultAsync(i => i.Id == id);
if (item is null) return NotFound();
db.StoreItems.Remove(item);
await LogAsync("delete_store_item", "storeitem", id, item.Slug);
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
// ── Helpers ──────────────────────────────────────────────────────────
private Task LogAsync(string action, string targetType, long targetId, string reason)
{
db.AdminActions.Add(new AdminActionEntity
{
AdminPlayerId = CurrentAdminId,
Action = action,
TargetType = targetType,
TargetId = targetId,
Reason = reason,
});
return Task.CompletedTask;
}
// ── Community Board ──────────────────────────────────────────────
// Backs the dorm community board panel. The watch hits
// /api/communityboard/v1/current; admins edit here.
/// <summary>GET the current board state. Same shape the watch
/// receives — handy for the admin UI to populate its form.</summary>
[HttpGet("communityboard")]
public async Task<ActionResult<CommunityBoardState>> GetCommunityBoard()
=> Ok(await communityBoard.GetAsync());
/// <summary>POST a full replacement state. Any null nested objects
/// (FeaturedPlayer / FeaturedRoomGroup / CurrentAnnouncement)
/// clear that section. Lists default to empty if omitted.</summary>
[HttpPost("communityboard")]
public async Task<ActionResult<CommunityBoardState>> SetCommunityBoard(
[FromBody] CommunityBoardState body)
{
body.InstagramImages ??= new();
body.Videos ??= new();
var saved = await communityBoard.UpdateAsync(body);
await LogAsync("set_community_board", "global", 0,
$"announcement=\"{saved.CurrentAnnouncement?.Message ?? ""}\" " +
$"featuredPlayer={saved.FeaturedPlayer?.Id} " +
$"videos={saved.Videos.Count} instagram={saved.InstagramImages.Count}");
return Ok(saved);
}
/// <summary>POST <c>api/admin/v1/communityboard/instagram/upload</c>
/// — accept a single image file, persist it as a global
/// <see cref="RoomDataBlobEntity"/> at <c>RoomId=0</c> under a
/// generated hash filename, return the filename + a ready-to-paste
/// img.* URL. The Community Board SPA section pipes a file picker
/// into this so admins don't have to know what an
/// <c>ImageName</c> is — they just drop a JPG/PNG and we wire it up.
///
/// Cap: 25 MB. Anything larger is almost certainly a mistake for an
/// Instagram-strip tile and would also push us close to the CF edge
/// limit on a non-chunked upload.</summary>
[HttpPost("communityboard/instagram/upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
public async Task<ActionResult> UploadInstagramImage([FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
return BadRequest(new { error = "unsupported_extension", allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" } });
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
// Hashed filename so re-uploads of the same content don't
// proliferate and so the URL is content-addressed. Prefix
// `cb_insta_` to keep these distinguishable from room-image
// blobs at a glance when grepping the table.
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var imageName = $"cb_insta_{hash[..24]}{ext}";
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName);
if (existing is null)
{
var (bucket, key) = BlobRouter.Route(imageName);
await storage.PutAsync(bucket, key, bytes,
ext == ".png" ? "image/png" : "image/jpeg");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = imageName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("cb_insta_upload", "community_board", 0, $"image={imageName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
// Use the configured apex (DomainConfig) so the returned URL
// works whether the admin is on localhost, rec.net, or a future
// deployment apex — driven solely by the DORKNET_DOMAIN env var.
return Ok(new
{
imageName,
imageUrl = $"https://{domain.Sub("img")}/{imageName}",
bytes = bytes.Length,
reused = existing is not null,
});
}
/// <summary>POST <c>api/admin/v1/communityboard/video/upload</c> —
/// accept a single video file and persist it as a global
/// <see cref="RoomDataBlobEntity"/> at <c>RoomId=0</c> under a hashed
/// <c>cb_video_*</c> name. The board's Videos section binds the
/// returned <c>blobName</c> straight into <c>VideoState.BlobName</c>
/// so the watch's <c>CommunityBoardVideoData</c> resolves it to
/// <c>cdn.{apex}/&lt;blobName&gt;</c>.
///
/// Cap: 100 MB — picks the same ceiling Cloudflare enforces at the
/// edge so a non-chunked POST never sees a 413. Anything bigger
/// should be hosted externally and pasted into <c>SourceUrl</c>.</summary>
// [RequestSizeLimit] alone is enough — pairing it with
// [DisableRequestSizeLimit] was undefined-behaviour territory
// (Microsoft docs explicitly say "don't combine them"). Setting
// 100 MB at the action keeps Kestrel from clipping the body at its
// 30 MB default while still capping uploads under Cloudflare's
// free-tier edge limit.
[HttpPost("communityboard/video/upload")]
[RequestFormLimits(MultipartBodyLengthLimit = 100_000_000, ValueLengthLimit = 100_000_000)]
[RequestSizeLimit(100_000_000)]
public async Task<ActionResult> UploadCommunityBoardVideo([FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".mp4" or ".webm" or ".mov" or ".m4v"))
return BadRequest(new {
error = "unsupported_extension",
allowed = new[] { ".mp4", ".webm", ".mov", ".m4v" },
});
var declaredSize = file.Length;
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
// Catch the "Kestrel/Cloudflare clipped the body" case BEFORE
// we hash + write — IFormFile.Length reports what the multipart
// parser saw in the part header, so a mismatch with how many
// bytes the stream actually produced is a transport-level
// truncation and the resulting blob would be the corrupted file
// the user saw on download.
if (bytes.LongLength != declaredSize)
{
adminLogger.LogError(
"[cb-video-upload] size mismatch — multipart says {Declared:N0} bytes, stream produced {Read:N0} bytes (file={Name})",
declaredSize, bytes.LongLength, file.FileName);
return BadRequest(new {
error = "upload_truncated",
declaredBytes = declaredSize,
receivedBytes = bytes.LongLength,
});
}
// Content-addressed name keeps re-uploads from proliferating —
// identical bytes resolve to the same row + S3 object regardless
// of original filename. `cb_video_` prefix keeps it greppable.
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var blobName = $"cb_video_{hash[..24]}{ext}";
var (bucket, key) = BlobRouter.Route(blobName);
// We were previously skipping S3 PUT when the DB row already
// existed. That created a zombie state: if the first upload's
// PUT silently failed (Garage outage, bad creds) the row was
// committed but the bytes weren't, and every subsequent
// upload of the same content returned `reused: true` without
// ever fixing the missing object. Verify S3 has the object;
// re-PUT if it doesn't.
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName);
var alreadyOnS3 = existing is not null && await storage.ExistsAsync(bucket, key);
if (!alreadyOnS3)
{
await storage.PutAsync(bucket, key, bytes, MimeFromExt(ext));
// Post-PUT HEAD: Garage occasionally accepts a PutObject
// and returns 200 without persisting (auth misconfig,
// bucket policy reject). HeadObject is the cheapest way to
// confirm the bytes are actually queryable before we
// pretend the upload succeeded.
if (!await storage.ExistsAsync(bucket, key))
{
adminLogger.LogError(
"[cb-video-upload] PUT returned OK but HEAD says {Bucket}/{Key} missing — Garage may be silently dropping",
bucket, key);
return StatusCode(500, new { error = "s3_put_lost", bucket, key });
}
adminLogger.LogInformation(
"[cb-video-upload] stored {Bucket}/{Key} ({Bytes:N0} bytes, ext={Ext})",
bucket, key, bytes.LongLength, ext);
}
if (existing is null)
{
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("cb_video_upload", "community_board", 0, $"video={blobName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
return Ok(new
{
blobName,
videoUrl = $"https://{domain.Sub("cdn")}/video/{blobName}",
bytes = bytes.Length,
reused = existing is not null && alreadyOnS3,
repaired = existing is not null && !alreadyOnS3,
});
}
/// <summary>POST <c>api/admin/v1/communityboard/videothumb/upload</c>
/// — accept a single image and persist it under a hashed
/// <c>cb_thumb_*</c> name for use as <see cref="VideoState"/>'s
/// <c>ThumbnailBlobName</c>. Mechanically identical to the Instagram
/// upload endpoint, just with a different prefix so thumbnail vs.
/// gallery images are distinguishable at a glance in the blob table.
///
/// Cap: 25 MB. The watch renders thumbnails at small sizes; bigger
/// is wasted bandwidth.</summary>
[HttpPost("communityboard/videothumb/upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
public async Task<ActionResult> UploadCommunityBoardVideoThumbnail(
[FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
return BadRequest(new {
error = "unsupported_extension",
allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" },
});
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var blobName = $"cb_thumb_{hash[..24]}{ext}";
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName);
if (existing is null)
{
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(bucket, key, bytes,
ext == ".png" ? "image/png" : ext == ".webp" ? "image/webp" : "image/jpeg");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("cb_videothumb_upload", "community_board", 0, $"thumb={blobName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
return Ok(new
{
blobName,
imageUrl = $"https://{domain.Sub("img")}/{blobName}",
bytes = bytes.Length,
reused = existing is not null,
});
}
// MIME mapping for the video upload — the CDN serves whatever
// Content-Type we wrote to S3, so getting this right makes the
// watch's HTTPVideoStreamPlayer accept the response without
// sniffing. Default to mp4 since that's what most modern exporters
// produce; unknown extensions fall back to octet-stream which the
// watch usually still handles but is worth flagging.
private static string MimeFromExt(string ext) => ext switch
{
".mp4" => "video/mp4",
".m4v" => "video/mp4",
".webm" => "video/webm",
".mov" => "video/quicktime",
_ => "application/octet-stream",
};
// ── Loading screen tips ──────────────────────────────────────────────
public sealed class LoadingScreenTipDto
{
public long? Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string ImageName { get; set; } = string.Empty;
public int Context { get; set; }
public int PlatformMask { get; set; } = -1;
public string RoomNamesCsv { get; set; } = string.Empty;
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>GET <c>api/admin/v1/loadingscreentips</c> — list every
/// tip (including inactive ones) for the admin SPA.</summary>
[HttpGet("loadingscreentips")]
public async Task<IActionResult> ListLoadingScreenTips()
{
var rows = await db.LoadingScreenTips
.OrderBy(t => t.SortOrder).ThenBy(t => t.Id)
.AsNoTracking()
.ToListAsync();
return Ok(rows.Select(ToLoadingScreenTipDto));
}
/// <summary>POST <c>api/admin/v1/loadingscreentips</c> — create
/// a new tip. Returns the persisted row including its assigned
/// Id so the SPA can update its local state.</summary>
[HttpPost("loadingscreentips")]
public async Task<IActionResult> CreateLoadingScreenTip([FromBody] LoadingScreenTipDto body)
{
var entity = new Data.Entities.LoadingScreenTipEntity
{
Title = (body.Title ?? string.Empty).Trim(),
Message = (body.Message ?? string.Empty).Trim(),
ImageName = (body.ImageName ?? string.Empty).Trim(),
Context = body.Context,
PlatformMask = body.PlatformMask,
RoomNamesCsv = NormaliseCsv(body.RoomNamesCsv),
SortOrder = body.SortOrder,
IsActive = body.IsActive,
};
db.LoadingScreenTips.Add(entity);
await db.SaveChangesAsync();
await LogAsync("create_loading_tip", "global", entity.Id, entity.Title);
return Ok(ToLoadingScreenTipDto(entity));
}
/// <summary>PUT <c>api/admin/v1/loadingscreentips/{id}</c> — update
/// an existing tip.</summary>
[HttpPut("loadingscreentips/{id:long}")]
public async Task<IActionResult> UpdateLoadingScreenTip(long id, [FromBody] LoadingScreenTipDto body)
{
var row = await db.LoadingScreenTips.FirstOrDefaultAsync(t => t.Id == id);
if (row is null) return NotFound();
row.Title = (body.Title ?? string.Empty).Trim();
row.Message = (body.Message ?? string.Empty).Trim();
row.ImageName = (body.ImageName ?? string.Empty).Trim();
row.Context = body.Context;
row.PlatformMask = body.PlatformMask;
row.RoomNamesCsv = NormaliseCsv(body.RoomNamesCsv);
row.SortOrder = body.SortOrder;
row.IsActive = body.IsActive;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await LogAsync("update_loading_tip", "global", id, row.Title);
return Ok(ToLoadingScreenTipDto(row));
}
/// <summary>DELETE <c>api/admin/v1/loadingscreentips/{id}</c> — hard
/// delete a tip. (Toggling <c>IsActive=false</c> via the update
/// endpoint is the soft-delete path.)</summary>
[HttpDelete("loadingscreentips/{id:long}")]
public async Task<IActionResult> DeleteLoadingScreenTip(long id)
{
var row = await db.LoadingScreenTips.FirstOrDefaultAsync(t => t.Id == id);
if (row is null) return NotFound();
db.LoadingScreenTips.Remove(row);
await db.SaveChangesAsync();
await LogAsync("delete_loading_tip", "global", id, row.Title);
return Ok(new { deleted = id });
}
/// <summary>POST <c>api/admin/v1/loadingscreentips/upload</c> —
/// accept an image file (jpg/png/webp), persist it via the same
/// content-addressed flow as the Community Board uploader, and
/// return the BlobName + img.* URL. The SPA pipes this into the
/// tip's <c>ImageName</c> field.</summary>
[HttpPost("loadingscreentips/upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
public async Task<IActionResult> UploadLoadingScreenTipImage([FromForm(Name = "file")] IFormFile? file)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "missing_file" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".jpg" or ".jpeg" or ".png" or ".webp"))
return BadRequest(new { error = "unsupported_extension", allowed = new[] { ".jpg", ".jpeg", ".png", ".webp" } });
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var bytes = ms.ToArray();
using var sha = System.Security.Cryptography.SHA1.Create();
var hash = Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
var imageName = $"loadingtip_{hash[..24]}{ext}";
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName);
if (existing is null)
{
var (bucket, key) = BlobRouter.Route(imageName);
await storage.PutAsync(bucket, key, bytes,
ext == ".png" ? "image/png" : "image/jpeg");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = imageName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("loading_tip_upload", "global", 0, $"image={imageName} bytes={bytes.Length}");
await db.SaveChangesAsync();
}
return Ok(new
{
imageName,
imageUrl = $"https://{domain.Sub("img")}/{imageName}",
bytes = bytes.Length,
reused = existing is not null,
});
}
private static LoadingScreenTipDto ToLoadingScreenTipDto(Data.Entities.LoadingScreenTipEntity t) => new()
{
Id = t.Id,
Title = t.Title,
Message = t.Message,
ImageName = t.ImageName,
Context = t.Context,
PlatformMask = t.PlatformMask,
RoomNamesCsv = t.RoomNamesCsv,
SortOrder = t.SortOrder,
IsActive = t.IsActive,
};
private static string NormaliseCsv(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
var parts = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return string.Join(",", parts);
}
// ── Room import ──────────────────────────────────────────────────────
/// <summary>POST <c>api/admin/v1/rooms/import</c> — multipart upload
/// of an archived multi-scene room. Each .room file (one per scene)
/// is uploaded with its filename including the scene folder, e.g.
/// <c>Lobby/abc.room</c> or <c>Ch1_Headfirst/def.room</c>. The form
/// can either send the per-file folder via <c>webkitRelativePath</c>
/// (browser folder picker preserves it) or via the filename itself.
///
/// Server inserts:
/// 1. A <see cref="RoomEntity"/> row for the new room (id 1000+).
/// 2. One <see cref="RoomDataBlobEntity"/> per uploaded file,
/// named <c>{roomname}_{scenefolder}.room</c> (lowercased).
/// 3. One <see cref="RoomSceneEntity"/> per scene with the entry
/// scene at OrderIndex=0 and the rest in folder order.
///
/// Default <c>RoomSceneLocationId</c> for every scene is the
/// MakerRoom (Basement) GUID — the canonical 2020 custom-room
/// canvas. If the import was a 2020-era export of an apartment
/// scene, edit <see cref="RoomSceneEntity.RoomSceneLocationId"/>
/// per-row after import.
///
/// Returns 400 if a room with this name already exists or no
/// files were uploaded.</summary>
[HttpPost("rooms/import")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 500_000_000)]
public async Task<IActionResult> ImportRoom(
[FromForm(Name = "name")] string? name,
[FromForm(Name = "description")] string? description,
[FromForm(Name = "entryScene")] string? entryScene,
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
[FromForm(Name = "scenePaths")] List<string>? scenePaths,
[FromForm(Name = "files")] List<IFormFile>? files,
[FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs)
{
// Per-import "run blob bytes through the 2020 protobuf normaliser"
// toggle, mirroring RoomZipImportController. Default OFF — see
// that file's notes for why the round-trip currently crashes the
// watch on load. Diagnostic Normalize() call still runs so its
// parse-OK / parse-FAIL log line remains in the import telemetry.
var normalize = normalizeBlobs ?? false;
if (string.IsNullOrWhiteSpace(name))
return BadRequest(new { error = "name_required" });
if (files is null || files.Count == 0)
return BadRequest(new { error = "no_files" });
var roomName = name.Trim();
if (await db.Rooms.AnyAsync(r => r.Name == roomName))
return BadRequest(new { error = "duplicate_name", message = $"A room named '{roomName}' already exists." });
// Pair each uploaded file with its scene folder name. Browsers
// submit folder uploads as files with FileName = "Lobby/blob.room"
// when webkitdirectory is used; fall back to a parallel
// scenePaths[] form field when the client supplies it explicitly
// (e.g. uploading individual files via JS FormData with the
// scene name set per entry).
//
// Each blob is also passed through RoomBlobNormalizerService
// (round-trip via the full 2020 PersistedRoomData schema). That
// strips redundant default-value encodings the modern client
// emits — without this, the watch's stricter 2020 parser logs
// "Error attempting to parse room data stream" and boots the
// player to dorm. Normalize() falls back to the original bytes
// if parse fails, so a corrupted upload doesn't drop content.
var perScene = new List<(string SceneName, string OriginalName, byte[] Bytes, RoomBlobNormalizerService.Result Norm)>();
for (int i = 0; i < files.Count; i++)
{
var f = files[i];
// FormFile.FileName is "scenefolder/blob.room" when uploaded
// via webkitdirectory. ScenePaths (if provided) overrides.
var sceneName = scenePaths is { } sp && i < sp.Count && !string.IsNullOrWhiteSpace(sp[i])
? sp[i].Trim()
: ExtractSceneFolder(f.FileName);
if (string.IsNullOrWhiteSpace(sceneName))
{
return BadRequest(new {
error = "missing_scene_folder",
message = $"File '{f.FileName}' has no scene folder. Either upload via folder picker, or supply scenePaths[] alongside files[]."
});
}
using var ms = new MemoryStream();
await f.CopyToAsync(ms);
var raw = ms.ToArray();
var norm = roomBlobNormalizer.Normalize(raw);
var persistedBytes = normalize ? norm.Bytes : raw;
adminLogger.LogInformation(
"[import] scene={Scene} file={File} raw={Raw:N0} → normalized={Out:N0} ({Status}) persistedFrom={Source}",
sceneName, f.FileName, raw.Length, norm.Bytes.Length,
norm.Normalized ? "ok" : $"fail: {norm.Error}",
normalize ? "normaliser" : "raw");
perScene.Add((sceneName, f.FileName, persistedBytes, norm));
}
// Pick entry scene: explicit override → "Lobby" if present →
// alphabetically-first folder.
var sceneNames = perScene.Select(s => s.SceneName).ToList();
var entry = !string.IsNullOrWhiteSpace(entryScene)
? perScene.FirstOrDefault(s => string.Equals(s.SceneName, entryScene, StringComparison.OrdinalIgnoreCase))
: default;
if (entry == default)
{
entry = perScene.FirstOrDefault(s => string.Equals(s.SceneName, "Lobby", StringComparison.OrdinalIgnoreCase));
}
if (entry == default) entry = perScene.OrderBy(s => s.SceneName).First();
// Allocate a new id in the user-room range (>=1000).
var newRoomId = (await db.Rooms
.Where(r => r.Id >= 1000)
.Select(r => (long?)r.Id)
.MaxAsync() ?? 999) + 1;
var creator = creatorPlayerId ?? CurrentAdminId;
const string makerRoomLocation = "a75f7547-79eb-47c6-8986-6767abcb4f92";
// Build BlobName per scene.
var slug = roomName.ToLowerInvariant().Replace(' ', '_');
string MakeBlobName(string sceneName) => $"{slug}_{sceneName.ToLowerInvariant()}.room";
// Reorder so entry scene is OrderIndex=0.
var ordered = new List<(string SceneName, string OriginalName, byte[] Bytes, RoomBlobNormalizerService.Result Norm)> { entry };
ordered.AddRange(perScene.Where(s => s.SceneName != entry.SceneName));
using var tx = await db.Database.BeginTransactionAsync();
try
{
// Insert blobs — bytes go to S3, DB only stores metadata.
foreach (var s in ordered)
{
var blobName = MakeBlobName(s.SceneName);
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(bucket, key, s.Bytes, "application/octet-stream");
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = newRoomId,
BlobName = blobName,
UploadedByPlayerId = creator,
UploadedAt = DateTime.UtcNow,
});
}
// Insert Room row pointing at entry scene's blob.
var entryBlob = MakeBlobName(entry.SceneName);
db.Rooms.Add(new RoomEntity
{
Id = newRoomId,
Name = roomName,
Description = string.IsNullOrWhiteSpace(description)
? $"Imported via admin upload ({ordered.Count} scenes)"
: description.Trim(),
CreatorPlayerId = creator,
ImageName = string.Empty,
State = 0,
Accessibility = 1,
IsAGRoom = false,
IsDormRoom = false,
CloningAllowed = true,
LocationReplicationId = makerRoomLocation,
TagsCsv = "community",
CheerCount = 0,
FavoriteCount = 0,
VisitCount = 0,
VisitorCount = 0,
HotScore = 5.0,
CurrentDataBlobName = entryBlob,
});
// Insert RoomScenes rows.
int idx = 0;
foreach (var s in ordered)
{
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = newRoomId,
OrderIndex = idx++,
Name = s.SceneName,
RoomSceneLocationId = makerRoomLocation,
DataBlobName = MakeBlobName(s.SceneName),
MaxPlayers = 8,
IsSandbox = false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
});
}
await db.SaveChangesAsync();
await tx.CommitAsync();
}
catch
{
await tx.RollbackAsync();
throw;
}
await LogAsync("import_room", "room", newRoomId,
$"name={roomName} scenes={ordered.Count} entry={entry.SceneName}");
// Fire-and-forget: scan the just-imported scene blobs for
// Holotar/AudioSampler .htr references and download each from
// cdn.rec.net into RoomDataBlobs (BlobName="{hash}.htr",
// RoomId=0). The watch's holotar projectors and audio
// samplers can then pull their content from cdn.localhost
// instead of failing on the public CDN. Doing this in the
// background keeps the upload response snappy (the .htr
// download set for a YarrHarrHeist-sized room is ~25 MB).
htrMirror.EnqueueAsync(
ordered.Select(s => s.Bytes).ToList(),
contextLabel: $"room={roomName}");
return Ok(new
{
roomId = newRoomId,
roomName,
sceneCount = ordered.Count,
entryScene = entry.SceneName,
normalizedSceneCount = ordered.Count(s => s.Norm.Normalized),
normalizationFailures = ordered.Where(s => !s.Norm.Normalized)
.Select(s => new { scene = s.SceneName, error = s.Norm.Error }),
htrMirrorStarted = true,
scenes = ordered.Select((s, i) => new
{
orderIndex = i,
name = s.SceneName,
blobName = MakeBlobName(s.SceneName),
rawBytes = s.Norm.InputBytes,
bytes = s.Bytes.Length,
normalized = s.Norm.Normalized,
}),
});
}
/// <summary>POST <c>api/admin/v1/rooms/{id}/mirror-htr</c> —
/// re-run the HoloTar / AudioSampler .htr mirror for an existing
/// room's blobs. Useful to (a) backfill assets for rooms imported
/// before the auto-mirror existed, (b) debug whether
/// HtrAssetMirrorService can actually extract refs from the C#
/// generated 2020 PersistedRoomData schema. Synchronous (awaits the
/// download stream) so the response carries the result counts —
/// the import-time path uses fire-and-forget instead.</summary>
[HttpPost("rooms/{roomId:long}/mirror-htr")]
public async Task<IActionResult> MirrorHtrForRoom(long roomId)
{
// Stream bytes back from S3 — DB carries only metadata now.
var blobNames = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId)
.Select(b => b.BlobName)
.ToListAsync();
if (blobNames.Count == 0) return NotFound(new { error = "no_blobs", roomId });
var blobs = new List<byte[]>(blobNames.Count);
foreach (var blobName in blobNames)
{
var (bucket, key) = BlobRouter.Route(blobName);
var bytes = await storage.GetAsync(bucket, key);
if (bytes is { Length: > 0 }) blobs.Add(bytes);
}
if (blobs.Count == 0) return NotFound(new { error = "blobs_missing_in_s3", roomId, expected = blobNames.Count });
var result = await htrMirror.MirrorAsync(blobs, contextLabel: $"manual room={roomId}");
await LogAsync("mirror_htr", "room", roomId,
$"refs={result.TotalRefs} skipped={result.AlreadyMirrored} inserted={result.Inserted} parseFails={result.RoomBlobParseFailures}");
return Ok(new
{
roomId,
scannedBlobs = blobs.Count,
uniqueRefs = result.TotalRefs,
alreadyMirrored = result.AlreadyMirrored,
downloaded = result.Inserted,
roomParseFailures = result.RoomBlobParseFailures,
assetDownloadFailures = result.AssetDownloadFailures,
});
}
/// <summary>Pull the immediate-parent folder out of a multi-segment
/// filename like "Lobby/abc.room" or "YarrHarrHeist/Lobby/abc.room".
/// Returns the second-to-last segment (the file's parent folder).
/// Returns null if the filename has no folder component.</summary>
private static string? ExtractSceneFolder(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return null;
// Browsers may use either separator depending on platform.
var parts = fileName.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2) return null;
// The .room file's parent folder is the scene name.
return parts[^2];
}
}
@@ -1,54 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// admin.rec.net/api/admin/v1/login — same-origin login endpoint for
/// the browser-based admin UI. Wraps a username+password verify
/// against PlayerEntity.PasswordHash and returns the JWT pair the UI
/// can stash in localStorage. Refuses non-admin accounts up front so a
/// regular player password can't be used to scope into the admin tools
/// even if the JWT is valid against api/admin/v1/* (the AdminOnly
/// filter would catch it anyway, but the early reject saves a
/// confused-looking 403).
///
/// Lives on admin.rec.net so the static admin page (also served from
/// admin.rec.net) can fetch with a same-origin request and skip CORS
/// entirely.
/// </summary>
[ApiController]
[Route("api/admin/v1")]
public class AdminLoginController(
DorkNetDbContext db,
AuthService authService) : ControllerBase
{
public sealed record AdminLoginRequest(string Username, string Password);
[HttpPost("login")]
public async Task<ActionResult> Login([FromBody] AdminLoginRequest body)
{
if (string.IsNullOrWhiteSpace(body.Username) || string.IsNullOrWhiteSpace(body.Password))
return BadRequest(new { error = "missing_credentials" });
var player = await db.Players.FirstOrDefaultAsync(p => p.Username == body.Username);
if (player is null || player.PasswordHash is null)
return Unauthorized(new { error = "invalid_credentials" });
if (!BCrypt.Net.BCrypt.Verify(body.Password, player.PasswordHash))
return Unauthorized(new { error = "invalid_credentials" });
if (!player.IsAdmin)
return StatusCode(403, new { error = "not_admin" });
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new
{
access_token = access,
refresh_token = refresh,
account_id = player.Id,
username = player.Username,
display_name = player.DisplayName,
});
}
}
@@ -1,1209 +0,0 @@
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using Google.Protobuf;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// admin.rec.net/api/admin/v1/rooms/zip-bulk-import — preferred room
/// import path going forward. The legacy multi-file endpoint
/// (<c>POST api/admin/v1/rooms/import</c>) stays alongside this for
/// tooling that already targets it.
///
/// The archive format mirrors RecNet's own room export shape so a
/// user can download a room from rec.net, drop the zip into the admin
/// page, and we re-create it with full fidelity — including the
/// <c>.htr</c> AudioSampler / Holotar assets already extracted into
/// each subroom folder so we never have to hit Rec Room's CDN:
///
/// root.zip/
/// Rooms/Room_&lt;id&gt;_&lt;name&gt;_&lt;timestamp&gt;/
/// RoomDetails.json (full RecNet v4 details schema)
/// RoomImage.jpg | &lt;hash&gt;.jpg
/// Promo/Image/PromoImage_*.jpg
/// SubRooms/&lt;SceneName&gt;/
/// Subroom.json (per-subroom manifest)
/// &lt;hash&gt;.room (referenced by Subroom.CurrentSave.DataBlob)
/// AudioSampler/&lt;hash&gt;.htr
/// Holotar/&lt;hash&gt;.htr
/// Image/PVImage_&lt;hash&gt;.jpg
/// Inventions/Invention_&lt;id&gt;_&lt;name&gt;_&lt;timestamp&gt;/
/// Invention.json
/// InventionDetails.json
/// InventionVersion.json
/// InventionImage.jpg
/// &lt;hash&gt;.inv
///
/// Entry-scene resolution: the order in <c>RoomDetails.SubRooms[]</c>
/// is the entry order (RecNet API convention). The first entry is the
/// entry scene. The admin can override with an explicit
/// <c>RoomDetails.EntryScene</c> field.
/// </summary>
[ApiController]
[Route("api/admin/v1/rooms")]
[Authorize]
[AdminOnly]
public class RoomZipImportController(
DorkNetDbContext db,
RoomBlobNormalizerService roomBlobNormalizer,
IObjectStorage storage,
ILogger<RoomZipImportController> logger) : ControllerBase
{
private long CurrentAdminId => this.RequireCurrentPlayerId();
// ── Room-blob normaliser toggle ─────────────────────────────────────
// The normaliser re-encodes .room bytes through the 2020 schema to
// strip non-canonical encodings. It's been tripping the watch into
// "Error attempting to parse room data stream" — the generated 2020
// protobuf bindings appear to be missing fields or to emit them in
// a wire-order the watch's parser disagrees with.
//
// Per-import flag (`normalizeBlobs` in the form / finalize body)
// defaults OFF. The SPA's importer surfaces it as a checkbox so an
// admin can opt back in once we're confident a particular zip's
// contents survive the re-encode. The diagnostic parse still runs
// either way (its parse-OK / parse-FAIL log line is useful), but
// when the flag is false the original bytes are what reach S3.
/// <summary>Write a blob to S3 at its canonical (bucket, key) AND
/// record the metadata row in <see cref="RoomDataBlobEntity"/>.
/// Bytes are NOT persisted on the DB row — S3 is the only place
/// they live. Previously the importer wrote bytes to the
/// <c>Bytes</c> column and skipped S3 entirely, so freshly
/// imported rooms were only reachable through the read-path's
/// DB-fallback branch. Centralised here so every importer call
/// site (scene saves, .htr/PV/polaroid assets, .meta sentinels,
/// invention saves, room thumbnails, promo images) ends up in
/// S3 the same way.</summary>
private async Task<RoomDataBlobEntity> WriteBlobAsync(
long roomId, string blobName, byte[] bytes, long uploaderPlayerId,
string? referencesCsv = null, string? contentTypeOverride = null)
{
var (bucket, key) = BlobRouter.Route(blobName);
var contentType = contentTypeOverride ?? MimeFromName(blobName);
try
{
await storage.PutAsync(bucket, key, bytes, contentType);
}
catch (Exception ex)
{
logger.LogError(ex,
"[zip-import] S3 PUT failed bucket={Bucket} key={Key} blob={Blob} bytes={Bytes} — row will still be recorded so the import isn't lost",
bucket, key, blobName, bytes.Length);
}
var entity = new RoomDataBlobEntity
{
RoomId = roomId,
BlobName = blobName,
UploadedByPlayerId = uploaderPlayerId,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = referencesCsv ?? string.Empty,
};
db.RoomDataBlobs.Add(entity);
return entity;
}
private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".mp4" => "video/mp4",
".htr" => "application/octet-stream",
".room" => "application/octet-stream",
".meta" => "application/octet-stream",
".inv" => "application/octet-stream",
".dat" => "application/octet-stream",
_ => "application/octet-stream",
};
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
// ── RoomDetails.json DTOs ──────────────────────────────────────────
public sealed class RoomDetailsDto
{
public long? RoomId { get; set; }
public string? Name { get; set; }
public string? FriendlyName { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public int? WarningMask { get; set; }
public string? CustomWarning { get; set; }
public long? CreatorAccountId { get; set; }
public int? State { get; set; }
public int? Accessibility { get; set; }
public bool? SupportsLevelVoting { get; set; }
public bool? IsRRO { get; set; }
public bool? SupportsScreens { get; set; }
public bool? SupportsWalkVR { get; set; }
public bool? SupportsTeleportVR { get; set; }
public bool? SupportsVRLow { get; set; }
public bool? SupportsMobile { get; set; }
public bool? SupportsJuniors { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public bool? IsDorm { get; set; }
public int? MaxPlayers { get; set; }
public bool? CloningAllowed { get; set; }
public bool? DisableMicAutoMute { get; set; }
public StatsDto? Stats { get; set; }
public List<TagDto>? Tags { get; set; }
public List<SubRoomDto>? SubRooms { get; set; }
public string? EntryScene { get; set; } // admin override; not in real exports
}
public sealed class StatsDto
{
public int? CheerCount { get; set; }
public int? FavoriteCount { get; set; }
public int? VisitorCount { get; set; }
public int? VisitCount { get; set; }
}
public sealed class TagDto
{
public string? Tag { get; set; }
public int? Type { get; set; }
public bool? IsPrimaryGenre { get; set; }
}
public sealed class SubRoomDto
{
public long? SubRoomId { get; set; }
public string? UnitySceneId { get; set; }
public string? Name { get; set; }
public bool? IsSandbox { get; set; }
public int? MaxPlayers { get; set; }
public int? Accessibility { get; set; }
public CurrentSaveDto? CurrentSave { get; set; }
}
public sealed class CurrentSaveDto
{
public long? SubRoomDataSaveId { get; set; }
public string? DataBlob { get; set; }
}
// ── Invention DTOs ────────────────────────────────────────────────
public sealed class InventionJsonDto
{
public long? InventionId { get; set; }
public string? ReplicationId { get; set; }
public long? CreatorPlayerId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? LongDescription { get; set; }
public string? ImageName { get; set; }
public int? UgcVersion { get; set; }
public int? CurrentVersionNumber { get; set; }
public int? LatestVersionNumber { get; set; }
public int? Accessibility { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
public DateTime? FirstPublishedAt { get; set; }
public long? CreationRoomId { get; set; }
public int? NumPlayersHaveUsedInRoom { get; set; }
public int? CheerCount { get; set; }
public int? CreatorPermission { get; set; }
public int? GeneralPermission { get; set; }
public bool? IsAgInvention { get; set; }
public bool? IsRecRoomApproved { get; set; }
}
public sealed class InventionVersionDto
{
public int? VersionNumber { get; set; }
public string? BlobName { get; set; }
}
public sealed class InventionDetailsDto
{
public List<TagDto>? Tags { get; set; }
}
// ── Endpoint ──────────────────────────────────────────────────────
[HttpPost("zip-bulk-import")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)] // 2 GB
public async Task<IActionResult> ZipImport(
[FromForm(Name = "archive")] IFormFile? archive,
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
[FromForm(Name = "normalizeBlobs")] bool? normalizeBlobs)
{
if (archive is null || archive.Length == 0)
return BadRequest(new { error = "missing_archive" });
await using var stream = archive.OpenReadStream();
return await RunImportAsync(
stream, archive.Length, creatorPlayerId,
normalizeBlobs: normalizeBlobs ?? false);
}
// ── Chunked upload (Cloudflare 100MB workaround) ─────────────────
//
// The whole multipart-zip path 413s through Cloudflare's edge for
// anything over 100 MB on free/pro plans. Real RecNet exports
// routinely run 150-300 MB. Splitting the upload into ≤90 MB
// chunks keeps each request safely under the cap; the server
// appends chunks to a temp file and only invokes the import logic
// once the finalize call comes in.
//
// Sessions are in-memory and short-lived (admins use the importer
// synchronously). If the process restarts before finalize, the
// upload is lost — acceptable for an admin tool used by 1-2 people.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, ZipUploadSession> Sessions = new();
public sealed class ZipUploadSession
{
public Guid Id { get; init; }
public string TempPath { get; init; } = string.Empty;
public long TotalBytes { get; set; }
public long ReceivedBytes { get; set; }
public string FileName { get; init; } = string.Empty;
public long AdminPlayerId { get; init; }
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
}
public sealed record ChunkInitRequest(string FileName, long TotalBytes);
/// <summary>POST <c>zip-upload-init</c> — allocates a temp file and
/// returns an <c>uploadId</c> the SPA threads through subsequent
/// chunk POSTs. Capped at 2 GB total; opens a sparse temp file so
/// disk usage tracks actual bytes received.</summary>
[HttpPost("zip-upload-init")]
public ActionResult ChunkInit([FromBody] ChunkInitRequest body)
{
if (body.TotalBytes <= 0 || body.TotalBytes > 2_000_000_000)
return BadRequest(new { error = "invalid_size" });
// Prune stale sessions before allocating a new one. Anything
// older than 2 hours with no activity is abandoned.
foreach (var (sid, sess) in Sessions)
{
if (DateTime.UtcNow - sess.CreatedAt > TimeSpan.FromHours(2))
{
Sessions.TryRemove(sid, out _);
try { if (System.IO.File.Exists(sess.TempPath)) System.IO.File.Delete(sess.TempPath); } catch { /* best effort */ }
}
}
var id = Guid.NewGuid();
var safeName = Path.GetFileName(body.FileName);
var tempPath = Path.Combine(Path.GetTempPath(), $"dorknet-ziplupload-{id:N}.zip");
// Pre-allocate the file at full length so chunk writes at
// arbitrary offsets work without the OS having to grow it
// page-by-page during the random writes.
using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write))
{
fs.SetLength(body.TotalBytes);
}
Sessions[id] = new ZipUploadSession
{
Id = id,
TempPath = tempPath,
TotalBytes = body.TotalBytes,
FileName = safeName,
AdminPlayerId = CurrentAdminId,
};
return Ok(new { uploadId = id });
}
/// <summary>POST <c>zip-upload-chunk/{id}?offset=N</c> — writes the
/// posted body at <c>offset</c> in the session's temp file.
/// Per-call size capped at 100 MB (Kestrel-side, well below the
/// Cloudflare 100 MB edge cap so we never see a 413). Body is the
/// raw chunk bytes; the SPA's <c>uploadZipInChunks</c> helper sends
/// the right slice.</summary>
[HttpPost("zip-upload-chunk/{id:guid}")]
[DisableRequestSizeLimit]
[RequestSizeLimit(100_000_000)]
public async Task<IActionResult> ChunkAppend(Guid id, [FromQuery] long offset)
{
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
if (offset < 0 || offset >= session.TotalBytes)
return BadRequest(new { error = "bad_offset", offset, total = session.TotalBytes });
await using var fs = new FileStream(session.TempPath, FileMode.Open, FileAccess.Write, FileShare.Read);
fs.Position = offset;
var written = 0L;
await Request.Body.CopyToAsync(fs);
written = fs.Position - offset;
session.ReceivedBytes = Math.Max(session.ReceivedBytes, fs.Position);
return Ok(new
{
uploadId = id,
offset,
written,
received = session.ReceivedBytes,
total = session.TotalBytes,
});
}
/// <summary>Body for <c>zip-upload-finalize</c>. <see cref="CreatorPlayerId"/>
/// stamps every imported room/invention with that player as the
/// owner. <see cref="SelectedRoomFolders"/> and
/// <see cref="SelectedInventionFolders"/>, when non-null and
/// non-empty, restrict the import to only those top-level folders
/// inside the archive — the SPA's preview offers a checkbox per
/// room/invention so admins can skip a bad export's broken entry
/// without rebuilding the zip. Null/empty means "import
/// everything" (back-compat with older SPA builds).</summary>
public sealed record ChunkFinalizeRequest(
long? CreatorPlayerId,
List<string>? SelectedRoomFolders,
List<string>? SelectedInventionFolders,
bool? NormalizeBlobs);
/// <summary>POST <c>zip-upload-finalize/{id}</c> — runs the existing
/// zip import on the assembled temp file then deletes it. Same
/// response shape as <c>zip-bulk-import</c>.</summary>
[HttpPost("zip-upload-finalize/{id:guid}")]
public async Task<IActionResult> ChunkFinalize(Guid id, [FromBody] ChunkFinalizeRequest? body)
{
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
if (session.AdminPlayerId != CurrentAdminId) return Forbid();
try
{
await using var stream = System.IO.File.OpenRead(session.TempPath);
return await RunImportAsync(
stream,
session.TotalBytes,
body?.CreatorPlayerId,
body?.SelectedRoomFolders,
body?.SelectedInventionFolders,
normalizeBlobs: body?.NormalizeBlobs ?? false);
}
finally
{
Sessions.TryRemove(id, out _);
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
}
}
/// <summary>DELETE <c>zip-upload-abort/{id}</c> — drop an in-flight
/// upload session (e.g. user navigated away). No-op if the id is
/// already gone.</summary>
[HttpDelete("zip-upload-abort/{id:guid}")]
public IActionResult ChunkAbort(Guid id)
{
if (Sessions.TryRemove(id, out var session))
{
try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
}
return Ok();
}
// ── Shared import core (called by both single-shot and chunked) ──
private async Task<IActionResult> RunImportAsync(
Stream archiveStream,
long archiveBytes,
long? creatorPlayerId,
List<string>? selectedRoomFolders = null,
List<string>? selectedInventionFolders = null,
bool normalizeBlobs = false)
{
var creator = creatorPlayerId ?? CurrentAdminId;
if (!await db.Players.AnyAsync(p => p.Id == creator))
return BadRequest(new { error = "creator_not_found", creator });
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false);
// Index entries by normalized path. We do path-based lookups
// throughout (per-room folder, per-subroom folder, per-invention
// folder), so a flat dictionary keyed by full path is simplest.
var entryByPath = new Dictionary<string, ZipArchiveEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var e in zip.Entries)
{
if (e.FullName.EndsWith("/", StringComparison.Ordinal)) continue;
entryByPath[Normalize(e.FullName)] = e;
}
// Find every room manifest (`Rooms/<folder>/RoomDetails.json`).
// Folder names are like "Room_3163127_YarrHarrHeist_20260509_180319"
// but we don't parse anything out of them — RoomDetails.json is
// the source of truth.
var roomFolders = entryByPath.Keys
.Where(k => k.StartsWith("Rooms/", StringComparison.OrdinalIgnoreCase)
&& k.EndsWith("/RoomDetails.json", StringComparison.OrdinalIgnoreCase))
.Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length))
.ToList();
// Inventions can live either at the zip root (Inventions/Invention_<id>_<name>_<ts>/)
// OR inside a room folder (Rooms/Room_<id>_<name>_<ts>/Inventions/Invention_…).
// Both layouts show up in the wild — handle either.
var inventionFolders = entryByPath.Keys
.Where(k => k.EndsWith("/Invention.json", StringComparison.OrdinalIgnoreCase)
&& (k.StartsWith("Inventions/", StringComparison.OrdinalIgnoreCase)
|| k.Contains("/Inventions/", StringComparison.OrdinalIgnoreCase)))
.Select(k => k[..^"/Invention.json".Length])
.ToList();
if (roomFolders.Count == 0 && inventionFolders.Count == 0)
return BadRequest(new { error = "empty_archive_no_manifests" });
// If the admin supplied an explicit selection (non-null,
// non-empty), filter to just those folders. Comparison is
// case-insensitive and tolerates leading/trailing slashes that
// the client might have included. Anything in the selection
// that we can't match against the archive is just ignored —
// the per-room report will simply not include it.
if (selectedRoomFolders is { Count: > 0 })
{
var allowed = selectedRoomFolders
.Select(s => Normalize(s).TrimEnd('/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
roomFolders = roomFolders.Where(f => allowed.Contains(f)).ToList();
}
if (selectedInventionFolders is { Count: > 0 })
{
var allowed = selectedInventionFolders
.Select(s => Normalize(s).TrimEnd('/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
inventionFolders = inventionFolders.Where(f => allowed.Contains(f)).ToList();
}
var roomReports = new List<object>();
long nextRoomId = (await db.Rooms
.Where(r => r.Id >= 1000)
.Select(r => (long?)r.Id)
.MaxAsync() ?? 999) + 1;
foreach (var folder in roomFolders)
{
try
{
var result = await ImportRoomAsync(folder, nextRoomId, creator, entryByPath, normalizeBlobs);
roomReports.Add(result);
if (result.GetType().GetProperty("ok")!.GetValue(result) as bool? == true)
nextRoomId++;
}
catch (Exception ex)
{
logger.LogError(ex, "[zip-import] room folder='{Folder}' failed", folder);
roomReports.Add(new { folder, ok = false, error = ex.Message });
}
}
var inventionReports = new List<object>();
foreach (var folder in inventionFolders)
{
try { inventionReports.Add(await ImportInventionAsync(folder, creator, entryByPath)); }
catch (Exception ex)
{
logger.LogError(ex, "[zip-import] invention folder='{Folder}' failed", folder);
inventionReports.Add(new { folder, ok = false, error = ex.Message });
}
}
return Ok(new
{
archiveBytes,
roomCount = roomFolders.Count,
inventionCount = inventionFolders.Count,
rooms = roomReports,
inventions = inventionReports,
});
}
// ── Per-room import ───────────────────────────────────────────────
private async Task<object> ImportRoomAsync(
string roomFolder,
long roomId,
long creator,
Dictionary<string, ZipArchiveEntry> entries,
bool normalizeBlobs)
{
// RoomDetails.json
var detailsKey = $"{roomFolder}/RoomDetails.json";
var details = await ReadJsonAsync<RoomDetailsDto>(entries[detailsKey]) ?? new RoomDetailsDto();
var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : Path.GetFileName(roomFolder);
if (await db.Rooms.AnyAsync(r => r.Name == name))
return new { name, ok = false, error = "duplicate_name", folder = roomFolder };
// Subroom manifests + blobs. Walk each folder under
// <roomFolder>/SubRooms/<scene>/ and pair its Subroom.json with
// the .room blob it references via CurrentSave.DataBlob, plus
// any AudioSampler/Holotar .htr files alongside.
var subroomRoot = $"{roomFolder}/SubRooms/";
var scenes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in entries.Keys.Where(k => k.StartsWith(subroomRoot, StringComparison.OrdinalIgnoreCase)))
{
var rel = path.Substring(subroomRoot.Length);
var slash = rel.IndexOf('/');
if (slash <= 0) continue;
var sceneName = rel.Substring(0, slash);
if (!scenes.ContainsKey(sceneName)) scenes[sceneName] = sceneName;
}
var resolvedScenes = new List<ResolvedSubroom>();
var missingScenes = new List<string>();
foreach (var sceneName in scenes.Keys)
{
var sceneFolder = $"{subroomRoot}{sceneName}";
var subroomJsonKey = $"{sceneFolder}/Subroom.json";
if (!entries.TryGetValue(subroomJsonKey, out var subroomJsonEntry))
{
missingScenes.Add($"{sceneName} (missing Subroom.json)");
continue;
}
var manifest = await ReadJsonAsync<SubRoomDto>(subroomJsonEntry) ?? new SubRoomDto();
var blobFile = manifest.CurrentSave?.DataBlob;
if (string.IsNullOrWhiteSpace(blobFile))
{
missingScenes.Add($"{sceneName} (no CurrentSave.DataBlob)");
continue;
}
var blobKey = $"{sceneFolder}/{blobFile}";
if (!entries.TryGetValue(blobKey, out var blobEntry))
{
missingScenes.Add($"{sceneName} → {blobFile}");
continue;
}
var blobBytes = await ReadAllAsync(blobEntry);
// The normaliser still runs so we keep the parse-OK / parse-FAIL
// log line — useful telemetry even when we're not persisting
// its output. Per-import `normalizeBlobs` flag (defaulting
// off because the re-encode currently crashes the watch on
// load) decides which bytes actually reach S3.
var norm = roomBlobNormalizer.Normalize(blobBytes);
var persistedBytes = normalizeBlobs ? norm.Bytes : blobBytes;
// Collect .htr asset blobs that live alongside this subroom.
var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>();
foreach (var sub in new[] { "AudioSampler", "Holotar" })
{
var prefix = $"{sceneFolder}/{sub}/";
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
if (k.EndsWith(".htr", StringComparison.OrdinalIgnoreCase))
htrAssets.Add((Path.GetFileName(k), e));
}
}
// Image/ folder: persistence-view previews (PVImage_<hash>.ext)
// and — since the exporter consolidated layouts — also the
// placed-in-world polaroids that used to live in Polaroids/.
//
// The watch fetches BOTH URLs for the same content:
// * `img.localhost/PVImage_<hash>.png` — persistence-view preview
// * `img.localhost/<hash>.png` — placed polaroid (room blob
// references this bare-hash form, decoded directly from
// the .room protobuf in our sample)
// So for any `PVImage_`-prefixed file we save the bytes under
// BOTH BlobNames. Same file in S3/DB twice is cheap and saves
// us from missing one of the lookup paths.
const string pvPrefix = "PVImage_";
var pvImages = new List<(string Name, ZipArchiveEntry Entry)>();
var polaroids = new List<(string Name, ZipArchiveEntry Entry)>();
var imageFolderPrefix = $"{sceneFolder}/Image/";
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(imageFolderPrefix, StringComparison.OrdinalIgnoreCase)))
{
var fileName = Path.GetFileName(k);
pvImages.Add((fileName, e));
if (fileName.StartsWith(pvPrefix, StringComparison.OrdinalIgnoreCase))
{
var bareHashName = fileName[pvPrefix.Length..];
polaroids.Add((bareHashName, e));
}
}
// Legacy fallback: older archives shipped polaroids as a
// separate Polaroids/ subfolder. Still support those so we
// don't regress on older test zips that haven't been re-
// exported. The persistence loop's de-dupe will collapse
// overlaps from the Image/-derived path.
var polaroidFolderPrefix = $"{sceneFolder}/Polaroids/";
foreach (var (k, e) in entries.Where(kv => kv.Key.StartsWith(polaroidFolderPrefix, StringComparison.OrdinalIgnoreCase)))
{
polaroids.Add((Path.GetFileName(k), e));
}
resolvedScenes.Add(new ResolvedSubroom(
Manifest: manifest,
SceneName: sceneName,
Bytes: persistedBytes,
RawBytes: blobBytes.Length,
// When the toggle is off we still report `norm.Normalized` —
// it reflects whether the diagnostic parse succeeded, not
// whether we actually applied the normalised output.
NormalizedOk: norm.Normalized,
HtrAssets: htrAssets,
PvImages: pvImages,
Polaroids: polaroids));
logger.LogInformation(
"[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}",
name, sceneName, blobFile, blobBytes.Length, norm.Bytes.Length,
normalizeBlobs ? "normaliser" : "raw",
htrAssets.Count, pvImages.Count, polaroids.Count);
}
if (resolvedScenes.Count == 0)
return new { name, ok = false, error = "no_resolvable_subrooms", missingScenes, folder = roomFolder };
// Entry resolution: order subrooms by manifest order if
// RoomDetails.SubRooms[] is present; otherwise by sceneName.
// EntryScene field overrides everything.
var manifestOrder = (details.SubRooms ?? new())
.Where(s => !string.IsNullOrWhiteSpace(s.Name))
.Select(s => s.Name!)
.ToList();
if (manifestOrder.Count > 0)
{
resolvedScenes = resolvedScenes
.OrderBy(r => manifestOrder.IndexOf(r.SceneName) is int i && i >= 0 ? i : int.MaxValue)
.ThenBy(r => r.SceneName)
.ToList();
}
else
{
resolvedScenes = resolvedScenes.OrderBy(r => r.SceneName).ToList();
}
// Entry resolution cascade:
// 1. Explicit `RoomDetails.EntryScene` override (admin-set,
// not in real exports).
// 2. <hash>.meta protobuf at the room root — RecNet's
// published-snapshot manifest. Field 2 is the entry
// subroom INDEX into RoomDetails.SubRooms[] (0-based).
// Decoded directly without a generated schema because
// the message is tiny (6 bytes total in the known sample).
// 3. First entry in manifest order.
// 4. Alphabetical fallback.
ResolvedSubroom? metaEntry = null;
int? metaIndex = null;
string? metaFileName = null;
var metaCandidate = entries
.Where(kv => kv.Key.StartsWith($"{roomFolder}/", StringComparison.OrdinalIgnoreCase)
&& !kv.Key[$"{roomFolder}/".Length..].Contains('/')
&& kv.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))
.Select(kv => kv.Value)
.FirstOrDefault();
if (metaCandidate is not null)
{
metaFileName = Path.GetFileName(metaCandidate.FullName);
var metaBytes = await ReadAllAsync(metaCandidate);
metaIndex = TryReadEntrySubroomIndexFromMeta(metaBytes);
logger.LogInformation("[zip-import] room='{Room}' .meta={Meta} bytes={Bytes} entryIndex={Idx}",
name, metaFileName, metaBytes.Length, metaIndex);
if (metaIndex is int idx && idx >= 0 && idx < manifestOrder.Count)
{
var sceneAtIndex = manifestOrder[idx];
metaEntry = resolvedScenes.FirstOrDefault(r =>
string.Equals(r.SceneName, sceneAtIndex, StringComparison.OrdinalIgnoreCase));
}
}
ResolvedSubroom entry;
string entrySource;
if (!string.IsNullOrWhiteSpace(details.EntryScene)
&& resolvedScenes.FirstOrDefault(r => string.Equals(r.SceneName, details.EntryScene, StringComparison.OrdinalIgnoreCase)) is { } overrideEntry)
{
entry = overrideEntry; entrySource = "RoomDetails.EntryScene";
}
else if (metaEntry is not null)
{
entry = metaEntry; entrySource = $"meta_index={metaIndex}";
}
else
{
entry = resolvedScenes[0]; entrySource = manifestOrder.Count > 0 ? "manifest_first" : "alphabetical_first";
}
// Image: try ImageName lookup first (RecNet-style hash filename),
// then RoomImage.* fallback (convenience format).
ZipArchiveEntry? imageEntry = null;
if (!string.IsNullOrWhiteSpace(details.ImageName)
&& entries.TryGetValue($"{roomFolder}/{details.ImageName}", out var hashed))
{
imageEntry = hashed;
}
else
{
imageEntry = entries.FirstOrDefault(kv =>
kv.Key.StartsWith($"{roomFolder}/RoomImage.", StringComparison.OrdinalIgnoreCase)).Value;
}
// Reorder so entry is first. Manifest order preserved for the rest.
var ordered = new List<ResolvedSubroom> { entry };
ordered.AddRange(resolvedScenes.Where(r => !ReferenceEquals(r, entry)));
var slug = name.ToLowerInvariant().Replace(' ', '_');
// Prefix with room_<id>_ so BlobRouter parks every .room scene
// save alongside its room_<id>_v*.dat versioned snapshots in
// saves/room/<id>/. Same routing parser handles both shapes;
// no per-extension special case.
string MakeBlobName(string sceneName) => $"room_{roomId}_{slug}_{sceneName.ToLowerInvariant()}.room";
// Use the entry subroom's UnitySceneId as the room's location.
var entryLocation = entry.Manifest.UnitySceneId ?? "a75f7547-79eb-47c6-8986-6767abcb4f92";
// Persist .htr assets and PV images at the GLOBAL level
// (RoomId=0) under their original hash filenames. The CDN
// / img.* controllers look up by exact blob name, so this
// matches what HtrAssetMirrorService would have produced if it
// had to fetch them from rec.net.
//
// We track three counters per asset type:
// * referenced — total unique filenames the archive points at
// * imported — newly inserted into RoomDataBlobs this run
// * alreadyInDb — already present from a prior import / mirror
// The third is important to surface: bulk-purge-custom keeps
// shared assets intentionally (other rooms might use them),
// so a re-import after purge will legitimately report 0 newly
// imported with non-zero already-in-DB. Reporting only "0
// imported" reads as "no HTR uploaded" which is wrong.
var htrAssetCount = 0;
var pvImageCount = 0;
var polaroidCount = 0;
var allHtrAssets = ordered.SelectMany(s => s.HtrAssets).ToList();
var allPvImages = ordered.SelectMany(s => s.PvImages).ToList();
var allPolaroids = ordered.SelectMany(s => s.Polaroids).ToList();
// De-dupe within the archive first (same .htr can appear under
// multiple scenes), then de-dupe against the DB. Both layers
// matter for accurate reporting.
var uniqueHtrNames = allHtrAssets.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var uniquePvNames = allPvImages.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var uniquePolaroidNames = allPolaroids.Select(a => a.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var existingHtrNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => uniqueHtrNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var existingPvNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => uniquePvNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var existingPolaroidNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => uniquePolaroidNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var htrAlreadyInDbAtStart = existingHtrNames.Count;
var pvAlreadyInDbAtStart = existingPvNames.Count;
var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count;
var imageBlobName = string.Empty;
await using (var tx = await db.Database.BeginTransactionAsync())
{
// Subroom blobs
foreach (var s in ordered)
{
await WriteBlobAsync(roomId, MakeBlobName(s.SceneName), s.Bytes, creator);
}
// HTR assets (shared content — keyed by hash, RoomId=0)
foreach (var (hname, hentry) in allHtrAssets)
{
if (existingHtrNames.Contains(hname)) continue;
existingHtrNames.Add(hname);
var bytes = await ReadAllAsync(hentry);
await WriteBlobAsync(0, hname, bytes, creator);
htrAssetCount++;
}
// PV images (persistence-view previews; shared)
foreach (var (pname, pentry) in allPvImages)
{
if (existingPvNames.Contains(pname)) continue;
existingPvNames.Add(pname);
var bytes = await ReadAllAsync(pentry);
await WriteBlobAsync(0, pname, bytes, creator);
pvImageCount++;
}
// Polaroids (placed in-world; watch fetches by bare hash).
foreach (var (polName, polEntry) in allPolaroids)
{
if (existingPolaroidNames.Contains(polName)) continue;
existingPolaroidNames.Add(polName);
var bytes = await ReadAllAsync(polEntry);
await WriteBlobAsync(0, polName, bytes, creator);
polaroidCount++;
}
// Room-root opaque marker files (e.g. <hash>.meta — 6-byte
// sentinel the exporter drops, purpose unclear but watching
// for fetches just in case). Rename to room_<id>_<hash>.meta
// so BlobRouter parks them in saves/room/<id>/ alongside the
// versioned save snapshots — keeps every byte that belongs
// to a specific room in one S3 folder.
var rootMetaPrefix = $"{roomFolder}/";
foreach (var (k, mentry) in entries.Where(kv =>
kv.Key.StartsWith(rootMetaPrefix, StringComparison.OrdinalIgnoreCase)
&& !kv.Key[rootMetaPrefix.Length..].Contains('/')
&& kv.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)))
{
var originalName = Path.GetFileName(k);
var fname = $"room_{roomId}_{originalName}";
if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue;
var bytes = await ReadAllAsync(mentry);
await WriteBlobAsync(roomId, fname, bytes, creator);
}
// Room image
if (imageEntry is not null)
{
var bytes = await ReadAllAsync(imageEntry);
var ext = Path.GetExtension(imageEntry.Name).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
imageBlobName = $"image_room_{roomId}{ext}";
await WriteBlobAsync(0, imageBlobName, bytes, creator);
}
// Promo images (kept under their original hashed names so
// the room's promo strip resolves them via img.* CDN).
var promoPrefix = $"{roomFolder}/Promo/Image/";
foreach (var (k, pentry) in entries.Where(kv => kv.Key.StartsWith(promoPrefix, StringComparison.OrdinalIgnoreCase)))
{
var fname = Path.GetFileName(k);
if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue;
var bytes = await ReadAllAsync(pentry);
await WriteBlobAsync(0, fname, bytes, creator);
}
db.Rooms.Add(new RoomEntity
{
Id = roomId,
Name = name,
Description = details.Description?.Trim() ?? $"Imported from zip ({ordered.Count} scenes)",
CreatorPlayerId = creator,
ImageName = imageBlobName,
State = details.State ?? 0,
Accessibility = details.Accessibility ?? 1,
IsAGRoom = false,
IsDormRoom = details.IsDorm ?? false,
CloningAllowed = details.CloningAllowed ?? true,
SupportsLevelVoting = details.SupportsLevelVoting ?? false,
SupportsVRLow = details.SupportsVRLow ?? true,
SupportsMobile = details.SupportsMobile ?? false,
SupportsScreens = details.SupportsScreens ?? true,
SupportsWalkVR = details.SupportsWalkVR ?? true,
SupportsTeleportVR = details.SupportsTeleportVR ?? true,
AllowsJuniors = details.SupportsJuniors ?? true,
RoomWarningMask = details.WarningMask ?? 0,
CustomRoomWarning = details.CustomWarning ?? string.Empty,
DisableMicAutoMute = details.DisableMicAutoMute ?? false,
LocationReplicationId = entryLocation,
TagsCsv = JoinTags(details.Tags),
CheerCount = details.Stats?.CheerCount ?? 0,
FavoriteCount = details.Stats?.FavoriteCount ?? 0,
VisitCount = details.Stats?.VisitCount ?? 0,
VisitorCount = details.Stats?.VisitorCount ?? 0,
HotScore = 5.0,
CurrentDataBlobName = MakeBlobName(entry.SceneName),
CreatedAt = details.CreatedAt ?? DateTime.UtcNow,
});
int idx = 0;
foreach (var s in ordered)
{
db.RoomScenes.Add(new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = idx++,
Name = s.SceneName,
RoomSceneLocationId = s.Manifest.UnitySceneId ?? entryLocation,
DataBlobName = MakeBlobName(s.SceneName),
MaxPlayers = s.Manifest.MaxPlayers ?? 8,
IsSandbox = s.Manifest.IsSandbox ?? false,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow,
});
}
db.AdminActions.Add(new AdminActionEntity
{
AdminPlayerId = CurrentAdminId,
Action = "zip_import_room",
TargetType = "room",
TargetId = roomId,
Reason = $"name={name} scenes={ordered.Count} entry={entry.SceneName} src={entrySource} " +
$"htr={htrAssetCount}new+{htrAlreadyInDbAtStart}existed " +
$"pv={pvImageCount}new+{pvAlreadyInDbAtStart}existed " +
$"polaroids={polaroidCount}new+{polaroidAlreadyInDbAtStart}existed",
});
await db.SaveChangesAsync();
await tx.CommitAsync();
}
return new
{
name,
ok = true,
roomId,
folder = roomFolder,
sceneCount = ordered.Count,
entryScene = entry.SceneName,
entryDetectionSource = entrySource,
missingScenes = missingScenes.Count > 0 ? missingScenes : null,
tags = details.Tags?.Select(t => t.Tag).Where(t => !string.IsNullOrWhiteSpace(t)).ToList(),
imageBlob = string.IsNullOrEmpty(imageBlobName) ? null : imageBlobName,
htrAssets = new
{
referenced = uniqueHtrNames.Count,
newlyImported = htrAssetCount,
alreadyInDb = htrAlreadyInDbAtStart,
},
pvImages = new
{
referenced = uniquePvNames.Count,
newlyImported = pvImageCount,
alreadyInDb = pvAlreadyInDbAtStart,
},
polaroids = new
{
referenced = uniquePolaroidNames.Count,
newlyImported = polaroidCount,
alreadyInDb = polaroidAlreadyInDbAtStart,
},
// Legacy fields kept for back-compat with anything that
// already reads them; new code should prefer htrAssets /
// pvImages / polaroids above.
htrAssetsImported = htrAssetCount,
pvImagesImported = pvImageCount,
stats = new
{
cheers = details.Stats?.CheerCount ?? 0,
favorites = details.Stats?.FavoriteCount ?? 0,
visitors = details.Stats?.VisitorCount ?? 0,
visits = details.Stats?.VisitCount ?? 0,
},
scenes = ordered.Select((s, i) => new
{
orderIndex = i,
name = s.SceneName,
blobName = MakeBlobName(s.SceneName),
bytes = s.Bytes.Length,
rawBytes = s.RawBytes,
normalized = s.NormalizedOk,
unitySceneId = s.Manifest.UnitySceneId,
isSandbox = s.Manifest.IsSandbox,
maxPlayers = s.Manifest.MaxPlayers,
htrAssets = s.HtrAssets.Count,
pvImages = s.PvImages.Count,
}),
};
}
// ── Per-invention import ─────────────────────────────────────────
private async Task<object> ImportInventionAsync(
string folder,
long creator,
Dictionary<string, ZipArchiveEntry> entries)
{
// Invention.json
var meta = await ReadJsonAsync<InventionJsonDto>(entries[$"{folder}/Invention.json"])
?? new InventionJsonDto();
InventionVersionDto version = entries.TryGetValue($"{folder}/InventionVersion.json", out var ve)
? await ReadJsonAsync<InventionVersionDto>(ve) ?? new InventionVersionDto()
: new InventionVersionDto();
InventionDetailsDto extras = entries.TryGetValue($"{folder}/InventionDetails.json", out var de)
? await ReadJsonAsync<InventionDetailsDto>(de) ?? new InventionDetailsDto()
: new InventionDetailsDto();
var name = !string.IsNullOrWhiteSpace(meta.Name) ? meta.Name!.Trim() : Path.GetFileName(folder);
// Find the .inv blob — by manifest reference if present, else
// first .inv file in the invention folder.
ZipArchiveEntry? blobEntry = null;
if (!string.IsNullOrWhiteSpace(version.BlobName)
&& entries.TryGetValue($"{folder}/{version.BlobName}", out var byManifest))
{
blobEntry = byManifest;
}
else
{
blobEntry = entries.FirstOrDefault(kv =>
kv.Key.StartsWith($"{folder}/", StringComparison.OrdinalIgnoreCase)
&& kv.Key.EndsWith(".inv", StringComparison.OrdinalIgnoreCase)).Value;
}
if (blobEntry is null)
return new { name, ok = false, error = "no_inv_blob", folder };
// Image (optional)
ZipArchiveEntry? imageEntry = null;
if (!string.IsNullOrWhiteSpace(meta.ImageName)
&& entries.TryGetValue($"{folder}/{meta.ImageName}", out var byHash))
{
imageEntry = byHash;
}
else
{
imageEntry = entries.FirstOrDefault(kv =>
kv.Key.StartsWith($"{folder}/InventionImage.", StringComparison.OrdinalIgnoreCase)).Value;
}
var blobBytes = await ReadAllAsync(blobEntry);
var entity = new InventionEntity
{
CreatorPlayerId = creator,
ReplicationId = !string.IsNullOrWhiteSpace(meta.ReplicationId)
? meta.ReplicationId!.Trim()
: Guid.NewGuid().ToString("N"),
Name = name,
Description = meta.Description?.Trim() ?? string.Empty,
ImageName = string.Empty, // set below after we know the blob name
IsAgInvention = meta.IsAgInvention ?? true,
IsPublished = false,
Permission = meta.Accessibility ?? 0,
CreatorPermission = meta.CreatorPermission ?? 100,
GeneralPermission = meta.GeneralPermission ?? 0,
CurrentVersionNumber = meta.CurrentVersionNumber ?? version.VersionNumber ?? 1,
NumPlayersHaveUsedInRoom = meta.NumPlayersHaveUsedInRoom ?? 0,
CheerCount = meta.CheerCount ?? 0,
CreationRoomId = meta.CreationRoomId,
TagsCsv = JoinTags(extras.Tags),
CreatedAt = meta.CreatedAt ?? DateTime.UtcNow,
UpdatedAt = meta.ModifiedAt ?? DateTime.UtcNow,
FirstPublishedAt = meta.FirstPublishedAt,
};
db.Inventions.Add(entity);
await db.SaveChangesAsync();
var blobName = $"invention_{entity.Id}_v{entity.CurrentVersionNumber}.dat";
entity.CurrentBlobName = blobName;
await WriteBlobAsync(0, blobName, blobBytes, creator);
string? imageBlobName = null;
if (imageEntry is not null)
{
var bytes = await ReadAllAsync(imageEntry);
var ext = Path.GetExtension(imageEntry.Name).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
imageBlobName = $"image_invention_{entity.Id}{ext}";
entity.ImageName = imageBlobName;
await WriteBlobAsync(0, imageBlobName, bytes, creator);
}
db.AdminActions.Add(new AdminActionEntity
{
AdminPlayerId = CurrentAdminId,
Action = "zip_import_invention",
TargetType = "invention",
TargetId = entity.Id,
Reason = $"name={name} blob={blobName} bytes={blobBytes.Length}",
});
await db.SaveChangesAsync();
logger.LogInformation(
"[zip-import] invention id={Id} name={Name} bytes={Bytes:N0} image={Image}",
entity.Id, name, blobBytes.Length, imageBlobName ?? "(none)");
return new
{
name,
ok = true,
inventionId = entity.Id,
folder,
blobName,
imageBlob = imageBlobName,
bytes = blobBytes.Length,
tags = extras.Tags?.Select(t => t.Tag).Where(t => !string.IsNullOrWhiteSpace(t)).ToList(),
};
}
// ── Helpers ──────────────────────────────────────────────────────
private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/');
private static async Task<byte[]> ReadAllAsync(ZipArchiveEntry entry)
{
await using var s = entry.Open();
using var ms = new MemoryStream();
await s.CopyToAsync(ms);
return ms.ToArray();
}
private static async Task<T?> ReadJsonAsync<T>(ZipArchiveEntry entry)
{
await using var s = entry.Open();
return await JsonSerializer.DeserializeAsync<T>(s, JsonOpts);
}
private static string JoinTags(List<TagDto>? tags)
{
if (tags is null || tags.Count == 0) return "community";
var values = tags
.Select(t => t.Tag?.Trim())
.Where(t => !string.IsNullOrEmpty(t))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return values.Count > 0 ? string.Join(',', values!) : "community";
}
private sealed record ResolvedSubroom(
SubRoomDto Manifest,
string SceneName,
byte[] Bytes,
int RawBytes,
bool NormalizedOk,
List<(string Name, ZipArchiveEntry Entry)> HtrAssets,
List<(string Name, ZipArchiveEntry Entry)> PvImages,
List<(string Name, ZipArchiveEntry Entry)> Polaroids);
/// <summary>Decode RecNet's room-snapshot manifest <c>.meta</c> and
/// return the entry-subroom index it encodes, or null if the file
/// isn't shaped like one. The full <c>.proto</c> schema isn't part
/// of the generated C# bindings, but the known-sample bytes are
/// trivially small and parseable by hand:
///
/// 10 01 1A 02 08 01
/// ┬──── ┬──────────
/// │ │
/// │ └─ field 3 (length-delimited), inner = { field 1 (varint) = 1 }
/// └─ field 2 (varint) = 1
///
/// Empirically field 2 is the entry subroom INDEX into
/// <c>RoomDetails.SubRooms[]</c> (0-based), and field 3's nested
/// field 1 echoes the same value (probably "published version =
/// 1"). We only need field 2 for the entry-selection decision.</summary>
private static int? TryReadEntrySubroomIndexFromMeta(byte[] bytes)
{
if (bytes is null || bytes.Length == 0) return null;
try
{
using var input = new CodedInputStream(bytes);
while (!input.IsAtEnd)
{
var tag = input.ReadTag();
var field = WireFormat.GetTagFieldNumber(tag);
var wire = WireFormat.GetTagWireType(tag);
if (field == 2 && wire == WireFormat.WireType.Varint)
{
return (int)input.ReadInt64();
}
input.SkipLastField();
}
}
catch
{
return null;
}
return null;
}
}

Some files were not shown because too many files have changed in this diff Show More