Compare commits

..

22 Commits

Author SHA1 Message Date
Alexa a07360ee3f ci(release): grant contents:write to the release workflow
The workflow creates a GitHub release and uploads assets, which needs the contents:write token scope. Declaring it per-workflow (matching release-launcher.yml on main) lets the repo keep a read-only default GITHUB_TOKEN.
2026-06-16 11:56:17 +03:00
Alexa ecaf17b764 Merge pull request #2 from DorkSquadRR/modernize-march-startup
Update project links
2026-06-15 14:07:36 +03:00
Alexa 4985bb09d4 Update project links 2026-06-15 11:50:13 +03:00
Alexa a5b00980a5 Merge pull request #1 from DorkSquadRR/modernize-march-startup
Modernize March server startup
2026-06-15 11:44:06 +03:00
Alexa 32cd60792f Modernize March server startup 2026-06-15 11:36:36 +03:00
Alexa 955246c6e4 feat: Refactor admin UI pages to support embedded mode and improve navigation
- Updated PlayerLogs, Players, Reports, Rooms, Settings, SignupCodes, and other pages to accept an optional `embedded` prop for better integration in various contexts.
- Introduced a new Activity page consolidating audit logs and player request logs with tab navigation.
- Added Content page for community board and loading tips with tab navigation.
- Created Moderation page to unify player directory, bans, and reports under a single interface.
- Implemented SettingsHome page to manage server settings and signup codes with tab navigation.
- Enhanced the Join page to include password creation for new accounts.
- Added global friends feature in Settings to allow all accounts to be friends without database entries.
- Introduced RelationshipQueries service to manage effective friend resolution based on global friends toggle.
- Added WireDates for consistent date handling in serialized data.
- Created reusable Tabs component for consistent tab navigation across various pages.
2026-06-06 16:39:19 +03:00
Alexa 34506e8294 admin: change a player's username from the Players panel
Add POST players/{id}/username + a Username field in the player detail's
Profile tab. Changes only the unique @handle (not the display name) and
rejects a collision outright rather than auto-suffixing, so the admin
gets the exact handle they typed. Same 2-24 char letters/digits/_/-
rule as the account API; logged to the audit trail as old -> new.
2026-05-31 20:52:11 +03:00
Alexa 11377b46d5 signup codes: admin-issued invite codes + site /join redemption
Port the December signup-code feature to the 2020.03 branch.

While account creation is disabled, the only way in is now an
admin-issued single-use signup code, redeemed on the public site's /join
page. Because the 2020.03 watch login is device-id-bound with no code
field, a new device refused at the signups-disabled gate (PlatformLogin /
account create) is stashed in a PendingDevices table keyed by the real
client IP (CF-Connecting-IP aware). The /join page shows the player the
device their own client just reported; they enter code + username and the
account is minted bound to that device, so the next launch logs straight
in. No watch changes.

- SignupCodeEntity + PendingDeviceEntity; tables created via idempotent
  CREATE TABLE IF NOT EXISTS at boot (Program.cs Postgres patch block +
  SQLite path) since they post-date the migration chain.
- SignupCodeService: generate / list / revoke / redeem + pending-device
  capture (single-use, expiry, revoke validation).
- Admin: GET/POST signup-codes + revoke endpoints and a Signup codes
  admin-ui page (generate with descriptor/expiry, status table, revoke).
- Public: anonymous /api/site/v1/join pending-devices + redeem, and a
  /join site page (+ header link).
- README documents the flow.
2026-05-31 20:08:33 +03:00
Alexa a840355489 site: load images from own img.{apex}; add Bowling/Paintball art
The public site built room/profile image URLs against a hardcoded
img.rec.net fallback for any non-localhost host, so thumbnails loaded
from real Rec Room (Azure-fronted — hence the TiPMix / x-ms-routing-name
cookies the browser rejected) and DorkNet custom-room images could never
display. Derive the CDN apex from the live host instead (shared
imageApex helper in types.ts, used by profileImageUrl and Rooms.tsx) so
images come from this server's own img.{apex}.

Add the two RR-Original thumbnails as static CDN files in data/images so
img.{apex} serves them directly: image_BowlingAlley.png and
image_PaintballKeyArt.png (both real PNGs).
2026-05-31 15:47:12 +03:00
Alexa b26ae93014 inventions: resolve real creator ids for a room (creatorIds)
api/inventions/v1/creatorIds was a stub returning an empty list, so the
2020.03 watch's GetCreatorIdsOfInventionsInRoom never learned which
creators it must request invention permissions for before spawning a
room's invention-based geometry. Resolve the real set from
InventionEntity.CreationRoomId via a shared ResolveRoomInventionsAsync
helper: direct matches (created in-place) unioned with the imported
cluster (most-frequent non-local CreationRoomId among the room creator's
inventions, since zip-imported inventions keep their original Rec Room
room id). Returns List<int> of distinct creator account ids.
2026-05-31 15:32:16 +03:00
Alexa f6a4fdc5fb weekly challenges: admin-configurable slate + working skin rewards
Port the admin-editable weekly-challenge feature to the 2020.03 branch
and fix the skin-reward bug it had on December: rewards now actually
land in the player's inventory instead of only granting XP + tokens.

Server config: add WeeklyChallenges* columns to ServerSettings (Postgres
post-EnsureCreated patches + SQLite column patches), and port the
ServerSettingsService weekly methods/records. The reward record carries
the store item Slug — the reliable handle December lacked — so the grant
can resolve the real item.

ProgressionController: CurrentChallenge + updateProgress now read the
configured slate and reward instead of a hardcoded one, keeping the
2020.03 ChallengeMap/ChallengeGift wire shapes (verified against the
march dump: ChallengeGift TypeDefIndex 11455). The Gift now sends
AvatarItemType (Outfit when an AvatarItemDesc is set) so the watch's
gift-card render binds the avatar preview. On completing the week's
challenges the configured skin/consumable is granted via the new
StoreService.GrantItemFreeBySlugAsync (same inventory path as a purchase,
no charge), gated behind the once-only reward sentinel.

Admin: GET/POST settings/weekly-challenges + reward-options (avatar +
consumable only — 2020.03 StoreService has no equipment payload helper),
and the admin-ui Server settings page to edit the slate + reward. The
reward picker keys on item slug rather than December's fragile
descriptor-string match.

Docs: README no longer lists weekly challenges as December-only;
url-coverage notes the config-driven challenge endpoints + skin grant.
2026-05-31 14:47:12 +03:00
Alexa 1eba8d9345 site/admin-ui: relative asset base for single-origin path prefix
Use base './' so built index.html references ./assets/... instead of
/assets/..., letting the SPAs work under the Easy Launcher's
single-origin path prefix (https://<apex>/__dn/admin/) as well as the
subdomain layout. Root-absolute paths would skip the prefix and 404.
2026-05-31 12:33:16 +03:00
Alexa a9518b08ab clientmod: parameterize EnableTlsTrustBypass via template placeholder
Render the flag from {ENABLE_TLS_TRUST_BYPASS} instead of hardcoding
true, so the launcher's opt-in toggle controls it. Off by default
since Localtunnel/Tunnelto serve valid certs.
2026-05-31 12:33:16 +03:00
Alexa 82c6f72219 Remove BepInEx client patcher (DorkNet.ClientPatch)
MelonLoader (DorkNet.ClientMod) is the sole client patcher now:

- delete the DorkNet.ClientPatch project + tools/install-plugin.ps1
- drop the project from DorkNet.sln
- README / deploy docs: present DorkNet.ClientMod (install-melon.ps1)
  as the patcher instead of the BepInEx plugin
- drop the dead install-plugin.ps1 reference from Program.cs's JWT
  error and install-melon.ps1's DRM-fallback hint
- tidy stray 'BepInEx port' comments

Legacy-cleanup paths that REMOVE an old BepInEx install
(install-legacy-client.ps1, build-patcher-zip.ps1's old_plugin_paths)
are kept intentionally.
2026-05-30 02:12:00 +03:00
Alexa 80cc4ce0bd Eager Photon AppId override via OnUpdate poll
The harmony hook on PhotonNetwork.ConnectUsingSettings registers in
March's IL2CPP build but never actually fires — the watch's connect
path goes through a different (mangled) entry point. Log confirms
[patch-ok] PhotonNetwork.ConnectUsingSettings but no
[photon-appid] override line, ever.

Fix moves the override out of the prefix-only path:

- New PhotonPatches.TryApplyOverride(reason) does the
  PhotonServerSettings.AppID / VoiceAppID / region mutation and
  flips an OverrideApplied flag once it succeeds. Idempotent.
- OnUpdate polls TryApplyOverride every 6th frame (~10 Hz) until
  it succeeds, then stops. The "type not found yet" log is
  throttled to once on first miss + every 300th miss after, so
  the file doesn't drown if PhotonServerSettings stays unloaded.
- Mod.ResolveType made internal (was private) so PhotonPatches
  + SavePatches can use the broader assembly-scan fallback —
  the local FindGameType copies were too narrow and missed
  IL2CPP-mangled type names. Both FindGameType helpers collapse
  to one-line delegates to Mod.ResolveType.
- Prefix on ConnectUsingSettings kept as a belt-and-braces hook
  (passes reason="prefix"); harmless if it fires, fine if it
  doesn't.
2026-05-28 13:46:39 +03:00
Alexa cd9d10586c Park Photon CustomAuth injector
- Photon dashboard has CustomAuth disabled on the AppId now, so the
  watch never needs to attach userid/LoginLock to its Authenticate
  ops. Removed the harmony hook on NetworkingPeer.CallAuthenticate,
  the AuthValuesInjector_Prefix method, the GetCurrentLockToken
  helper, and the InjectAuthValues Cfg field + config load. Bumped
  the [config] log line to drop the InjectAuthValues field.
- Both dorknet-clientmod.json.template copies (source +
  local-releases) lose the "InjectAuthValues" key.
- Code moved verbatim to DorkNet.ClientMod/attic/AuthValuesInjector.cs.attic
  with a header explaining why it was parked and how to restore it.
  The .cs.attic suffix keeps the SDK's auto-globber from compiling it.
2026-05-28 13:21:34 +03:00
Alexa 46e2bb15e4 Single-origin URL rewriting; configurable SQLite path; default InjectAuthValues=false
- ClientMod: add SingleOriginBaseUrl config + URI rewrite path so the
  client can talk to one https://<apex>/__dn/<service>/... base
  instead of demanding wildcard subdomains. ServerHost rewrite still
  the fallback when SingleOriginBaseUrl is empty.
- ClientMod config: InjectAuthValues now defaults to false. The
  server's PhotonCustomAuthController already fail-opens
  (Photon:RequireCustomAuth=false), so anonymous bootstrap is the
  cleaner default and avoids Photon's CustomAuthFailed loop when a
  WAF returns 403 to Photon's probe.
- Server: SQLite path now reads Database:SqlitePath config
  (Database__SqlitePath env). Falls back to <bin>/data/dorknet.db
  so existing Docker / standalone deploys are unchanged. Lets the
  Windows launcher put the db under %APPDATA%\DorkNet.
- Server: DomainConfig + ConfigService updates for the single-origin
  apex.
- .gitignore: exclude local-releases.zip.
2026-05-28 01:39:54 +03:00
Alexa 676dcd09a7 Support local domains and bundled client patching 2026-05-27 18:27:09 +03:00
Alexa ecbf192e47 patcher: install mod to <root>/Mods, mirrors main f9a40ba
MelonLoader 0.6.x scans <install root>\Mods\ for plugin DLLs and
ignores MelonLoader\Mods\, so 'plugin_dest' = 'MelonLoader/Mods'
in the manifest meant DorkNet.ClientMod.dll was never loaded —
Player.log showed '0 Mods loaded.' and the watch booted unpatched.
2026-05-27 13:13:32 +03:00
Alexa bc8bcd99d0 patcher: bundle Goldberg steam_api64.dll stub + steam_appid
Stripping SteamStub DRM with Steamless is necessary but not
sufficient — Rec Room's Plugins/steam_api64.dll still tries to
talk to Steam at runtime and bails when Steam isn't loaded. The
working test installs swap in Goldberg Steam Emulator (~11 MB),
which fakes a logged-in user without needing Steam running.

- DorkNet.ClientMod/steam-stub/ holds the stub DLL + a 7-byte
  steam_appid.txt (Rec Room is Steam app 471710)
- build-patcher-zip.ps1 copies both into the zip and writes a new
  steam_stub block in manifest.json so the launcher knows where to
  install them
- Manifest schema is back-compat: launchers that don't know the
  steam_stub block just ignore it

Without this, the launcher's Steamless step produced a runnable
exe that exited within a second of launch because of the Steam
API failure. With it, the unwrapped exe starts up cleanly.
2026-05-27 13:03:43 +03:00
Alexa 1f407ef7b3 clientmod: build without a local Rec Room install
The mod resolves every game-side type at runtime via
AccessTools.TypeByName, so the IL2CPP interop assemblies
(Assembly-CSharp, Photon3Unity3D, UnityEngine.*) in the csproj were
dead references. Dropped them entirely — only MelonLoader's own
DLLs are actually needed at compile time.

tools/build-patcher-zip.ps1 now downloads MelonLoader BEFORE the
dotnet build and points MelonLoaderDir at the extracted contents,
so a fresh contributor checkout builds the patcher zip cold with
no Rec Room install required. (Previously the csproj defaulted
RecRoomDir to a hardcoded user path and failed for anyone else.)

Verified by deleting bin/obj and running the script from scratch
on a machine without RecRoomDir set — produces a 20 MB zip with
the same manifest/DLL/MelonLoader.zip layout as before.
2026-05-27 11:47:25 +03:00
Alexa d471b418b9 Import march-2020-03-10 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.03.10 client (also serves 2020.03.06).

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces dorknet-clientpatch-{key}.zip
  for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
  dorknet-server-march_2020_03_10-win-x64.zip and
  dorknet-clientpatch-march_2020_03_10.zip as Release assets.

Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
2026-05-27 01:06:12 +03:00
344 changed files with 38160 additions and 149663 deletions
@@ -1,78 +0,0 @@
# Disabled template. GitHub ignores files outside .github/workflows/.
# Move this file to .github/workflows/endpoint-contract.yml when it should run.
name: Endpoint Contract
on:
workflow_dispatch:
jobs:
endpoint-contract:
runs-on: ubuntu-latest
env:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://127.0.0.1:18080
Database__Provider: sqlite
Database__SqlitePath: ${{ runner.temp }}/dorknet-endpoint-contract.db
DORKNET_JWT_SECRET: endpoint-contract-secret-000000000000000000000000000000000000000000
S3__Endpoint: ""
S3__AccessKey: ""
S3__SecretKey: ""
PROTOBUF_PROTOC: /usr/bin/protoc
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Install protobuf compiler
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compiler
- name: Build server
run: dotnet build DorkNet.Server/DorkNet.Server.csproj
- name: Build endpoint contract runner
run: dotnet build tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-restore
- name: Start server
run: |
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
- name: Wait for server readiness
run: |
for attempt in {1..60}; do
if curl --fail --silent http://127.0.0.1:18080/healthz; then
exit 0
fi
sleep 2
done
cat "$RUNNER_TEMP/dorknet-server.log"
exit 1
- name: Run endpoint contract
run: |
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
--base-url http://127.0.0.1:18080 \
--report "$RUNNER_TEMP/endpoint-contract-report.json"
- name: Upload endpoint contract artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: endpoint-contract
path: |
${{ runner.temp }}/endpoint-contract-report.json
${{ runner.temp }}/dorknet-server.log
- name: Stop server
if: always()
run: |
if [ -f "$RUNNER_TEMP/dorknet-server.pid" ]; then
kill "$(cat "$RUNNER_TEMP/dorknet-server.pid")" || true
fi
+3 -3
View File
@@ -1,4 +1,4 @@
name: Release (december-2020-12-18) name: Release (march-2020-03-10)
# Auto-publishes dorknet-server + dorknet-clientpatch zips to a # Auto-publishes dorknet-server + dorknet-clientpatch zips to a
# GitHub Release on tag push. The launcher (on the public main # GitHub Release on tag push. The launcher (on the public main
@@ -9,7 +9,7 @@ name: Release (december-2020-12-18)
on: on:
push: push:
tags: tags:
- 'v1-december-*' - 'v1-march-*'
permissions: permissions:
contents: write # create the GitHub release + upload assets contents: write # create the GitHub release + upload assets
@@ -19,7 +19,7 @@ jobs:
runs-on: windows-latest runs-on: windows-latest
env: env:
VERSION_KEY: december_2020_12_18 VERSION_KEY: march_2020_03_10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+2 -23
View File
@@ -8,6 +8,7 @@ build/
.vscode/ .vscode/
*.csproj.bak *.csproj.bak
dist/ dist/
local-releases/
## Data / secrets ## Data / secrets
# Local SQLite + uploaded blobs only — DON'T match the source-tree # Local SQLite + uploaded blobs only — DON'T match the source-tree
# DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on # DorkNet.Server/Data/ folder (which holds DbContext + Entities/) on
@@ -48,7 +49,6 @@ dist$ZIPNAME.zip
## time someone rebuilds. Keep the source committed, regenerate output. ## time someone rebuilds. Keep the source committed, regenerate output.
DorkNet.Server/wwwroot/admin/index.html DorkNet.Server/wwwroot/admin/index.html
DorkNet.Server/wwwroot/admin/assets/ DorkNet.Server/wwwroot/admin/assets/
DorkNet.Server/wwwroot/admin/brand/
DorkNet.Server/wwwroot/admin/*.map DorkNet.Server/wwwroot/admin/*.map
DorkNet.Server/admin-ui/node_modules/ DorkNet.Server/admin-ui/node_modules/
DorkNet.Server/admin-ui/dist/ DorkNet.Server/admin-ui/dist/
@@ -59,30 +59,9 @@ DorkNet.Server/admin-ui/tsconfig.tsbuildinfo
## Same reasoning as admin-ui above: hashed output, regenerate per build. ## Same reasoning as admin-ui above: hashed output, regenerate per build.
DorkNet.Server/wwwroot/site/index.html DorkNet.Server/wwwroot/site/index.html
DorkNet.Server/wwwroot/site/assets/ DorkNet.Server/wwwroot/site/assets/
DorkNet.Server/wwwroot/site/brand/
DorkNet.Server/wwwroot/site/*.map DorkNet.Server/wwwroot/site/*.map
DorkNet.Server/site/node_modules/ DorkNet.Server/site/node_modules/
DorkNet.Server/site/dist/ DorkNet.Server/site/dist/
DorkNet.Server/site/.vite/ DorkNet.Server/site/.vite/
DorkNet.Server/site/tsconfig.tsbuildinfo DorkNet.Server/site/tsconfig.tsbuildinfo
local-releases.zip
steamless/
## Local recovery / cache-mining artifacts produced by tools/recover-inventions.ps1
## and friends. These can run hundreds of MB to multi-GB (HTTPCache snapshots,
## raw .inv bytes staged for re-import, ready-to-import zip bundles). Per-room,
## per-run, regeneratable from the source room zip + a live walkthrough — never
## belongs in source control.
*-cache-snapshot/
*-cache-snapshot-*/
*-recovered-inv/
*-recovered.zip
DorkSquad-recovered/
ProficioVII-recovered/
Recovered.zip
Recovered/
Recovered(*)/
local-releases/**
-2
View File
@@ -53,14 +53,12 @@ WORKDIR /src
# DorkNet.Models project ref, so its csproj needs to be present too. # DorkNet.Models project ref, so its csproj needs to be present too.
COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/ COPY DorkNet.Server/DorkNet.Server.csproj DorkNet.Server/
COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/ COPY DorkNet.Models/DorkNet.Models.csproj DorkNet.Models/
COPY DorkNet.Contracts/DorkNet.Contracts.csproj DorkNet.Contracts/
RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj RUN dotnet restore DorkNet.Server/DorkNet.Server.csproj
# Now copy the rest of the .NET source, then drop the prebuilt admin # 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. # SPA into wwwroot/admin so the publish step ships it as static content.
COPY DorkNet.Server/ DorkNet.Server/ COPY DorkNet.Server/ DorkNet.Server/
COPY DorkNet.Models/ DorkNet.Models/ COPY DorkNet.Models/ DorkNet.Models/
COPY DorkNet.Contracts/ DorkNet.Contracts/
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/ COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/ COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
-55
View File
@@ -1,55 +0,0 @@
ARG DOTNET_VERSION=9.0
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS admin-ui
WORKDIR /ui
COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./
RUN npm install --no-fund --no-audit --loglevel=error
COPY DorkNet.Server/admin-ui/ ./
RUN mkdir -p /wwwroot/admin \
&& npm run build
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
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
ARG PROJECT_PATH
WORKDIR /src
COPY . .
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
RUN dotnet publish "${PROJECT_PATH}" -c Release \
-p:SkipAdminUIBuild=true \
-p:DorkNetServiceContainerPublish=true \
-o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
ARG APP_DLL
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
USER app
COPY --from=build --chown=app:app /app/publish .
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_URLS=http://0.0.0.0:8080 \
DOTNET_RUNNING_IN_CONTAINER=true \
DOTNET_PRINT_TELEMETRY_MESSAGE=false \
APP_DLL=${APP_DLL}
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
CMD curl --fail --silent http://localhost:8080/healthz || exit 1
ENTRYPOINT ["sh", "-c", "dotnet \"$APP_DLL\""]
-39
View File
@@ -1,39 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Pages;
namespace DorkNet.AdminMobile;
public class App : Application
{
public App(LoginPage loginPage)
{
UserAppTheme = AppTheme.Dark;
MainPage = new NavigationPage(loginPage)
{
BarBackgroundColor = AppDesign.Canvas,
BarTextColor = AppDesign.Text,
};
}
public static void ShowAdmin()
{
Current!.MainPage = new NavigationPage(
AppServices.Services.GetRequiredService<AdminTabbedPage>())
{
BarBackgroundColor = AppDesign.Canvas,
BarTextColor = AppDesign.Text,
};
}
public static void ShowLogin()
{
Current!.MainPage = new NavigationPage(
AppServices.Services.GetRequiredService<LoginPage>())
{
BarBackgroundColor = AppDesign.Canvas,
BarTextColor = AppDesign.Text,
};
}
}
-120
View File
@@ -1,120 +0,0 @@
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Shapes;
namespace DorkNet.AdminMobile.Design;
public static class AppDesign
{
public static readonly Color Canvas = Color.FromArgb("#0f1115");
public static readonly Color Surface = Color.FromArgb("#171a20");
public static readonly Color SurfaceLifted = Color.FromArgb("#20252d");
public static readonly Color RowSurface = Color.FromArgb("#14171d");
public static readonly Color Stroke = Color.FromArgb("#2a3039");
public static readonly Color Text = Color.FromArgb("#f4f6fa");
public static readonly Color Muted = Color.FromArgb("#aeb7c6");
public static readonly Color Subtle = Color.FromArgb("#778294");
public static readonly Color Accent = Color.FromArgb("#64b5ff");
public static readonly Color AndroidAccent = Color.FromArgb("#b7dd72");
public static readonly Color Danger = Color.FromArgb("#ff6f82");
public static Brush PageBackground => new SolidColorBrush(Canvas);
public static Color PlatformAccent =>
#if ANDROID
AndroidAccent;
#else
Accent;
#endif
public static Label Title(string text) => new()
{
Text = text,
TextColor = Text,
FontSize = 22,
FontAttributes = FontAttributes.Bold,
LineBreakMode = LineBreakMode.TailTruncation,
};
public static Label Section(string text) => new()
{
Text = text.ToUpperInvariant(),
TextColor = Subtle,
FontSize = 12,
FontAttributes = FontAttributes.Bold,
Margin = new Thickness(0, 12, 0, 0),
};
public static Border GlassCard(View content, Thickness? padding = null) => new()
{
Padding = padding ?? new Thickness(14),
Stroke = Stroke,
StrokeThickness = 1,
BackgroundColor = Surface,
StrokeShape = new RoundRectangle { CornerRadius = 10 },
Content = content,
};
public static Button PrimaryButton(string text) => new()
{
Text = text,
BackgroundColor = PlatformAccent,
TextColor = Colors.Black,
CornerRadius = 10,
HeightRequest = 44,
FontAttributes = FontAttributes.Bold,
Padding = new Thickness(14, 0),
};
public static Button SecondaryButton(string text) => new()
{
Text = text,
BackgroundColor = SurfaceLifted,
TextColor = Text,
CornerRadius = 10,
HeightRequest = 42,
Padding = new Thickness(14, 0),
};
public static Button DangerButton(string text) => new()
{
Text = text,
BackgroundColor = Color.FromArgb("#4c1d28"),
TextColor = Color.FromArgb("#ffd5dc"),
CornerRadius = 10,
HeightRequest = 42,
Padding = new Thickness(12, 0),
};
public static Entry Entry(string placeholder, Keyboard? keyboard = null, bool secret = false) => new()
{
Placeholder = placeholder,
IsPassword = secret,
TextColor = Text,
PlaceholderColor = Subtle,
BackgroundColor = Color.FromArgb("#19202b"),
ClearButtonVisibility = ClearButtonVisibility.WhileEditing,
Keyboard = keyboard ?? Keyboard.Text,
HeightRequest = 44,
};
public static Label Caption(string text) => new()
{
Text = text,
TextColor = Muted,
FontSize = 13,
LineBreakMode = LineBreakMode.WordWrap,
};
public static BoxView Divider() => new()
{
HeightRequest = 1,
Color = Stroke,
Opacity = 0.7,
};
public static Label StatusLabel() => new()
{
TextColor = Muted,
FontSize = 13,
};
}
@@ -1,32 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net9.0-android36.0;net9.0-ios26.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<RootNamespace>DorkNet.AdminMobile</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationTitle>DorkNet Admin</ApplicationTitle>
<ApplicationId>net.dorknet.adminmobile</ApplicationId>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>3</ApplicationVersion>
<AndroidTargetSdkVersion>36</AndroidTargetSdkVersion>
<RuntimeIdentifiers>android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
<PublishTrimmed Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">false</PublishTrimmed>
<RunAOTCompilation Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">false</RunAOTCompilation>
<AndroidLinkMode Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">None</AndroidLinkMode>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">23.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
</PropertyGroup>
<ItemGroup>
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#0B0F14" />
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#0B0F14" BaseSize="256,256" />
<MauiImage Include="Resources\Brand\dorknet.svg" BaseSize="256,256" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="9.0.120" />
</ItemGroup>
</Project>
-34
View File
@@ -1,34 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Controls.Hosting;
using Microsoft.Maui.Hosting;
using DorkNet.AdminMobile.Pages;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
builder.Services.AddSingleton<SecureAdminSettings>();
builder.Services.AddSingleton<AdminApiClient>();
builder.Services.AddTransient<LoginPage>();
builder.Services.AddTransient<AdminTabbedPage>();
builder.Services.AddTransient<DashboardPage>();
builder.Services.AddTransient<PlayersPage>();
builder.Services.AddTransient<RoomsPage>();
builder.Services.AddTransient<SettingsPage>();
var app = builder.Build();
AppServices.Services = app.Services;
return app;
}
}
public static class AppServices
{
public static IServiceProvider Services { get; set; } = default!;
}
-114
View File
@@ -1,114 +0,0 @@
using System.Text.Json.Serialization;
namespace DorkNet.AdminMobile.Models;
public sealed class AdminLoginResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = string.Empty;
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; } = string.Empty;
[JsonPropertyName("account_id")]
public long AccountId { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("display_name")]
public string DisplayName { get; set; } = string.Empty;
}
public sealed class PlayerSummary
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? Email { get; set; }
public bool IsAdmin { get; set; }
public bool IsDeveloper { get; set; }
public bool IsVerified { get; set; }
public bool IsJunior { get; set; }
public bool Online { get; set; }
public int Level { get; set; }
public int XP { get; set; }
public DateTime? LastSeenAt { get; set; }
public DateTime? BannedUntil { get; set; }
}
public sealed class RoomSummary
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public long CreatorPlayerId { get; set; }
public int BlobCount { get; set; }
public bool IsAGRoom { get; set; }
public bool IsDormRoom { get; set; }
}
public sealed class InstanceSummary
{
public long RoomInstanceId { get; set; }
public long RoomId { get; set; }
public string RoomName { get; set; } = string.Empty;
public string PhotonRoomId { get; set; } = string.Empty;
public string PhotonRegionId { get; set; } = string.Empty;
public int MaxCapacity { get; set; }
public bool IsPrivate { get; set; }
public List<InstanceParticipant> Participants { get; set; } = [];
}
public sealed class InstanceParticipant
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public bool IsMaster { get; set; }
}
public sealed class ServerSettingsDto
{
public bool SignupsDisabled { get; set; }
public bool GlobalFriendsEnabled { get; set; }
public bool WeeklyChallengesCompletedRequired { get; set; }
public DateTime UpdatedAt { get; set; }
}
public sealed class AdminStats
{
public PlayerStats Players { get; set; } = new();
public RoomStats Rooms { get; set; } = new();
public int Inventions { get; set; }
public PhotoStats Photos { get; set; } = new();
public ModerationStats Moderation { get; set; } = new();
public DateTime ServerTime { get; set; }
}
public sealed class PlayerStats
{
public int Total { get; set; }
public int OnlineNow { get; set; }
public int BannedNow { get; set; }
public int NewToday { get; set; }
}
public sealed class RoomStats
{
public int Total { get; set; }
public long TotalVisits { get; set; }
public long TotalCheers { get; set; }
public int ActiveSessionCount { get; set; }
public int InGamePlayerCount { get; set; }
}
public sealed class PhotoStats
{
public int Today { get; set; }
}
public sealed class ModerationStats
{
public int OpenReports { get; set; }
public int ActiveIpBans { get; set; }
}
@@ -1,23 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
namespace DorkNet.AdminMobile.Pages;
public sealed class AdminTabbedPage : TabbedPage
{
public AdminTabbedPage(
DashboardPage dashboard,
PlayersPage players,
RoomsPage rooms,
SettingsPage settings)
{
BarBackgroundColor = AppDesign.Canvas;
BarTextColor = AppDesign.Text;
SelectedTabColor = AppDesign.PlatformAccent;
UnselectedTabColor = AppDesign.Subtle;
Children.Add(dashboard);
Children.Add(players);
Children.Add(rooms);
Children.Add(settings);
}
}
@@ -1,91 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class DashboardPage : ContentPage
{
private readonly AdminApiClient api;
private readonly Label status = AppDesign.StatusLabel();
private readonly Grid metrics = new() { ColumnSpacing = 10, RowSpacing = 10 };
public DashboardPage(AdminApiClient api)
{
this.api = api;
Title = "Dashboard";
Background = AppDesign.PageBackground;
var refresh = AppDesign.SecondaryButton("Refresh");
refresh.Clicked += async (_, _) => await LoadAsync();
Content = new ScrollView
{
Content = new VerticalStackLayout
{
Padding = 16,
Spacing = 14,
Children =
{
AppDesign.Title("Operations"),
refresh,
metrics,
status,
},
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (metrics.Children.Count == 0) await LoadAsync();
}
private async Task LoadAsync()
{
try
{
status.Text = "Loading...";
var s = await api.GetStatsAsync();
DrawMetrics(s);
status.Text = $"Server time {s.ServerTime:g}";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
}
private void DrawMetrics(AdminStats s)
{
metrics.Children.Clear();
metrics.RowDefinitions.Clear();
metrics.ColumnDefinitions.Clear();
metrics.ColumnDefinitions.Add(new ColumnDefinition());
metrics.ColumnDefinitions.Add(new ColumnDefinition());
AddMetric(0, 0, "Players", $"{s.Players.Total}", $"{s.Players.OnlineNow} online");
AddMetric(0, 1, "Rooms", $"{s.Rooms.Total}", $"{s.Rooms.ActiveSessionCount} active sessions");
AddMetric(1, 0, "Moderation", $"{s.Moderation.OpenReports}", "open reports");
AddMetric(1, 1, "Traffic", $"{s.Rooms.InGamePlayerCount}", $"{s.Rooms.TotalVisits:n0} visits");
AddMetric(2, 0, "New today", $"{s.Players.NewToday}", "players");
AddMetric(2, 1, "Photos", $"{s.Photos.Today}", "last 24h");
}
private void AddMetric(int row, int col, string label, string value, string caption)
{
while (metrics.RowDefinitions.Count <= row) metrics.RowDefinitions.Add(new RowDefinition());
var tile = AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 4,
Children =
{
new Label { Text = label, TextColor = AppDesign.Subtle, FontSize = 12 },
new Label { Text = value, TextColor = AppDesign.Text, FontSize = 28, FontAttributes = FontAttributes.Bold },
new Label { Text = caption, TextColor = AppDesign.Muted, FontSize = 12 },
},
}, new Thickness(14));
metrics.Add(tile, col, row);
}
}
-137
View File
@@ -1,137 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class LoginPage : ContentPage
{
private readonly AdminApiClient api;
private readonly SecureAdminSettings settings;
private readonly Entry baseUrl = AppDesign.Entry("https://admin.example.com", Keyboard.Url);
private readonly Entry cfClientId = AppDesign.Entry("Cloudflare Access Client ID");
private readonly Entry cfClientSecret = AppDesign.Entry("Cloudflare Access Client Secret", secret: true);
private readonly Entry cfJwt = AppDesign.Entry("Cloudflare Access JWT assertion", secret: true);
private readonly Entry username = AppDesign.Entry("Admin username");
private readonly Entry password = AppDesign.Entry("Password", secret: true);
private readonly Label status = AppDesign.StatusLabel();
private readonly ActivityIndicator busy = new() { IsVisible = false };
private bool loaded;
public LoginPage(AdminApiClient api, SecureAdminSettings settings)
{
this.api = api;
this.settings = settings;
Title = "DorkNet Admin";
Background = AppDesign.PageBackground;
var login = AppDesign.PrimaryButton("Sign in");
login.Clicked += OnLogin;
var continueSaved = AppDesign.SecondaryButton("Continue saved session");
continueSaved.Clicked += async (_, _) =>
{
if (await settings.GetJwtAsync() is { Length: > 0 }) App.ShowAdmin();
else status.Text = "No saved DorkNet admin session is stored.";
};
Content = new ScrollView
{
Content = new VerticalStackLayout
{
Padding = new Thickness(20, 28),
Spacing = 14,
Children =
{
new Image
{
Source = "dorknet.svg",
WidthRequest = 96,
HeightRequest = 96,
HorizontalOptions = LayoutOptions.Start,
},
AppDesign.Title("Admin mobile"),
new Label
{
Text = "Connect to a DorkNet admin host. Cloudflare Access service tokens are sent with every request when configured.",
TextColor = AppDesign.Muted,
FontSize = 14,
},
AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 10,
Children =
{
AppDesign.Section("Connection"),
baseUrl,
cfClientId,
cfClientSecret,
cfJwt,
},
}),
AppDesign.GlassCard(new VerticalStackLayout
{
Spacing = 10,
Children =
{
AppDesign.Section("DorkNet admin login"),
username,
password,
login,
continueSaved,
},
}),
busy,
status,
},
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (loaded) return;
loaded = true;
try
{
var saved = await settings.LoadConnectionAsync();
baseUrl.Text = saved.BaseUrl;
cfClientId.Text = saved.CloudflareAccessClientId;
cfClientSecret.Text = saved.CloudflareAccessClientSecret;
cfJwt.Text = saved.CloudflareAccessJwt;
}
catch (Exception ex)
{
baseUrl.Text = "https://admin.rec.net";
status.Text = $"Secure storage was reset: {ex.Message}";
}
}
private async void OnLogin(object? sender, EventArgs e)
{
busy.IsVisible = true;
busy.IsRunning = true;
status.Text = string.Empty;
try
{
await settings.SaveConnectionAsync(new AdminConnectionSettings(
baseUrl.Text ?? string.Empty,
cfClientId.Text ?? string.Empty,
cfClientSecret.Text ?? string.Empty,
cfJwt.Text ?? string.Empty));
await api.LoginAsync(username.Text ?? string.Empty, password.Text ?? string.Empty);
App.ShowAdmin();
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
busy.IsRunning = false;
busy.IsVisible = false;
}
}
}
-222
View File
@@ -1,222 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class PlayersPage : ContentPage
{
private readonly AdminApiClient api;
private readonly Entry search = AppDesign.Entry("Search players");
private readonly CollectionView list = new();
private readonly Label status = AppDesign.StatusLabel();
private readonly Button refresh = AppDesign.SecondaryButton("Refresh");
private List<PlayerSummary> players = [];
public PlayersPage(AdminApiClient api)
{
this.api = api;
Title = "Players";
Background = AppDesign.PageBackground;
refresh.Clicked += async (_, _) => await LoadAsync();
search.Completed += async (_, _) => await LoadAsync();
list.SelectionMode = SelectionMode.Single;
list.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
list.SelectionChanged += async (_, e) =>
{
if (e.CurrentSelection.FirstOrDefault() is PlayerSummary player)
{
list.SelectedItem = null;
await PlayerActionsAsync(player);
}
};
list.EmptyView = new Label
{
Text = "No players loaded",
TextColor = AppDesign.Subtle,
HorizontalTextAlignment = TextAlignment.Center,
VerticalTextAlignment = TextAlignment.Center,
};
list.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 16 };
name.SetBinding(Label.TextProperty, nameof(PlayerSummary.DisplayName));
var handle = new Label { TextColor = AppDesign.Muted, FontSize = 13 };
handle.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Username), stringFormat: "@{0}"));
var detail = new Label { TextColor = AppDesign.Subtle, FontSize = 12 };
detail.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Level), stringFormat: "Level {0}"));
var online = new Label { TextColor = AppDesign.AndroidAccent, FontSize = 12, FontAttributes = FontAttributes.Bold };
online.SetBinding(Label.IsVisibleProperty, nameof(PlayerSummary.Online));
online.Text = "ONLINE";
var chevron = new Label
{
Text = ">",
TextColor = AppDesign.Subtle,
FontSize = 18,
VerticalTextAlignment = TextAlignment.Center,
};
var row = new Grid
{
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
},
ColumnDefinitions =
{
new ColumnDefinition(),
new ColumnDefinition { Width = GridLength.Auto },
new ColumnDefinition { Width = GridLength.Auto },
},
Padding = new Thickness(12, 10),
RowSpacing = 2,
ColumnSpacing = 10,
BackgroundColor = AppDesign.RowSurface,
};
row.Add(name, 0, 0);
row.Add(online, 1, 0);
row.Add(handle, 0, 1);
row.Add(detail, 0, 2);
row.Add(chevron, 2, 0);
Grid.SetRowSpan(chevron, 3);
return new VerticalStackLayout
{
Spacing = 0,
Children = { row, AppDesign.Divider() },
};
});
var searchRow = new Grid
{
ColumnSpacing = 8,
ColumnDefinitions =
{
new ColumnDefinition(),
new ColumnDefinition { Width = GridLength.Auto },
},
};
searchRow.Add(search, 0, 0);
searchRow.Add(refresh, 1, 0);
var grid = new Grid
{
Padding = 16,
RowSpacing = 12,
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition(),
new RowDefinition { Height = GridLength.Auto },
},
};
grid.Add(AppDesign.Title("Players"), 0, 0);
grid.Add(searchRow, 0, 1);
grid.Add(list, 0, 2);
grid.Add(status, 0, 3);
Content = grid;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (players.Count == 0) await LoadAsync();
}
private async Task LoadAsync()
{
try
{
refresh.IsEnabled = false;
status.Text = "Loading...";
players = await api.GetPlayersAsync(search.Text);
list.ItemsSource = players;
status.Text = $"{players.Count} players loaded";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
refresh.IsEnabled = true;
}
}
private async Task PlayerActionsAsync(PlayerSummary player)
{
var action = await DisplayActionSheet(
$"@{player.Username}",
"Cancel",
"Remove account",
"Kick",
"Reset avatar");
switch (action)
{
case "Kick":
await ConfirmAction($"Kick @{player.Username}?", () => api.KickPlayerAsync(player.Id, "Kicked from mobile admin."));
break;
case "Reset avatar":
await ConfirmAction($"Reset @{player.Username}'s avatar?", () => api.ResetAvatarAsync(player.Id));
break;
case "Remove account":
await RemoveAccountAsync(player);
break;
}
}
private async Task ConfirmAction(string message, Func<Task> action)
{
if (!await DisplayAlert("Confirm", message, "Run", "Cancel")) return;
try
{
await action();
await LoadAsync();
}
catch (Exception ex)
{
await DisplayAlert("Request failed", ex.Message, "OK");
}
}
private async Task RemoveAccountAsync(PlayerSummary player)
{
var username = await DisplayPromptAsync(
"Verify username",
$"Type {player.Username} exactly to remove @{player.Username}.",
"Next",
"Cancel",
maxLength: Math.Max(32, player.Username.Length),
keyboard: Keyboard.Text);
if (!string.Equals(username?.Trim(), player.Username, StringComparison.Ordinal)) return;
var phrase = $"DELETE {player.Id}";
var typedPhrase = await DisplayPromptAsync(
"Verify removal",
$"Type {phrase} exactly. This cannot be undone.",
"Remove",
"Cancel",
maxLength: phrase.Length,
keyboard: Keyboard.Text);
if (!string.Equals(typedPhrase?.Trim(), phrase, StringComparison.Ordinal)) return;
if (!await DisplayAlert("Remove account", $"Permanently remove @{player.Username}?", "Remove", "Cancel")) return;
try
{
status.Text = $"Removing @{player.Username}...";
await api.DeletePlayerAsync(player.Id, player.Username, phrase, "Removed from mobile admin.");
await LoadAsync();
}
catch (Exception ex)
{
await DisplayAlert("Removal failed", ex.Message, "OK");
}
}
}
-129
View File
@@ -1,129 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Models;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class RoomsPage : ContentPage
{
private readonly AdminApiClient api;
private readonly CollectionView rooms = new();
private readonly CollectionView instances = new();
private readonly Label status = AppDesign.StatusLabel();
private readonly Button refresh = AppDesign.SecondaryButton("Refresh");
private bool loaded;
public RoomsPage(AdminApiClient api)
{
this.api = api;
Title = "Rooms";
Background = AppDesign.PageBackground;
rooms.SelectionMode = SelectionMode.None;
rooms.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
rooms.EmptyView = EmptyList("No recent rooms");
rooms.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 };
name.SetBinding(Label.TextProperty, nameof(RoomSummary.Name));
var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 };
meta.SetBinding(Label.TextProperty, new Binding(nameof(RoomSummary.Id), stringFormat: "Room #{0}"));
var row = new VerticalStackLayout
{
Padding = new Thickness(12, 9),
Spacing = 2,
BackgroundColor = AppDesign.RowSurface,
Children = { name, meta },
};
return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } };
});
instances.SelectionMode = SelectionMode.None;
instances.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
instances.EmptyView = EmptyList("No active instances");
instances.ItemTemplate = new DataTemplate(() =>
{
var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 };
name.SetBinding(Label.TextProperty, nameof(InstanceSummary.RoomName));
var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 };
meta.SetBinding(Label.TextProperty, new Binding(nameof(InstanceSummary.PhotonRegionId), stringFormat: "Region {0}"));
var count = new Label { TextColor = AppDesign.Subtle, FontSize = 12 };
count.SetBinding(Label.TextProperty, new Binding("Participants.Count", stringFormat: "{0} participants"));
var row = new VerticalStackLayout
{
Padding = new Thickness(12, 9),
Spacing = 2,
BackgroundColor = AppDesign.RowSurface,
Children = { name, meta, count },
};
return new VerticalStackLayout { Spacing = 0, Children = { row, AppDesign.Divider() } };
});
refresh.Clicked += async (_, _) => await LoadAsync();
var grid = new Grid
{
Padding = 16,
RowSpacing = 10,
RowDefinitions =
{
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = new GridLength(1, GridUnitType.Star) },
new RowDefinition { Height = GridLength.Auto },
new RowDefinition { Height = new GridLength(2, GridUnitType.Star) },
new RowDefinition { Height = GridLength.Auto },
},
};
grid.Add(AppDesign.Title("Rooms"), 0, 0);
grid.Add(refresh, 0, 1);
grid.Add(new Label { Text = "Live instances", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 2);
grid.Add(instances, 0, 3);
grid.Add(new Label { Text = "Recent rooms", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, 0, 4);
grid.Add(rooms, 0, 5);
grid.Add(status, 0, 6);
Content = grid;
}
private static Label EmptyList(string text) => new()
{
Text = text,
TextColor = AppDesign.Subtle,
HorizontalTextAlignment = TextAlignment.Center,
VerticalTextAlignment = TextAlignment.Center,
};
protected override async void OnAppearing()
{
base.OnAppearing();
if (!loaded)
{
loaded = true;
await LoadAsync();
}
}
private async Task LoadAsync()
{
try
{
refresh.IsEnabled = false;
status.Text = "Loading...";
var instanceRows = await api.GetInstancesAsync();
var roomRows = await api.GetRoomsAsync();
instances.ItemsSource = instanceRows;
rooms.ItemsSource = roomRows;
status.Text = $"{instanceRows.Count} active instances, {roomRows.Count} rooms loaded";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
refresh.IsEnabled = true;
}
}
}
-119
View File
@@ -1,119 +0,0 @@
using Microsoft.Maui.Controls;
using DorkNet.AdminMobile.Design;
using DorkNet.AdminMobile.Services;
namespace DorkNet.AdminMobile.Pages;
public sealed class SettingsPage : ContentPage
{
private readonly AdminApiClient api;
private readonly SecureAdminSettings settings;
private readonly Switch signupsDisabled = new();
private readonly Switch globalFriends = new();
private readonly Label status = AppDesign.StatusLabel();
private bool loading;
public SettingsPage(AdminApiClient api, SecureAdminSettings settings)
{
this.api = api;
this.settings = settings;
Title = "Settings";
Background = AppDesign.PageBackground;
signupsDisabled.Toggled += async (_, e) =>
{
if (!loading) await SaveToggleAsync(() => api.SetSignupsDisabledAsync(e.Value));
};
globalFriends.Toggled += async (_, e) =>
{
if (!loading) await SaveToggleAsync(() => api.SetGlobalFriendsAsync(e.Value));
};
var refresh = AppDesign.SecondaryButton("Refresh");
refresh.Clicked += async (_, _) => await LoadAsync();
var logout = AppDesign.DangerButton("Clear saved session");
logout.Clicked += (_, _) =>
{
settings.ClearSession();
App.ShowLogin();
};
Content = new VerticalStackLayout
{
Padding = 16,
Spacing = 14,
Children =
{
AppDesign.Title("Settings"),
refresh,
Row("Disable new account creation", signupsDisabled),
Row("Global friends mode", globalFriends),
logout,
status,
},
};
}
protected override async void OnAppearing()
{
base.OnAppearing();
await LoadAsync();
}
private async Task LoadAsync()
{
try
{
loading = true;
status.Text = "Loading...";
var s = await api.GetSettingsAsync();
signupsDisabled.IsToggled = s.SignupsDisabled;
globalFriends.IsToggled = s.GlobalFriendsEnabled;
status.Text = $"Updated {s.UpdatedAt:g}";
}
catch (Exception ex)
{
status.Text = ex.Message;
}
finally
{
loading = false;
}
}
private async Task SaveToggleAsync(Func<Task> save)
{
try
{
status.Text = "Saving...";
await save();
status.Text = "Saved";
}
catch (Exception ex)
{
status.Text = ex.Message;
await LoadAsync();
}
}
private static View Row(string label, View control)
{
var grid = new Grid
{
ColumnDefinitions =
{
new ColumnDefinition(),
new ColumnDefinition { Width = GridLength.Auto },
},
Padding = new Thickness(12, 10),
};
grid.Add(new Label
{
Text = label,
TextColor = AppDesign.Text,
VerticalTextAlignment = TextAlignment.Center,
}, 0, 0);
grid.Add(control, 1, 0);
return AppDesign.GlassCard(grid, new Thickness(0));
}
}
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="false"
android:icon="@mipmap/appicon"
android:roundIcon="@mipmap/appicon_round"
android:label="DorkNet Admin"
android:supportsRtl="true" />
</manifest>
@@ -1,23 +0,0 @@
using Android.App;
using Android.Content.PM;
using Microsoft.Maui;
namespace DorkNet.AdminMobile;
[Activity(
Label = "DorkNet Admin",
Icon = "@mipmap/appicon",
RoundIcon = "@mipmap/appicon_round",
MainLauncher = true,
Exported = true,
Theme = "@style/Maui.SplashTheme",
ConfigurationChanges =
ConfigChanges.ScreenSize |
ConfigChanges.Orientation |
ConfigChanges.UiMode |
ConfigChanges.ScreenLayout |
ConfigChanges.SmallestScreenSize |
ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}
@@ -1,13 +0,0 @@
using Android.App;
using Android.Runtime;
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace DorkNet.AdminMobile;
[Application]
public class MainApplication(nint handle, JniHandleOwnership ownership)
: MauiApplication(handle, ownership)
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -1,11 +0,0 @@
using Foundation;
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace DorkNet.AdminMobile;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>DorkNet Admin</string>
<key>CFBundleIdentifier</key>
<string>net.dorknet.adminmobile</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
<string>14.2</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -1,11 +0,0 @@
using UIKit;
namespace DorkNet.AdminMobile;
public static class Program
{
public static void Main(string[] args)
{
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
-45
View File
@@ -1,45 +0,0 @@
# DorkNet Admin Mobile
Native Android/iOS admin app for DorkNet, built with .NET MAUI.
In the microservices deployment, point the app at the public admin host
for the stack, usually `https://admin.<domain>`. Cloudflare and
`DorkNet.Gateway` keep the public URL stable. Requests made through the
admin host are handled by the `web` service's same-origin admin route;
the `/api/admin/*` path family is also exposed by the moderation slice
for non-admin-host routing.
The browser admin page, route map, and troubleshooting flow are
documented in [`../docs/admin.md`](../docs/admin.md).
## Authentication
The app stores sensitive values in platform secure storage:
- DorkNet admin JWT from `POST /api/admin/v1/login`
- Cloudflare Access service-token client id
- Cloudflare Access service-token client secret
- Optional Cloudflare Access JWT assertion
Every API request includes:
```http
Authorization: Bearer <dorknet-admin-jwt>
CF-Access-Client-Id: <service-token-client-id>
CF-Access-Client-Secret: <service-token-client-secret>
CF-Access-Jwt-Assertion: <optional-jwt>
```
The Cloudflare headers are optional, but required when the admin host is behind Cloudflare Access.
## Build
Install the .NET MAUI workloads, then build per platform:
```powershell
dotnet workload install maui
dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-android36.0
dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-ios26.0
```
The project targets Android 16 / API 36 and iOS 26 SDK. iOS device/archive builds still require Xcode 26 on macOS.
@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,147 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using DorkNet.AdminMobile.Models;
namespace DorkNet.AdminMobile.Services;
public sealed class AdminApiClient(SecureAdminSettings settings)
{
private static readonly JsonSerializerOptions Json = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly HttpClient http = new()
{
Timeout = TimeSpan.FromSeconds(20),
};
public async Task<AdminLoginResponse> LoginAsync(string username, string password)
{
var result = await SendAsync<AdminLoginResponse>(
HttpMethod.Post,
"login",
new { Username = username, Password = password },
includeJwt: false);
await settings.SaveSessionAsync(result.AccessToken, result.RefreshToken);
return result;
}
public Task<AdminStats> GetStatsAsync() =>
SendAsync<AdminStats>(HttpMethod.Get, "stats");
public Task<List<PlayerSummary>> GetPlayersAsync(string? query = null)
{
var suffix = string.IsNullOrWhiteSpace(query)
? "players?take=100"
: $"players?take=100&query={Uri.EscapeDataString(query)}";
return SendAsync<List<PlayerSummary>>(HttpMethod.Get, suffix);
}
public Task<List<RoomSummary>> GetRoomsAsync() =>
SendAsync<List<RoomSummary>>(HttpMethod.Get, "rooms?take=100");
public Task<List<InstanceSummary>> GetInstancesAsync() =>
SendAsync<List<InstanceSummary>>(HttpMethod.Get, "instances");
public Task<ServerSettingsDto> GetSettingsAsync() =>
SendAsync<ServerSettingsDto>(HttpMethod.Get, "settings");
public Task<ServerSettingsDto> SetSignupsDisabledAsync(bool disabled) =>
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/signups", new { Disabled = disabled });
public Task<ServerSettingsDto> SetGlobalFriendsAsync(bool enabled) =>
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/global-friends", new { Enabled = enabled });
public Task KickPlayerAsync(long playerId, string reason) =>
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/kick", new { Reason = reason });
public Task ResetAvatarAsync(long playerId) =>
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/avatar/reset");
public Task DeletePlayerAsync(long playerId, string username, string phrase, string? reason) =>
SendAsync<object>(
HttpMethod.Delete,
$"players/{playerId}",
new { ConfirmUsername = username, ConfirmPhrase = phrase, Reason = reason });
private async Task<T> SendAsync<T>(
HttpMethod method,
string path,
object? body = null,
bool includeJwt = true)
{
var connection = await settings.LoadConnectionAsync();
var url = $"{connection.BaseUrl.TrimEnd('/')}/api/admin/v1/{path.TrimStart('/')}";
using var request = new HttpRequestMessage(method, url);
AddCloudflareAccessHeaders(request, connection);
if (includeJwt)
{
var jwt = await settings.GetJwtAsync();
if (!string.IsNullOrWhiteSpace(jwt))
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
}
if (body is not null)
{
var json = JsonSerializer.Serialize(body, Json);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
}
using var response = await http.SendAsync(request);
var text = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new AdminApiException((int)response.StatusCode, ExtractError(text, response.ReasonPhrase));
if (typeof(T) == typeof(object) || string.IsNullOrWhiteSpace(text))
return default!;
return JsonSerializer.Deserialize<T>(text, Json)
?? throw new AdminApiException(0, "Server returned an empty response.");
}
private static void AddCloudflareAccessHeaders(HttpRequestMessage request, AdminConnectionSettings connection)
{
if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessClientId) &&
!string.IsNullOrWhiteSpace(connection.CloudflareAccessClientSecret))
{
request.Headers.SetOrReplace("CF-Access-Client-Id", connection.CloudflareAccessClientId);
request.Headers.SetOrReplace("CF-Access-Client-Secret", connection.CloudflareAccessClientSecret);
}
if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessJwt))
request.Headers.SetOrReplace("CF-Access-Jwt-Assertion", connection.CloudflareAccessJwt);
}
private static string ExtractError(string text, string? fallback)
{
if (string.IsNullOrWhiteSpace(text)) return fallback ?? "Request failed.";
try
{
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.TryGetProperty("error", out var error))
return error.GetString() ?? fallback ?? "Request failed.";
if (doc.RootElement.TryGetProperty("message", out var message))
return message.GetString() ?? fallback ?? "Request failed.";
}
catch { }
return text.Length > 300 ? text[..300] : text;
}
}
public sealed class AdminApiException(int statusCode, string message) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}
internal static class HeaderExtensions
{
public static void SetOrReplace(this HttpRequestHeaders headers, string name, string value)
{
headers.Remove(name);
headers.Add(name, value);
}
}
@@ -1,61 +0,0 @@
using Microsoft.Maui.Storage;
namespace DorkNet.AdminMobile.Services;
public sealed record AdminConnectionSettings(
string BaseUrl,
string CloudflareAccessClientId,
string CloudflareAccessClientSecret,
string CloudflareAccessJwt);
public sealed class SecureAdminSettings
{
private const string BaseUrlKey = "admin.baseUrl";
private const string JwtKey = "admin.jwt";
private const string RefreshKey = "admin.refresh";
private const string CfClientIdKey = "cf.access.clientId";
private const string CfClientSecretKey = "cf.access.clientSecret";
private const string CfJwtKey = "cf.access.jwt";
public async Task<AdminConnectionSettings> LoadConnectionAsync()
{
try
{
var baseUrl = await SecureStorage.GetAsync(BaseUrlKey) ?? "https://admin.rec.net";
var cfId = await SecureStorage.GetAsync(CfClientIdKey) ?? string.Empty;
var cfSecret = await SecureStorage.GetAsync(CfClientSecretKey) ?? string.Empty;
var cfJwt = await SecureStorage.GetAsync(CfJwtKey) ?? string.Empty;
return new AdminConnectionSettings(baseUrl, cfId, cfSecret, cfJwt);
}
catch
{
SecureStorage.Remove(BaseUrlKey);
SecureStorage.Remove(CfClientIdKey);
SecureStorage.Remove(CfClientSecretKey);
SecureStorage.Remove(CfJwtKey);
return new AdminConnectionSettings("https://admin.rec.net", string.Empty, string.Empty, string.Empty);
}
}
public async Task SaveConnectionAsync(AdminConnectionSettings settings)
{
await SecureStorage.SetAsync(BaseUrlKey, settings.BaseUrl.TrimEnd('/'));
await SecureStorage.SetAsync(CfClientIdKey, settings.CloudflareAccessClientId.Trim());
await SecureStorage.SetAsync(CfClientSecretKey, settings.CloudflareAccessClientSecret.Trim());
await SecureStorage.SetAsync(CfJwtKey, settings.CloudflareAccessJwt.Trim());
}
public Task<string?> GetJwtAsync() => SecureStorage.GetAsync(JwtKey);
public async Task SaveSessionAsync(string accessToken, string refreshToken)
{
await SecureStorage.SetAsync(JwtKey, accessToken);
await SecureStorage.SetAsync(RefreshKey, refreshToken);
}
public void ClearSession()
{
SecureStorage.Remove(JwtKey);
SecureStorage.Remove(RefreshKey);
}
}
+11 -19
View File
@@ -1,13 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework Condition="'$(TargetFramework)' == '' and '$(TargetFrameworks)' == ''">net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>DorkNet.ClientMod</RootNamespace> <RootNamespace>DorkNet.ClientMod</RootNamespace>
<AssemblyName>DorkNet.ClientMod</AssemblyName> <AssemblyName>DorkNet.ClientMod</AssemblyName>
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' == ''">false</AppendTargetFrameworkToOutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendTargetFrameworkToOutputPath Condition="'$(TargetFrameworks)' != ''">true</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies> <CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn> <NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup> </PropertyGroup>
@@ -20,28 +19,21 @@
they would be derivative works of the game anyway, so we never they would be derivative works of the game anyway, so we never
reference them. Only MelonLoader's own DLLs are needed. reference them. Only MelonLoader's own DLLs are needed.
By default the project downloads the pinned desktop MelonLoader release By default the project downloads the pinned MelonLoader release
into DorkNet.ClientMod/build/ and references its net6 DLLs. For Quest / into DorkNet.ClientMod/build/ and references those DLLs. Pass
LemonLoader builds, pass: -p:MelonLoaderDir=<path-to-extracted-MelonLoader> only when you
deliberately want to use a local override.
-p:TargetFrameworks=net8.0
-p:MelonLoaderDir=<path-to-LemonLoader-melon_data/MelonLoader>
The reference folder follows the target framework unless explicitly
overridden with -p:MelonLoaderReferenceTfm=...
--> -->
<PropertyGroup> <PropertyGroup>
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion> <MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot> <MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)build\melonloader</MelonLoaderCacheRoot>
<MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip> <MelonLoaderZip>$(MelonLoaderCacheRoot)\MelonLoader.x64.$(MelonLoaderVersion).zip</MelonLoaderZip>
<MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir> <MelonLoaderDir Condition="'$(MelonLoaderDir)' == ''">$(MelonLoaderCacheRoot)\MelonLoader</MelonLoaderDir>
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == '' and '$(TargetFramework)' == 'net8.0'">net8</MelonLoaderReferenceTfm>
<MelonLoaderReferenceTfm Condition="'$(MelonLoaderReferenceTfm)' == ''">net6</MelonLoaderReferenceTfm>
</PropertyGroup> </PropertyGroup>
<Target Name="EnsureMelonLoaderReferences" <Target Name="EnsureMelonLoaderReferences"
BeforeTargets="ResolveAssemblyReferences" BeforeTargets="ResolveAssemblyReferences"
Condition="'$(MelonLoaderReferenceTfm)' == 'net6' and !Exists('$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll')"> Condition="!Exists('$(MelonLoaderDir)\net6\MelonLoader.dll')">
<MakeDir Directories="$(MelonLoaderCacheRoot)" /> <MakeDir Directories="$(MelonLoaderCacheRoot)" />
<DownloadFile <DownloadFile
SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip" SourceUrl="https://github.com/LavaGang/MelonLoader/releases/download/$(MelonLoaderVersion)/MelonLoader.x64.zip"
@@ -55,19 +47,19 @@
<ItemGroup> <ItemGroup>
<Reference Include="MelonLoader"> <Reference Include="MelonLoader">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\MelonLoader.dll</HintPath> <HintPath>$(MelonLoaderDir)\net6\MelonLoader.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
<Reference Include="0Harmony"> <Reference Include="0Harmony">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\0Harmony.dll</HintPath> <HintPath>$(MelonLoaderDir)\net6\0Harmony.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
<Reference Include="Il2CppInterop.Runtime"> <Reference Include="Il2CppInterop.Runtime">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Runtime.dll</HintPath> <HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Runtime.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
<Reference Include="Il2CppInterop.Common"> <Reference Include="Il2CppInterop.Common">
<HintPath>$(MelonLoaderDir)\$(MelonLoaderReferenceTfm)\Il2CppInterop.Common.dll</HintPath> <HintPath>$(MelonLoaderDir)\net6\Il2CppInterop.Common.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
</ItemGroup> </ItemGroup>
+176 -1474
View File
@@ -43,11 +43,10 @@ public class Mod : MelonMod
private static readonly Dictionary<string, Type> ResolvedTypeCache = new(); private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
private readonly HashSet<string> _diagnosticPatchLabels = new(); private readonly HashSet<string> _diagnosticPatchLabels = new();
private bool _networkPatchesRegistered; private bool _networkPatchesRegistered;
private bool _joinTracePatchesRegistered;
private bool _diagnosticCoreRegistered; private bool _diagnosticCoreRegistered;
private bool _diagnosticGameComplete; private bool _diagnosticGameComplete;
private int _diagnosticRetryFrame; private int _diagnosticRetryFrame;
private bool _firstUpdateLogged; private int _photonOverridePollFrame;
// Config backed by a plain JSON file under MelonLoader/UserData. // Config backed by a plain JSON file under MelonLoader/UserData.
// The defaults below match the recommended install, so dropping this // The defaults below match the recommended install, so dropping this
@@ -55,6 +54,7 @@ public class Mod : MelonMod
public static class Cfg public static class Cfg
{ {
public static string ServerHost = "localhost"; public static string ServerHost = "localhost";
public static string SingleOriginBaseUrl = "";
public static string PhotonAppId = ""; public static string PhotonAppId = "";
public static string PhotonVoiceAppId = ""; public static string PhotonVoiceAppId = "";
// Forced PhotonServerSettings.PreferredRegion (with HostType // Forced PhotonServerSettings.PreferredRegion (with HostType
@@ -67,19 +67,9 @@ public class Mod : MelonMod
// any /goto has returned. Default matches the server's // any /goto has returned. Default matches the server's
// appsettings.json Photon:CloudRegion ("eu"). // appsettings.json Photon:CloudRegion ("eu").
public static string PhotonCloudRegion = "eu"; public static string PhotonCloudRegion = "eu";
// Photon Custom Auth injector parked 2026-05-28; see
// attic/AuthValuesInjector.cs.attic for the code + restore notes.
public static bool EnableTlsTrustBypass = true; public static bool EnableTlsTrustBypass = true;
// Log-only trace for the stuck first-dorm "Change Username?" // Photon Custom Auth injector was parked 2026-05-28; see
// dialog. When true, RegistrationPatches hooks the dorm // attic/AuthValuesInjector.cs.attic for the code + restore notes.
// registration-prompt flow + the confirm-dialog buttons and writes
// what fires to MelonLoader/UserData/dorknet-diagnostics.log, so a
// brand-new account that reproduces the freeze tells us exactly
// where it breaks (does the click reach Button_Affirmative? does
// the prompt even run?). No behaviour change — purely diagnostic.
// See RegistrationPatches and the registration site in
// OnLateInitializeMelon.
public static bool TraceRegistrationDialog = true;
// Force-enable the client's built-in RecRoom.Debugging.DebugConsole // Force-enable the client's built-in RecRoom.Debugging.DebugConsole
// (normally dev-gated) and silence CheatManager's tamper detectors // (normally dev-gated) and silence CheatManager's tamper detectors
// so movement/time commands (SetTimeScale, Fly, Teleport, …) don't // so movement/time commands (SetTimeScale, Fly, Teleport, …) don't
@@ -87,68 +77,6 @@ public class Mod : MelonMod
// is a UnityEngine.KeyCode name; BackQuote is the `~` key. // is a UnityEngine.KeyCode name; BackQuote is the `~` key.
public static bool EnableDebugConsole = false; public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote"; public static string DebugConsoleToggleKey = "BackQuote";
// ── Desktop Screen Sharing gadget FPS ──
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts
// the shared desktop image. Its cadence is a baked [SerializeField]
// float screenShareImageRefreshFrequency — the broadcast tick gates
// on (Time.time - lastSend >= 1f / frequency), verified at
// DesktopScreenSharingDisplay.txt:697-703, so the field IS the FPS
// and its editor-only [Range] is NOT enforced at runtime. Set a
// target here to override it (default prefab value is low, ~5).
// 0 = leave the prefab value alone (patch not even registered).
public static float DesktopScreenShareFps = 0f;
// The captured frame ships via the gadget's IPunObservable
// OnPhotonSerializeView, which only fires at
// PhotonNetwork.SerializationRate (default 10 Hz) — so 30 fps capture
// still delivers ~10 unless we raise it. This lifts SendRate +
// SerializationRate to >= the FPS target. GLOBAL: affects every
// PhotonView's sync traffic, so turn off if it lags a busy room.
public static bool DesktopScreenShareRaisePhotonRate = true;
// Optional per-frame size knobs (0 = leave the prefab value). Higher
// FPS needs smaller frames to fit the Photon serialization budget;
// lower these if 30 fps saturates bandwidth (chunked frames stall).
public static int DesktopScreenShareResolution = 0; // horizontal px
public static int DesktopScreenShareQuality = 0; // JPEG quality
// One-shot dev-UI diagnostic (no behaviour change). When true,
// ~5s after load the mod logs whether the watch's native developer
// menu content actually exists in this build (HomeScreenFlow.
// devMenuPanel / devButtonPrefab present vs NULL), the dev-flow list
// counts, and whether RecRoom.Debugging.DebugConsole is in the scene
// + whether its static Execute() command path works. This tells us
// stripped-vs-gated so we know whether to un-gate the game's own dev
// menu or build a custom watch entry. Output goes to the MelonLoader
// console + dorknet-diagnostics.log.
public static bool DiagnoseDevMenu = false;
// Inject a "Dev Console" button into the watch's native dev-menu
// panel (HomeScreenFlow.devMenuPanel) by cloning the game's own
// devButtonPrefab. The panel/prefab exist in this build (just gated
// off), so we instantiate + activate + label, then wire its click to
// run console commands. Native Button3D → works in VR and Screen Mode.
public static bool EnableDevWatchButton = false;
// Preset dev commands shown as watch buttons (when EnableDevWatchButton
// is on). Each entry is "Label=command"; clicking runs
// DebugConsole.Execute(command). Edit to match this build's actual
// DebugConsoleCommandConfig command names/args.
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
// Raise RRO quest party size past the baked 4. The cap lives in each
// quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize
// ScriptableObject (baked in the client bundle; no server lever). The
// quest scoreboard + party HUD build rows dynamically, so they
// auto-scale once the team cap is raised. 0 = off (leave at 4).
// Only single-team, quest-named configs are touched, so PvP team
// sizes (paintball/laser tag) are never changed. See QuestTeamSize.
public static int QuestMaxTeamSize = 0;
// Only these RRO quest GameConfigurationAssets get their team size
// bumped (matched loosely: case/space-insensitive, filler words like
// "the/of/for/quest" ignored, so "IsleOfLostSkulls" matches the
// in-game "Isle of the Lost Skulls"). Empty = bump nothing. The
// generic "Quest" config is intentionally NOT in the default list.
public static string[] QuestMaxTeamSizeRooms =
{ "CrimsonCauldron", "Crescendo", "GoldenTrophy", "IsleOfLostSkulls", "TheRiseofJumbotron" };
} }
public override void OnInitializeMelon() public override void OnInitializeMelon()
@@ -167,53 +95,14 @@ public class Mod : MelonMod
RegisterNetworkPatches(); RegisterNetworkPatches();
TryPatchByName("PhotonNetwork", "ConnectUsingSettings", TryPatchByName("PhotonNetwork", "ConnectUsingSettings",
prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix)); prefix: nameof(PhotonPatches.PhotonAppIdOverride_Prefix));
// Hook the watch's own AppSettings + AuthValues builders directly. // NetworkingPeer.CallAuthenticate hook (Photon Custom Auth
// Both are private PUNNetworkManager methods with obfuscated 2020.12 // injector) parked — see attic/AuthValuesInjector.cs.attic.
// names — we know they always run before any Photon connect because TryPatchByName("BestHTTP.SecureProtocol.Org.BouncyCastle.Crypto.Tls.ServerOnlyTlsAuthentication",
// ConnectUsingSettings reads their return values. Postfix logs them "NotifyServerCertificate",
// after construction. prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
// TryPatchByName("Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication",
// FJOLIPKKIBE returns Photon.Realtime.AppSettings — verified at "NotifyServerCertificate",
// PUNNetworkManager.txt:7046. One bool arg (DOJLKMCEFFB — likely prefix: nameof(TlsPatches.NotifyServerCertificate_Prefix));
// "for voice"). Two call paths in the binary.
// EMGGKBALALH returns Photon.Realtime.AuthenticationValues —
// verified at PUNNetworkManager.txt:7560. Builds AuthValues with
// environment + accessToken AuthGetParameters.
//
// Quest-Dec uses the same obfuscation seed as PC-Dec, but discover by
// return type first so a future platform/reseed does not make these
// two critical hooks depend on the private method names.
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AppSettings",
fallbackMethodName: "FJOLIPKKIBE",
postfix: nameof(PhotonPatches.LogAppSettings_Postfix));
TryPatchDeclaredMethodByReturnType("PUNNetworkManager", "AuthenticationValues",
fallbackMethodName: "EMGGKBALALH",
postfix: nameof(PhotonPatches.LogAuthValues_Postfix));
// PUNNetworkManager.OnCustomAuthenticationFailed — accepts whatever
// string-like arg the IL2CPP method takes (declared `string` in
// dump.cs but Il2CppInterop wraps it in a way that breaks
// Harmony's IL emit when we declare `string` directly). Using
// `object` lets Harmony pass through whatever wrapper the IL2CPP
// proxy uses without compile-error.
TryPatchByName("PUNNetworkManager", "OnCustomAuthenticationFailed",
prefix: nameof(PhotonPatches.OnCustomAuthenticationFailed_Prefix));
// TLS cert-validation bypass — gated behind Cfg.EnableTlsTrustBypass.
// Even when the prefix's return value is "let original run", the
// detour itself still has to be JIT-installed when BouncyCastle
// does its first handshake, and MonoMod's CompileMethodHook
// fatal-CLRs (0x80131506) installing trampolines into these
// third-party interface implementations. Only register the
// detours if the bypass is actually wanted; on a setup with
// valid certs (the default) skip the patch entirely.
if (Cfg.EnableTlsTrustBypass)
{
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 // Post-save blob-name refresh. The 2020 watch's
// RoomPersistenceManager.RoomDataBlobName is only ever set by // RoomPersistenceManager.RoomDataBlobName is only ever set by
// /goto-driven flows — neither the SubscriptionUpdateRoom push // /goto-driven flows — neither the SubscriptionUpdateRoom push
@@ -301,236 +190,21 @@ public class Mod : MelonMod
"RpcChatEmote", "RpcChatEmote",
args: new[] { typeof(string) }, args: new[] { typeof(string) },
prefix: nameof(ChatPatches.RpcChatEmote_Prefix)); prefix: nameof(ChatPatches.RpcChatEmote_Prefix));
// Debug console: skip CheatManager's tamper detectors so the
// Diagnostic trace for the stuck first-dorm "Change Username?" // console's movement/time commands don't trip a dorm-drop. The
// dialog (new accounts can't get past it; "Okay" highlights but // console UI toggle itself is driven from OnUpdate (hotkey) and
// never dismisses the modal, on every device). This is LOG-ONLY — // resolved lazily — only the anti-cheat skip needs a patch.
// no behaviour change. It bisects the failure for a brand-new if (Cfg.EnableDebugConsole)
// account that reproduces the freeze, writing to
// MelonLoader/UserData/dorknet-diagnostics.log:
//
// 1. RegistrationModel.IsFullyRegistered() return value — if
// false, the dorm prompt path runs; if true, no prompt (so a
// freeze here would be something else entirely).
// 2. DormroomSceneManager.<PromptForRegistration>b__6_* lambdas —
// which prompt branch executed (b__6_1 = a dialog was raised).
// 3. ConfirmUIDialog.Button_Affirmative/Negative/NotNow/Cancel —
// whether the click actually reaches the dialog's button
// handler when the user taps Okay. If these FIRE but the modal
// stays, the break is in the resolve/hide completer
// (UIDialog`1.KHKDGPHPIND's guard); if they DON'T fire, the
// click never routes to the button (input/raycast) and the fix
// is elsewhere. Either way the next step is unambiguous.
if (Cfg.TraceRegistrationDialog)
{ {
TryPatchByName("RRUI.Data.NUX.RegistrationModel", foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
"IsFullyRegistered", TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
args: Type.EmptyTypes, Log.Msg($"[debugconsole] enabled — CheatManager detectors silenced; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
postfix: nameof(RegistrationPatches.IsFullyRegistered_Postfix));
// The two registration dialogs are raised from
// DormroomSceneManager's PromptForRegistration via its
// compiler-generated <>c lambdas (b__6_0..b__6_3). Reuse the
// same nested-lambda patcher the join trace uses.
PatchNestedLambdas("DormroomSceneManager", "PromptForRegistration",
nameof(RegistrationPatches.PromptLambda_Prefix));
// The confirm-dialog buttons. Clean, non-generic, no-arg names
// on AGUI.StackedUI.Dialog.ConfirmUIDialog — Okay maps to
// Button_Affirmative, the screenshot's only button.
foreach (var (method, label) in new[]
{
("Button_Affirmative", nameof(RegistrationPatches.ButtonAffirmative_Prefix)),
("Button_Negative", nameof(RegistrationPatches.ButtonNegative_Prefix)),
("Button_NotNow", nameof(RegistrationPatches.ButtonNotNow_Prefix)),
("Button_Cancel", nameof(RegistrationPatches.ButtonCancel_Prefix)),
})
{
TryPatchByName("AGUI.StackedUI.Dialog.ConfirmUIDialog",
method, args: Type.EmptyTypes, prefix: label);
}
}
RegisterJoinTracePatches();
// Desktop Screen Sharing FPS override. Only register when a target
// is set; the postfix on the gadget's Awake rewrites the baked
// refresh-frequency field (and, if enabled, raises the Photon
// serialization rate so the higher capture rate actually transmits).
if (Cfg.DesktopScreenShareFps > 0f)
{
TryPatchByName("RecRoom.Tools.Productivity.DesktopScreenSharingDisplay",
"Awake", args: Type.EmptyTypes,
postfix: nameof(ScreenSharePatches.DisplayAwake_Postfix));
Log.Msg($"[screenshare] override armed: target {Cfg.DesktopScreenShareFps} fps " +
$"(raisePhotonRate={Cfg.DesktopScreenShareRaisePhotonRate})");
}
TryPatchByName("RecRoom.Tools.MakerPenVisuals",
"set_LaserPointerEnabled",
args: new[] { typeof(bool) },
prefix: nameof(MakerPenGiftPreviewPatches.LaserPointerEnabled_Prefix));
// RRO quest party-size bump. Prefix the runtime config-apply path so
// we can read the config's Name (scope to the allowlisted quests) and
// bump GameConfigurationData.TeamConfigurations[].TeamSize before the
// team manager / pre-game roster reads it. Only when a target is set.
if (Cfg.QuestMaxTeamSize > 0)
{
// The runtime team cap is built from the baked GameConfigurationAsset
// (a readable ScriptableObject) by GameConfigurationAsset.GDECPANDBHJ()
// — it reads asset.TeamConfigurations[i].MaxTeamSize (a typed int
// struct-array) into the protobuf TeamSize. Prefix that builder and
// bump the source array IN PLACE before it converts, scoped by
// asset.Name. This avoids the obfuscated protobuf entirely (its
// generic RepeatedField getter isn't emitted by Il2CppInterop).
// Host-authoritative: the quest host must run the mod. See QuestTeamSize.
TryPatchByName("RecRoom.Core.GameManagement.GameConfigurationAsset", "GDECPANDBHJ",
prefix: nameof(QuestTeamSize.Build_Prefix));
QuestTeamSize.TryPatchSpawnIndexNormalizers(HarmonyInstance);
TryPatchByName("GameSpawnManager", "FKEICBPCMPJ",
postfix: nameof(QuestTeamSize.RelaxEmptySpawnFilter_Postfix));
Log.Msg($"[questsize] armed: target {Cfg.QuestMaxTeamSize} for [{string.Join(", ", Cfg.QuestMaxTeamSizeRooms)}]");
} }
RegisterDiagnostics(); RegisterDiagnostics();
Log.Msg("=== Client patches registered ==="); Log.Msg("=== Client patches registered ===");
} }
// ── Room-join + watch-button trace ────────────────────────────────
// Traces the full "press the Dorm Room button → join the room" chain
// so a stuck join (e.g. the watch's Dorm Room button doing nothing)
// is debuggable from the log alone. The chain, verified against the
// 2020.12.18 IL2CPP dump:
//
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() (button)
// → SessionManager.RunJoinRoom("DormRoom", …) (join entry)
// → OJMCBOKJFOF.NHBPIIGDAJP(name) → GET /goto/room/{name}
// rejects with "No such room" (OJMCBOKJFOF.txt:958-1019)
// → .Then(SessionManager.RunJoinRoom(RoomInstance, …))
// → on reject: SessionManager+<>c.<RunJoinRoom>b__165_1(error)
// → NotificationManager.Play("An error occured", "…happyfox…")
// (the "contact recroom.happyfox.com" toast)
//
// We can't express the obfuscated RunJoinRoom param types as a Type[]
// for AccessTools, so every RunJoinRoom overload + every /goto overload
// is patched by iterating declared methods by name, and the prefixes
// read arguments generically via Harmony's `object[] __args`.
private void RegisterJoinTracePatches()
{
if (_joinTracePatchesRegistered) return;
Log.Msg("=== Registering room-join trace ===");
// Watch "Dorm Room" button. Void, no params, unique name —
// HomeScreenFlow.txt:4791, calls RunJoinRoom("DormRoom",…) at :4848.
TryPatchByName("AGUI.StackedUI.HomeScreenFlow", "Button_DormRoom",
args: Type.EmptyTypes,
prefix: nameof(JoinPatches.ButtonDormRoom_Prefix));
// Every SessionManager.RunJoinRoom overload (string-name entry +
// the RoomInstance inner legs). Logs the method + its arguments.
PatchAllOverloads("SessionManager", "RunJoinRoom",
nameof(JoinPatches.RunJoinRoom_Prefix));
// The /goto/room/{name|id} promise builders — both the string
// (room-name) and Int64 (room-id) overloads of NHBPIIGDAJP.
PatchAllOverloads("OJMCBOKJFOF", "NHBPIIGDAJP",
nameof(JoinPatches.Goto_Prefix));
// The RunJoinRoom promise-chain callbacks compiled onto the
// SessionManager <>c display class — most importantly the reject
// handler b__165_1(string error) that raises the happyfox toast.
// Match by the (de-mangled) substring "RunJoinRoom" + the lambda
// marker "b__" so we hit the callbacks but not the RunJoinRoom
// state-machine MoveNext.
PatchNestedLambdas("SessionManager", "RunJoinRoom",
nameof(JoinPatches.JoinCallback_Prefix));
// Additionally capture the REJECT REASON string on b__165_1 (the
// happyfox-toast handler). Matches only that one lambda; it ends
// up with two prefixes (flow + error string), which is harmless.
PatchNestedLambdas("SessionManager", "RunJoinRoom_b__165_1",
nameof(JoinPatches.JoinError_Prefix));
_joinTracePatchesRegistered = true;
Log.Msg("=== Room-join trace registered ===");
}
// Patch every declared method on `typeName` named `methodName`,
// regardless of signature, with a single prefix. Used for obfuscated
// overload sets whose param types we can't name in a Type[].
private bool PatchAllOverloads(string typeName, string methodName, string prefix, bool logMiss = true)
{
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): type not found");
return false;
}
var prefixMethod = GetPatchMethod(prefix);
var patched = 0;
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.Name != methodName) continue;
if (method.IsAbstract || method.ContainsGenericParameters) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}.{methodName} overload: {ex.Message}");
}
}
if (patched == 0)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}.{methodName}(*): no overloads matched");
return false;
}
Log.Msg($"[patch-ok] {typeName}.{methodName}(*) x{patched}");
return true;
}
// Patch compiler-generated lambda methods (names containing "b__")
// on the nested display classes of `typeName` whose (de-mangled) name
// also contains `nameContains`. Il2CppInterop mangles the angle
// brackets of `<RunJoinRoom>b__165_1` to `_003C…_003E` but leaves the
// method name itself intact, so a plain substring match still works.
private bool PatchNestedLambdas(string typeName, string nameContains, string prefix, bool logMiss = true)
{
var type = ResolveType(typeName);
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): type not found");
return false;
}
var prefixMethod = GetPatchMethod(prefix);
var patched = 0;
foreach (var nested in type.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic))
{
foreach (var method in AccessTools.GetDeclaredMethods(nested))
{
if (method.IsAbstract || method.ContainsGenericParameters) continue;
if (method.Name.IndexOf("b__", StringComparison.Ordinal) < 0) continue;
if (method.Name.IndexOf(nameContains, StringComparison.Ordinal) < 0) continue;
try
{
HarmonyInstance.Patch(method, prefix: new HarmonyMethod(prefixMethod));
patched++;
Log.Msg($"[patch-ok] {typeName}+{nested.Name}.{method.Name}");
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}+{nested.Name}.{method.Name}: {ex.Message}");
}
}
}
if (patched == 0 && logMiss)
Log.Warning($"[patch-miss] {typeName}+<lambdas>({nameContains}): none matched");
return patched > 0;
}
private void RegisterNetworkPatches() private void RegisterNetworkPatches()
{ {
if (_networkPatchesRegistered) return; if (_networkPatchesRegistered) return;
@@ -584,11 +258,19 @@ public class Mod : MelonMod
public override void OnUpdate() public override void OnUpdate()
{ {
if (!_firstUpdateLogged) // Photon AppId override poller. The harmony hook on
{ // PhotonNetwork.ConnectUsingSettings registers in IL2CPP but
_firstUpdateLogged = true; // doesn't actually fire in this build (verified in the
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)"); // 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 (_diagnosticGameComplete) return;
if (_diagnosticRetryFrame++ > 3600) return; if (_diagnosticRetryFrame++ > 3600) return;
@@ -610,7 +292,17 @@ public class Mod : MelonMod
{ {
try try
{ {
var userData = ResolveUserDataDirectory(); // 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); Directory.CreateDirectory(userData);
DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log"); DiagnosticsLogPath = Path.Combine(userData, "dorknet-diagnostics.log");
var path = Path.Combine(userData, "dorknet-clientmod.json"); var path = Path.Combine(userData, "dorknet-clientmod.json");
@@ -621,42 +313,17 @@ public class Mod : MelonMod
} }
var doc = JsonDocument.Parse(File.ReadAllText(path)); var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement; var r = doc.RootElement;
if (TryGetConfigValue(r, "ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost; if (r.TryGetProperty("ServerHost", out var v)) Cfg.ServerHost = v.GetString() ?? Cfg.ServerHost;
if (TryGetConfigValue(r, "PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId; if (r.TryGetProperty("SingleOriginBaseUrl", out v)) Cfg.SingleOriginBaseUrl = (v.GetString() ?? "").TrimEnd('/');
if (TryGetConfigValue(r, "PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId; if (r.TryGetProperty("PhotonAppId", out v)) Cfg.PhotonAppId = v.GetString() ?? Cfg.PhotonAppId;
if (TryGetConfigValue(r, "PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion; if (r.TryGetProperty("PhotonVoiceAppId", out v)) Cfg.PhotonVoiceAppId = v.GetString() ?? Cfg.PhotonVoiceAppId;
if (TryGetConfigValue(r, "EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean(); if (r.TryGetProperty("PhotonCloudRegion", out v)) Cfg.PhotonCloudRegion = v.GetString() ?? Cfg.PhotonCloudRegion;
if (TryGetConfigValue(r, "TraceRegistrationDialog", out v)) Cfg.TraceRegistrationDialog = v.GetBoolean(); if (r.TryGetProperty("EnableTlsTrustBypass", out v)) Cfg.EnableTlsTrustBypass = v.GetBoolean();
if (TryGetConfigValue(r, "EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean(); if (r.TryGetProperty("EnableDebugConsole", out v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (TryGetConfigValue(r, "DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey; if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
if (TryGetConfigValue(r, "DesktopScreenShareFps", out v)) Cfg.DesktopScreenShareFps = (float)v.GetDouble();
if (TryGetConfigValue(r, "DesktopScreenShareRaisePhotonRate", out v)) Cfg.DesktopScreenShareRaisePhotonRate = v.GetBoolean();
if (TryGetConfigValue(r, "DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
if (TryGetConfigValue(r, "DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
if (TryGetConfigValue(r, "DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
if (TryGetConfigValue(r, "QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
if (TryGetConfigValue(r, "QuestMaxTeamSizeRooms", out v) && v.ValueKind == JsonValueKind.Array)
{
var rooms = new List<string>();
foreach (var e in v.EnumerateArray())
if (e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString()))
rooms.Add(e.GetString()!);
Cfg.QuestMaxTeamSizeRooms = rooms.ToArray(); // explicit (incl. empty = none)
}
if (TryGetConfigValue(r, "EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
if (TryGetConfigValue(r, "DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
{
var list = new List<string>();
foreach (var e in v.EnumerateArray())
{
var s = e.GetString();
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
}
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
}
// "InjectAuthValues" key in the template is now ignored — // "InjectAuthValues" key in the template is now ignored —
// see attic/AuthValuesInjector.cs.attic. // see attic/AuthValuesInjector.cs.attic.
Log.Msg($"[config] loaded from {path}: ServerHost={Cfg.ServerHost}, PhotonAppId={(string.IsNullOrEmpty(Cfg.PhotonAppId) ? "<unset>" : "<set>")}, " + 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}"); $"EnableTlsTrustBypass={Cfg.EnableTlsTrustBypass}");
} }
catch (Exception ex) catch (Exception ex)
@@ -665,48 +332,9 @@ public class Mod : MelonMod
} }
} }
private static string ResolveUserDataDirectory()
{
foreach (var typeName in new[]
{
"MelonLoader.MelonEnvironment, MelonLoader",
"MelonLoader.MelonUtils, MelonLoader",
})
{
var type = Type.GetType(typeName);
var prop = type?.GetProperty("UserDataDirectory", BindingFlags.Public | BindingFlags.Static);
if (prop?.GetValue(null) is string value && !string.IsNullOrWhiteSpace(value))
return value;
}
var modDll = Assembly.GetExecutingAssembly().Location;
var modsDir = Path.GetDirectoryName(modDll) ?? string.Empty;
var gameDir = Path.GetDirectoryName(modsDir) ?? AppContext.BaseDirectory;
return Path.Combine(gameDir, "MelonLoader", "UserData");
}
private static bool TryGetConfigValue(JsonElement root, string name, out JsonElement value)
{
if (root.ValueKind == JsonValueKind.Object)
{
foreach (var prop in root.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
}
value = default;
return false;
}
// ── Patch dispatch helpers ──────────────────────────────────────── // ── Patch dispatch helpers ────────────────────────────────────────
private bool TryPatch(string label, Type target, string methodName, private bool TryPatch(string label, Type target, string methodName,
Type[]? args, string? prefix = null, string? postfix = null, Type[]? args, string? prefix = null, string? postfix = null)
bool logMiss = true)
{ {
try try
{ {
@@ -716,12 +344,7 @@ public class Mod : MelonMod
: AccessTools.Method(target, methodName, args)); : AccessTools.Method(target, methodName, args));
if (method is null) if (method is null)
{ {
// `logMiss` is throttled by the retry-loop caller — once per Log.Warning($"[patch-miss] {label}");
// 5 seconds in OnUpdate, every time in the one-shot initial
// RegisterDiagnostics. Without the gate the per-second retry
// loop would spam `[patch-miss]` lines forever for any method
// whose name drifted in a newer game build.
if (logMiss) Log.Warning($"[patch-miss] {label}");
return false; return false;
} }
HarmonyInstance.Patch(method, HarmonyInstance.Patch(method,
@@ -754,59 +377,7 @@ public class Mod : MelonMod
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found"); if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return false; return false;
} }
return TryPatch(label, type, methodName, args, prefix, postfix, logMiss); return TryPatch(label, type, methodName, args, prefix, postfix);
}
private bool TryPatchDeclaredMethodByReturnType(string typeName, string returnTypeName,
string fallbackMethodName,
string? prefix = null, string? postfix = null,
bool logMiss = true)
{
var type = ResolveType(typeName);
var label = $"{typeName}.<returns {returnTypeName}>";
if (type is null)
{
if (logMiss) Log.Warning($"[patch-miss] {label}: type not found");
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
}
var matches = new List<MethodInfo>();
foreach (var method in AccessTools.GetDeclaredMethods(type))
{
if (method.IsAbstract || method.ContainsGenericParameters || method.IsSpecialName) continue;
if (ReturnTypeMatches(method.ReturnType, returnTypeName)) matches.Add(method);
}
if (matches.Count == 1)
{
var method = matches[0];
try
{
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] {typeName}.{method.Name} (returns {returnTypeName})");
return true;
}
catch (Exception ex)
{
Log.Error($"[patch-fail] {typeName}.{method.Name} (returns {returnTypeName}): {ex.Message}");
return false;
}
}
if (matches.Count > 1)
{
var names = new List<string>();
foreach (var method in matches) names.Add(method.Name);
Log.Warning($"[patch-ambiguous] {label}: {string.Join(", ", names)}; falling back to {fallbackMethodName}");
}
else if (logMiss)
{
Log.Warning($"[patch-miss] {label}; falling back to {fallbackMethodName}");
}
return TryPatchByName(typeName, fallbackMethodName, args: null, prefix, postfix, logMiss);
} }
internal static Type? ResolveType(string name) internal static Type? ResolveType(string name)
@@ -881,15 +452,6 @@ public class Mod : MelonMod
type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true; type.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true;
} }
private static bool ReturnTypeMatches(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) private static string FormatPatchLabel(string typeName, string methodName, Type[]? args)
{ {
if (args is null) return $"{typeName}.{methodName}"; if (args is null) return $"{typeName}.{methodName}";
@@ -903,7 +465,7 @@ public class Mod : MelonMod
{ {
// Look in all three patch holder classes — small enough that a // Look in all three patch holder classes — small enough that a
// linear scan is cheaper than per-class lookups. // 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(JoinPatches), typeof(RegistrationPatches), typeof(DebugConsolePatches), typeof(ScreenSharePatches), typeof(MakerPenGiftPreviewPatches), typeof(QuestTeamSize) }) 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); var m = holder.GetMethod(name, BindingFlags.Public | BindingFlags.Static);
if (m is not null) return m; if (m is not null) return m;
@@ -930,24 +492,21 @@ public class Mod : MelonMod
private bool RegisterGameDiagnostics(bool logMisses) private bool RegisterGameDiagnostics(bool logMisses)
{ {
var complete = true; var complete = true;
complete &= TryPatchDiagnosticByName("BootSequence", "RegisterError", new[] { typeof(string) },
prefix: nameof(DiagnosticPatches.BootSequenceRegisterError_Prefix),
logMiss: logMisses);
// CheatManager fires these when its anti-cheat heuristics trip —
// logging them surfaces "client thinks it's been tampered with",
// a common cause of silent dorm-drops / kicks on a modded client.
// Names verified in CheatManager.txt for 2020.12.18; the older
// build's "AnalyticsHelper.<X>Cheat" names don't exist here, and
// there's no DeveloperFlag/StreamingAsset callback nor a
// BootSequence.RegisterError sink in this build — those legacy
// hooks were removed (they only ever logged [patch-miss] forever).
foreach (var method in new[] foreach (var method in new[]
{ {
"OnTimeCheatDetected", "UnityTimeCheat",
"OnObscuredTypeCheatDetected", "ObscuredTypeCheat",
"OnHeightCheatDetected", "DeveloperFlagCheat",
"OnAdvancedMovementCheatDetected", "HeightChangeCheat",
"AdvancedMotionCheat",
"StreamingAssetCheat"
}) })
{ {
complete &= TryPatchDiagnosticByName("CheatManager", method, complete &= TryPatchDiagnosticByName("AnalyticsHelper", method,
prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix), prefix: nameof(DiagnosticPatches.AnalyticsCheat_Prefix),
logMiss: logMisses); logMiss: logMisses);
} }
@@ -955,14 +514,15 @@ public class Mod : MelonMod
complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit", complete &= TryPatchDiagnosticByName("SessionManager", "TryApplicationQuit",
prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix), prefix: nameof(DiagnosticPatches.SessionManagerTryApplicationQuit_Prefix),
logMiss: logMisses); logMiss: logMisses);
// SteamManager.Awake + SteamPlatformManager.*LoginInitialize were complete &= TryPatchDiagnosticByName("SteamManager", "Awake",
// pure-logging diagnostics that fired during Unity's very first prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
// wake-up tick. On december (EAC build, IL2CPP wrapped Steam logMiss: logMisses);
// methods) MonoMod's lazy CompileMethodHook on the trampoline complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PreLoginInitialize",
// crashes the CLR with 0x80131506 the instant Awake JITs. The prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
// hooks gave us no behavioral leverage — only a `[call] …` log logMiss: logMisses);
// line — so they're disabled. If anyone needs Steam-side visibility complete &= TryPatchDiagnosticByName("SteamPlatformManager", "PostLoginInitialize",
// later, route it through the game's own log lines instead. prefix: nameof(DiagnosticPatches.NamedMethod_Prefix),
logMiss: logMisses);
return complete; return complete;
} }
@@ -1183,543 +743,6 @@ internal static class DebugConsolePatches
} }
} }
// ── Dev-menu runtime probe (one-shot diagnostic) ────────────────────────
// Answers stripped-vs-gated for the watch developer menu. With a real dev
// account + forced flag the menu still doesn't appear, so the gate isn't the
// account flag — either the native dev-menu prefab content is stripped from
// this retail build, or a build-level flag skips its init. This logs the
// actual runtime state once ~5s after load (no behaviour change) so we know
// whether to un-gate the game's own dev menu or build our own watch entry.
// Called from OnUpdate when Cfg.DiagnoseDevMenu is set; self-gates to run
// once. Not a Harmony holder — invoked directly, so it's not registered in
// GetPatchMethod.
internal static class DevMenuProbe
{
private static int _frame;
private static bool _done;
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 120) return; // ~2s — let MelonLoader/game boot
if ((_frame % 60) != 0) return; // then check ~once per second
// Only fire once a REAL, in-scene HomeScreenFlow exists with its
// UIBase wired (it gets assigned when the watch StackedUI initialises
// in the dorm). The earlier version fired during boot on the loaded
// PREFAB (Resources.FindObjectsOfTypeAll returns prefabs/inactive,
// whose runtime UIBase is null) and, being one-shot, captured nothing
// and never retried. NO timeout now — we wait until the watch is up.
var homeType = Mod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? Mod.ResolveType("HomeScreenFlow");
var home = homeType != null ? FindLiveHome(homeType) : null;
if (home == null)
{
if ((_frame % 600) == 0)
Mod.Log.Msg("[devmenu-probe] waiting for an in-scene HomeScreenFlow — enter your dorm / open the watch…");
return;
}
_done = true;
try { Run(homeType!, home); }
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] failed: {ex}"); }
}
// The live HomeScreenFlow (UIBase wired), skipping the prefab/inactive
// copies FindObjectsOfTypeAll also returns.
private static object? FindLiveHome(Type homeType)
{
try
{
var il2 = ToIl2CppType(homeType);
if (il2 is null) return null;
var resources = Mod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2)?.Invoke(null, new[] { il2 });
int n = ArrLen(all);
for (int i = 0; i < n; i++)
{
var o = ArrItem(all, i);
if (o is null) continue;
if (GetMemberValue(homeType, o, "UIBase", "get_UIBase") != null) return o;
}
}
catch { }
return null;
}
private static void Run(Type homeType, object homeInst)
{
Mod.Log.Msg("=== [devmenu-probe] runtime dev-UI state ===");
ProbeType("RecRoom.Core.WatchUI", "WatchUI", DumpWatchUi);
Mod.Log.Msg($"[devmenu-probe] HomeScreenFlow: type={homeType.FullName}, liveInstance=FOUND (UIBase wired)");
try { DumpHomeScreen(homeType, homeInst); }
catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] HomeScreenFlow dump failed: {ex.Message}"); }
ProbeType("RecRoom.Debugging.DebugConsole", "DebugConsole", DumpDebugConsole);
Mod.Log.Msg("=== [devmenu-probe] done ===");
}
private static void ProbeType(string fullName, string shortName, Action<Type, object?> dump)
{
var t = Mod.ResolveType(fullName) ?? Mod.ResolveType(shortName);
if (t is null) { Mod.Log.Msg($"[devmenu-probe] {shortName}: TYPE NOT FOUND"); return; }
var inst = FindOne(t);
Mod.Log.Msg($"[devmenu-probe] {shortName}: type={t.FullName}, liveInstance={(inst is null ? "null" : "FOUND")}");
try { dump(t, inst); } catch (Exception ex) { Mod.Log.Warning($"[devmenu-probe] {shortName} dump failed: {ex.Message}"); }
}
private static void DumpHomeScreen(Type t, object? inst)
{
if (inst is null) { Mod.Log.Msg(" (no HomeScreenFlow instance live yet — open the watch, then re-probe)"); return; }
// Il2CppInterop exposes IL2CPP instance fields as PROPERTIES on the
// managed proxy, so a plain AccessTools.Field misses them (the earlier
// "field not found" was a false negative). Try property first, then
// field. NULL => content stripped from the prefab (can't un-gate);
// present => only gated.
foreach (var name in new[] { "devMenuPanel", "devButtonPrefab", "reportBugButtonSpacer" })
{
var (found, mt, v) = GetMember(t, inst, name);
if (!found) { Mod.Log.Msg($" {name}: <not found as property or field>"); continue; }
var extra = "";
if (v != null && name == "devMenuPanel")
{
try { var cc = v.GetType().GetProperty("childCount")?.GetValue(v); if (cc != null) extra = $" childCount={cc}"; } catch { }
}
Mod.Log.Msg($" {name} ({mt.Name}): {(v is null ? "NULL <-- stripped/unset" : "present")}{extra}");
}
// Calibration: are ordinary serialized field names exposed at all on
// the runtime proxy? If 'profileButton' (a non-dev [SerializeField])
// IS found but devMenuPanel isn't, the dev fields are genuinely absent
// in this build. If profileButton is ALSO not found, Il2CppInterop
// isn't exposing these fields by name (need a different access path).
foreach (var cal in new[] { "profileButton", "settingsButton" })
{
var (cf, cmt, cv) = GetMember(t, inst, cal);
Mod.Log.Msg($" [calib] {cal}: {(cf ? $"FOUND ({cmt.Name}, {(cv is null ? "null" : "present")})" : "not found")}");
}
// Named lookups miss → dump the proxy's actual member names to see
// Il2CppInterop's scheme, then enumerate Button3D children directly
// (no field names needed) — those are our clone sources for injection.
var fs = t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
var ps = t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
var fn = new List<string>(); foreach (var f in fs) fn.Add(f.Name);
var pn = new List<string>(); foreach (var p in ps) pn.Add(p.Name);
Mod.Log.Msg($" [members] fields={fn.Count}, props={pn.Count}");
if (fn.Count > 0) Mod.Log.Msg($" [fieldnames] {string.Join(", ", fn.GetRange(0, Math.Min(40, fn.Count)))}");
if (pn.Count > 0) Mod.Log.Msg($" [propnames] {string.Join(", ", pn.GetRange(0, Math.Min(40, pn.Count)))}");
DumpWatchButtons(t, inst);
DumpCollections(t, inst, "HomeScreenFlow");
DumpStackedUi(t, inst);
}
// Walk HomeScreenFlow -> UIBase (StackedUIBase) and dump the data we need
// to build a CUSTOM watch page by cloning a real flow:
// * flowResources catalog — the TypeName of every PUSHABLE flow; these
// are our clone-target candidates (pick a simple self-contained one).
// * the Type->flow cache — what's already instantiated (do NOT mutate
// these cached singletons; clone an independent copy instead).
// * ScreenHeader presence — proves a cloned flow brings its own title
// bar + back button for free.
// Names are obfuscated, so we match by SHAPE (array-of-prefab-ref,
// Dictionary<Type,flow>) rather than by field name.
private static void DumpStackedUi(Type homeType, object homeInst)
{
var ui = GetMemberValue(homeType, homeInst, "UIBase", "get_UIBase");
if (ui is null) { Mod.Log.Msg(" [stackedui] UIBase: null (could not reach StackedUIBase)"); return; }
var uiType = ui.GetType();
Mod.Log.Msg($" [stackedui] UIBase type={uiType.FullName}");
var hdr = GetMemberValue(homeType, homeInst, "ScreenHeader", "get_ScreenHeader");
if (hdr != null)
{
var ht = hdr.GetType();
var hp = new List<string>(); foreach (var p in ht.GetProperties()) hp.Add(p.Name);
Mod.Log.Msg($" [stackedui] home ScreenHeader={ht.Name}; props={string.Join(",", hp.GetRange(0, Math.Min(30, hp.Count)))}");
}
else Mod.Log.Msg(" [stackedui] home ScreenHeader: null");
foreach (var f in uiType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
object? v; try { v = f.GetValue(ui); } catch { continue; }
if (v is null) continue;
int n = ArrLen(v);
// Flow-prefab catalog: a non-empty array whose elements expose a
// TypeName string (FlowPrefabResourceReference).
if (n > 0 && n <= 300 && (v is Array || v.GetType().Name.IndexOf("Array", StringComparison.OrdinalIgnoreCase) >= 0))
{
var first = ArrItem(v, 0);
var et = first?.GetType().Name ?? "?";
if (et.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Prefab", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Resource", StringComparison.OrdinalIgnoreCase) < 0 &&
et.IndexOf("Reference", StringComparison.OrdinalIgnoreCase) < 0) continue;
Mod.Log.Msg($" [stackedui] flow catalog '{f.Name}': {et}[{n}]");
for (int i = 0; i < n; i++)
{
var e = ArrItem(v, i); if (e is null) continue;
Mod.Log.Msg($" [{i}] {ReadStringMember(e)}");
}
continue;
}
// Flow cache: Dictionary<Type, StackedUIFlowBase>.
var vt = v.GetType();
if (vt.IsGenericType && vt.Name.StartsWith("Dictionary"))
{
var ga = vt.GetGenericArguments();
if (ga.Length == 2 && ga[1].Name.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0)
{
var cp = vt.GetProperty("Count");
Mod.Log.Msg($" [stackedui] flow cache '{f.Name}': Dictionary<{ga[0].Name},{ga[1].Name}> count={(cp?.GetValue(v) as int? ?? -1)}");
}
}
}
}
// Property by name, else parameterless getter method, else null.
private static object? GetMemberValue(Type t, object inst, string prop, string getter)
{
var (found, _, v) = GetMember(t, inst, prop);
if (found && v != null) return v;
var m = t.GetMethod(getter, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
null, Type.EmptyTypes, null);
if (m != null) { try { return m.Invoke(inst, null); } catch { } }
return v;
}
// First non-empty string field/property on an object — for obfuscated
// wrappers, that's the TypeName of a FlowPrefabResourceReference.
private static string ReadStringMember(object o)
{
var t = o.GetType();
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
if (p.PropertyType == typeof(string) && p.GetIndexParameters().Length == 0)
{ try { if (p.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
if (f.FieldType == typeof(string))
{ try { if (f.GetValue(o) is string s && s.Length > 0) return s; } catch { } }
return "(no string member)";
}
// Enumerate Button3D components under the HomeScreenFlow via Unity's
// GetComponentsInChildren(Type, includeInactive) — needs no field names.
// Confirms we can grab native buttons to clone for the dev entry.
private static void DumpWatchButtons(Type homeType, object homeInst)
{
var btnType = Mod.ResolveType("AGUI.StackedUI.Button3D");
if (btnType is null) { Mod.Log.Msg(" [buttons] Button3D type NOT FOUND"); return; }
var il2 = ToIl2CppType(btnType);
if (il2 is null) { Mod.Log.Msg(" [buttons] Button3D il2cppType null"); return; }
MethodInfo? m = null;
foreach (var mi in homeType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
if (mi.Name != "GetComponentsInChildren") continue;
var mp = mi.GetParameters();
if (mp.Length == 2 && mp[0].ParameterType.IsInstanceOfType(il2) && mp[1].ParameterType == typeof(bool)) { m = mi; break; }
}
if (m is null) { Mod.Log.Msg(" [buttons] GetComponentsInChildren(Type,bool) not found"); return; }
object? arr;
try { arr = m.Invoke(homeInst, new object[] { il2, true }); }
catch (Exception ex) { Mod.Log.Msg($" [buttons] GetComponentsInChildren threw: {(ex.InnerException ?? ex).Message}"); return; }
int n = ArrLen(arr);
Mod.Log.Msg($" [buttons] Button3D children: {n}");
for (int i = 0; i < n && i < 20; i++)
{
var b = ArrItem(arr, i);
var nm = b?.GetType().GetProperty("name")?.GetValue(b)?.ToString() ?? "?";
Mod.Log.Msg($" [{i}] {nm}");
}
}
private static int ArrLen(object? arr)
{
if (arr is null) return 0;
if (arr is Array a) return a.Length;
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
return (lp?.GetValue(arr) as int?) ?? 0;
}
private static object? ArrItem(object? arr, int i)
{
if (arr is null) return null;
if (arr is Array a) return a.GetValue(i);
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
return gi?.Invoke(arr, new object[] { i });
}
private static (bool found, Type type, object? val) GetMember(Type t, object inst, string name)
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
{ try { return (true, p.PropertyType, p.GetValue(inst)); } catch { } }
var f = AccessTools.Field(t, name);
if (f != null) { try { return (true, f.FieldType, f.GetValue(inst)); } catch { } }
return (false, typeof(object), null);
}
private static void DumpWatchUi(Type t, object? inst)
{
if (inst is null) { Mod.Log.Msg(" (no WatchUI instance live yet)"); return; }
DumpCollections(t, inst, "WatchUI");
}
private static void DumpDebugConsole(Type t, object? inst)
{
foreach (var name in new[] { "GJIKMPIMDHH", "IEGCNLHPKFH", "ShowButton", "ShowErrorsInHeadset", "ShowUnityDevelopmentConsole" })
{
try
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
if (p != null && p.CanRead) { Mod.Log.Msg($" static {name} = {p.GetValue(null)}"); continue; }
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
if (f != null) Mod.Log.Msg($" static {name} = {f.GetValue(null)}");
}
catch (Exception ex) { Mod.Log.Msg($" {name}: read failed ({ex.Message})"); }
}
// Does the static command path work without the console UI in-scene?
var exec = AccessTools.Method(t, "Execute", new[] { typeof(string) });
if (exec is null) { Mod.Log.Msg(" Execute(string): <not found>"); return; }
try { exec.Invoke(null, new object[] { "help" }); Mod.Log.Msg(" Execute(\"help\"): invoked OK — static command path is usable"); }
catch (Exception ex) { var e = ex.InnerException ?? ex; Mod.Log.Msg($" Execute(\"help\"): threw {e.GetType().Name} ({e.Message})"); }
}
// Log every array / List<> field whose element type mentions Flow/Watch,
// with its count — surfaces the dev-flow list (devOnlyFlows-equivalent)
// regardless of its obfuscated name, so we can see if dev flows exist.
private static void DumpCollections(Type t, object inst, string label)
{
var members = new List<(string name, object? val)>();
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{ try { members.Add((f.Name, f.GetValue(inst))); } catch { } }
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{ if (p.CanRead && p.GetIndexParameters().Length == 0) { try { members.Add((p.Name, p.GetValue(inst))); } catch { } } }
foreach (var (name, v) in members)
{
if (v is null) continue;
int? count = null; string elem = "";
if (v is Array arr) { count = arr.Length; elem = v.GetType().GetElementType()?.Name ?? "?"; }
else
{
var ft = v.GetType();
if (ft.IsGenericType && typeof(System.Collections.IEnumerable).IsAssignableFrom(ft))
{
var cp = ft.GetProperty("Count");
if (cp != null && cp.PropertyType == typeof(int))
{
count = (int?)cp.GetValue(v);
var ga = ft.GetGenericArguments();
elem = ga.Length > 0 ? ga[0].Name : "?";
}
}
}
if (count.HasValue &&
(elem.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0 ||
elem.IndexOf("Watch", StringComparison.OrdinalIgnoreCase) >= 0))
Mod.Log.Msg($" {label}.{name}: {elem}[{count}]");
}
}
// Under Il2CppInterop, UnityEngine.Object.FindObjectOfType takes an
// Il2CppSystem.Type (NOT System.Type), so AccessTools.Method(...,
// typeof(Type)) misses — that's the "FindObjectOfType(Type) not found"
// in the log. Convert the managed Type to an Il2CppSystem.Type via
// Il2CppType.From, then use Resources.FindObjectsOfTypeAll (which also
// returns INACTIVE objects + loaded prefab instances — important, the
// watch UI is usually inactive until opened). Falls back to the
// active-only FindObjectOfType.
private static object? FindOne(Type t)
{
try
{
var il2cppType = ToIl2CppType(t);
if (il2cppType is null) return null;
var resources = Mod.ResolveType("UnityEngine.Resources");
var all = OneArgMethod(resources, "FindObjectsOfTypeAll", il2cppType);
var first = FirstOf(all?.Invoke(null, new[] { il2cppType }));
if (first != null) return first;
var objType = Mod.ResolveType("UnityEngine.Object");
var findOne = OneArgMethod(objType, "FindObjectOfType", il2cppType);
return findOne?.Invoke(null, new[] { il2cppType });
}
catch { return null; }
}
private static object? ToIl2CppType(Type managed)
{
try
{
var il2cppTypeType = Mod.ResolveType("Il2CppInterop.Runtime.Il2CppType");
var from = il2cppTypeType?.GetMethod("From", new[] { typeof(Type), typeof(bool) })
?? il2cppTypeType?.GetMethod("From", new[] { typeof(Type) });
if (from is null) return null;
var args = from.GetParameters().Length == 2
? new object[] { managed, true } : new object[] { managed };
return from.Invoke(null, args);
}
catch { return null; }
}
// Find a public static method `name` whose single parameter accepts `arg`.
private static MethodInfo? OneArgMethod(Type? t, string name, object arg)
{
if (t is null) return null;
foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (m.Name != name) continue;
var ps = m.GetParameters();
if (ps.Length == 1 && ps[0].ParameterType.IsInstanceOfType(arg)) return m;
}
return null;
}
// First element of an Il2CppReferenceArray / Array / IList, or null.
private static object? FirstOf(object? arr)
{
if (arr is null) return null;
if (arr is Array a) return a.Length > 0 ? a.GetValue(0) : null;
var at = arr.GetType();
var lenProp = at.GetProperty("Length") ?? at.GetProperty("Count");
if (lenProp?.GetValue(arr) is int n && n > 0)
{
var getItem = at.GetMethod("get_Item", new[] { typeof(int) });
if (getItem != null) return getItem.Invoke(arr, new object[] { 0 });
}
return null;
}
}
// ── Desktop Screen Sharing FPS override ─────────────────────────────────
// RecRoom.Tools.Productivity.DesktopScreenSharingDisplay broadcasts the
// shared desktop image. Capture cadence is the baked [SerializeField]
// float screenShareImageRefreshFrequency; the broadcast tick gates on
// if (Time.time - lastSend >= 1f / screenShareImageRefreshFrequency)
// (DesktopScreenSharingDisplay.txt:697-703) — so the field is literally the
// FPS, with no runtime clamp (the [Range] is editor-only). We rewrite it in
// an Awake postfix. The captured frame is transmitted via the component's
// IPunObservable OnPhotonSerializeView, which only fires at
// PhotonNetwork.SerializationRate (default 10 Hz), so we also lift the
// Photon send/serialization rate to cover the target — otherwise a 30 fps
// capture still only delivers ~10. Field names are un-obfuscated on both
// builds (confirmed in the 2020.12.18 dump), so a plain AccessTools.Field
// lookup works.
internal static class ScreenSharePatches
{
private static bool _photonRateApplied;
public static void DisplayAwake_Postfix(object __instance)
{
try
{
var fps = Mod.Cfg.DesktopScreenShareFps;
if (fps <= 0f || __instance is null) return;
var t = __instance.GetType();
SetField(t, __instance, "screenShareImageRefreshFrequency", fps);
if (Mod.Cfg.DesktopScreenShareResolution > 0)
SetField(t, __instance, "screenShareImageHorizontalResolution", Mod.Cfg.DesktopScreenShareResolution);
if (Mod.Cfg.DesktopScreenShareQuality > 0)
SetField(t, __instance, "screenShareImageCompressionQuality", Mod.Cfg.DesktopScreenShareQuality);
Mod.Log.Msg($"[screenshare] {t.Name}: refreshFrequency→{fps}fps" +
(Mod.Cfg.DesktopScreenShareResolution > 0 ? $" res→{Mod.Cfg.DesktopScreenShareResolution}px" : "") +
(Mod.Cfg.DesktopScreenShareQuality > 0 ? $" quality→{Mod.Cfg.DesktopScreenShareQuality}" : ""));
if (Mod.Cfg.DesktopScreenShareRaisePhotonRate && !_photonRateApplied)
RaisePhotonRates((int)Math.Ceiling(fps));
}
catch (Exception ex) { Mod.Log.Warning($"[screenshare] Awake postfix failed: {ex.Message}"); }
}
private static void SetField(Type t, object inst, string name, object value)
{
var f = AccessTools.Field(t, name);
if (f is null) { Mod.Log.Warning($"[screenshare] field '{name}' not found on {t.Name}"); return; }
f.SetValue(inst, value);
}
// Lift PhotonNetwork.SendRate + SerializationRate to >= hz. PUN requires
// SerializationRate <= SendRate, so SendRate is kept at the max of the
// two. Both are public static int members (property on some PUN builds,
// field on others) — handle either. GLOBAL: every PhotonView's
// OnPhotonSerializeView now fires at this rate.
private static void RaisePhotonRates(int hz)
{
var pn = Mod.ResolveType("PhotonNetwork");
if (pn is null) { Mod.Log.Warning("[screenshare] PhotonNetwork not found; can't raise serialization rate (delivered FPS will stay ~10)"); return; }
var send = ReadInt(pn, "SendRate");
var ser = ReadInt(pn, "SerializationRate");
var target = Math.Max(hz, 1);
var newSend = Math.Max(target, Math.Max(send, ser));
WriteInt(pn, "SendRate", newSend);
WriteInt(pn, "SerializationRate", target);
_photonRateApplied = true;
Mod.Log.Msg($"[screenshare] Photon rates raised (GLOBAL): SendRate {send}→{newSend}, SerializationRate {ser}→{target}");
}
private static int ReadInt(Type t, string name)
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
if (p is not null && p.CanRead && p.GetValue(null) is int pv) return pv;
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
if (f is not null && f.GetValue(null) is int fv) return fv;
return 0;
}
private static void WriteInt(Type t, string name, int value)
{
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
if (p is not null && p.CanWrite) { p.SetValue(null, value); return; }
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Static);
if (f is not null) f.SetValue(null, value);
}
}
internal static class MakerPenGiftPreviewPatches
{
private static bool _loggedMissingPointer;
private static bool _loggedMissingRestrictionFlag;
public static bool LaserPointerEnabled_Prefix(object __instance, bool ONGBFDACHHG)
{
try
{
if (__instance is null) return true;
var t = __instance.GetType();
if (ReadField(__instance, t, "pointerLineRenderer") is null)
{
if (!_loggedMissingPointer)
{
_loggedMissingPointer = true;
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled on MakerPenVisuals with no pointerLineRenderer");
}
return false;
}
if (ONGBFDACHHG && ReadField(__instance, t, "EANJBIDHAMN") is null)
{
if (!_loggedMissingRestrictionFlag)
{
_loggedMissingRestrictionFlag = true;
Mod.Log.Msg("[makerpen-preview] skipped LaserPointerEnabled=true on MakerPenVisuals with no laser restriction flag");
}
return false;
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[makerpen-preview] guard failed, letting original run: {ex.Message}");
}
return true;
}
private static object? ReadField(object instance, Type type, string name)
{
var field = AccessTools.Field(type, name) ?? AccessTools.Field(type.BaseType, name);
return field?.GetValue(instance);
}
}
internal static class UriPatches internal static class UriPatches
{ {
public static void HttpManagerSendRequestString_Prefix(ref string url) public static void HttpManagerSendRequestString_Prefix(ref string url)
@@ -1764,9 +787,22 @@ internal static class UriPatches
private static void RewriteString(ref string uriString, string label) private static void RewriteString(ref string uriString, string label)
{ {
if (string.IsNullOrEmpty(uriString)) return; 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; var host = Mod.Cfg.ServerHost;
if (string.IsNullOrEmpty(host)) return; if (string.IsNullOrEmpty(host)) return;
if (uriString.IndexOf(".rec.net", StringComparison.OrdinalIgnoreCase) < 0) return;
var rewritten = uriString.Replace(".rec.net", "." + host); var rewritten = uriString.Replace(".rec.net", "." + host);
if (rewritten == uriString) return; if (rewritten == uriString) return;
Mod.Log.Msg($"[{label}] {uriString} → {rewritten}"); Mod.Log.Msg($"[{label}] {uriString} → {rewritten}");
@@ -1782,30 +818,88 @@ internal static class UriPatches
var service = uri.Host[..^".rec.net".Length]; var service = uri.Host[..^".rec.net".Length];
if (string.IsNullOrWhiteSpace(service)) return null; 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) return new UriBuilder(uri)
{ {
Host = service + "." + host, Host = service + "." + host,
}.Uri; }.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 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() public static void PhotonAppIdOverride_Prefix()
{ {
if (string.IsNullOrEmpty(Mod.Cfg.PhotonAppId)) return; // 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 try
{ {
// PhotonNetwork.PhotonServerSettings — accessed via reflection // Use Mod.ResolveType (the same resolver TryPatchByName uses)
// so the type-name resolution stays in one place. Mutating the // — it falls back to a full assembly scan after the prefix
// ScriptableObject in-place is what the BepInEx plugin does // attempts, which is how it finds the IL2CPP-mangled types
// too; the Photon Connect path reads AppID / VoiceAppID off // that the local FindGameType helper misses.
// this object immediately after. var photonNetwork = Mod.ResolveType("PhotonNetwork");
var photonNetwork = Mod.Instance is null ? null : FindGameType("PhotonNetwork"); if (photonNetwork is null)
if (photonNetwork is null) return; {
// 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 settingsProp = photonNetwork.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static);
var settings = settingsProp?.GetValue(null); var settings = settingsProp?.GetValue(null);
if (settings is null) return; if (settings is null) return false; // ScriptableObject not loaded yet; quietly retry next frame.
var settingsType = settings.GetType(); var settingsType = settings.GetType();
var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId"); var appIdProp = settingsType.GetProperty("AppID") ?? settingsType.GetProperty("AppId");
@@ -1814,19 +908,9 @@ internal static class PhotonPatches
var newId = Mod.Cfg.PhotonAppId; var newId = Mod.Cfg.PhotonAppId;
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId; var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId) ? newId : Mod.Cfg.PhotonVoiceAppId;
// Diagnostic: dump every readable AppSettings field before
// we mutate, so a Photon Custom Auth failure on first connect
// is debuggable from a single log dump. Lists every public
// property + field on PhotonServerSettings (Server, Port,
// NameServer, AuthMode, HostType, PreferredRegion, AppID,
// VoiceAppID, BestRegionSummary, etc.) — covers both 2020.03
// (Server/Port) and 2020.12 (AppSettings) layouts.
Mod.Log.Msg("[photon-diag] PhotonServerSettings BEFORE override:");
DumpSettings(settings, settingsType, indent: " ");
appIdProp?.SetValue(settings, newId); appIdProp?.SetValue(settings, newId);
voiceProp?.SetValue(settings, newVoice); voiceProp?.SetValue(settings, newVoice);
Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice}"); Mod.Log.Msg($"[photon-appid] override AppID/VoiceAppID → {newId} / {newVoice} ({reason})");
// Force a deterministic Photon region so two watches running // Force a deterministic Photon region so two watches running
// this mod end up on the same Photon master and "JoinByName" // this mod end up on the same Photon master and "JoinByName"
@@ -1848,8 +932,14 @@ internal static class PhotonPatches
// any) over the config default — that way a server-side // any) over the config default — that way a server-side
// region change propagates without redeploying the mod. // region change propagates without redeploying the mod.
ApplyForcedRegion(settings, settingsType); ApplyForcedRegion(settings, settingsType);
OverrideApplied = true;
return true;
}
catch (Exception ex)
{
Mod.Log.Warning($"[photon-appid] override failed ({reason}): {ex.Message}");
return false;
} }
catch (Exception ex) { Mod.Log.Warning($"[photon-appid] override failed: {ex.Message}"); }
} }
/// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c> /// <summary>Forces <c>PhotonServerSettings.HostType = PhotonCloud</c>
@@ -1928,190 +1018,11 @@ internal static class PhotonPatches
catch { return null; } catch { return null; }
} }
// AuthValuesInjector_Prefix parked 2026-05-28 — never wired to a // AuthValuesInjector_Prefix + GetCurrentLockToken parked 2026-05-28
// harmony hook in this branch anyway. See // — see attic/AuthValuesInjector.cs.attic. Photon Custom Auth is
// attic/AuthValuesInjector.cs.attic. // now disabled at the AppId level in the Photon dashboard, so the
// watch no longer needs to attach userid+LoginLock to Photon
/// <summary>Postfix on <c>PUNNetworkManager.FJOLIPKKIBE</c> — the // Authenticate ops.
/// 2020.12 watch's private AppSettings builder. <c>__result</c> is the
/// <c>Photon.Realtime.AppSettings</c> it just built.
///
/// **CRITICAL OVERRIDE**: replaces <c>AppIdRealtime</c> and
/// <c>AppIdVoice</c> with our configured values before the AppSettings
/// reaches <c>PhotonNetwork.ConnectUsingSettings</c>. The 2020.12
/// watch hardcodes Rec Room's official AppIds as IL2CPP string
/// literals (verified at <c>PUNNetworkManager.txt:7205,7208</c>:
/// <c>"9372aa8d-..."</c> and <c>"e93ae440-..."</c>). Patching
/// resources.assets at offsets 0x7962480 / 0x79624ac doesn't help —
/// those are unrelated leftover strings, NOT the AppIds the SDK
/// actually reads. The only reliable swap point is here, after
/// FJOLIPKKIBE constructs the AppSettings object but before the
/// LoadBalancingClient consumes it.
///
/// Then dumps every readable field/property for diagnostics.</summary>
public static void LogAppSettings_Postfix(object __result)
{
try
{
if (__result is null) { Mod.Log.Msg("[photon-diag] FJOLIPKKIBE returned null AppSettings"); return; }
// ── AppId injection ────────────────────────────────────────
var t = __result.GetType();
var newPun = string.IsNullOrEmpty(Mod.Cfg.PhotonAppId) ? null : Mod.Cfg.PhotonAppId;
var newVoice = string.IsNullOrEmpty(Mod.Cfg.PhotonVoiceAppId)
? (newPun ?? null)
: Mod.Cfg.PhotonVoiceAppId;
if (newPun is not null)
{
var realtimeProp = t.GetProperty("AppIdRealtime") ?? t.GetProperty("AppIdFusion");
var oldPun = realtimeProp?.GetValue(__result) as string;
realtimeProp?.SetValue(__result, newPun);
Mod.Log.Msg($"[photon-override] AppIdRealtime: {oldPun} → {newPun}");
}
if (newVoice is not null)
{
var voiceProp = t.GetProperty("AppIdVoice");
var oldVoice = voiceProp?.GetValue(__result) as string;
voiceProp?.SetValue(__result, newVoice);
Mod.Log.Msg($"[photon-override] AppIdVoice: {oldVoice} → {newVoice}");
}
// Also force FixedRegion to the configured region so two clients
// never end up on different regions of the same room name.
if (!string.IsNullOrEmpty(Mod.Cfg.PhotonCloudRegion))
{
var fixedRegionProp = t.GetProperty("FixedRegion");
if (fixedRegionProp is not null && fixedRegionProp.CanWrite)
{
fixedRegionProp.SetValue(__result, Mod.Cfg.PhotonCloudRegion);
Mod.Log.Msg($"[photon-override] FixedRegion → {Mod.Cfg.PhotonCloudRegion}");
}
}
Mod.Log.Msg("[photon-diag] FJOLIPKKIBE built AppSettings (post-override):");
DumpSettings(__result, t, indent: " ");
}
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAppSettings/override failed: {ex.Message}"); }
}
/// <summary>Postfix on <c>PUNNetworkManager.EMGGKBALALH</c> — the
/// 2020.12 watch's private AuthenticationValues builder. <c>__result</c>
/// is the AuthValues it just built with environment + accessToken
/// AuthGetParameters. Logs AuthType, UserId, and the full
/// AuthGetParameters / AuthPostData payload that Photon Cloud will
/// forward to the configured Custom Auth URL.</summary>
public static void LogAuthValues_Postfix(object __result)
{
try
{
if (__result is null)
{
Mod.Log.Msg("[photon-diag] EMGGKBALALH returned null AuthValues (anonymous connect)");
return;
}
var t = __result.GetType();
string Read(string name) => t.GetProperty(name)?.GetValue(__result)?.ToString() ?? "<null>";
var authType = Read("AuthType");
var userId = Read("UserId");
var getParams = t.GetProperty("AuthGetParameters")?.GetValue(__result)?.ToString() ?? "<null>";
var postData = t.GetProperty("AuthPostData")?.GetValue(__result);
var postStr = postData is byte[] bytes
? $"<{bytes.Length} bytes: {System.Text.Encoding.UTF8.GetString(bytes)}>"
: postData?.ToString() ?? "<null>";
Mod.Log.Msg($"[photon-diag] EMGGKBALALH built AuthValues: AuthType={authType} UserId={userId}");
Mod.Log.Msg($"[photon-diag] AuthGetParameters = {getParams}");
Mod.Log.Msg($"[photon-diag] AuthPostData = {postStr}");
}
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] LogAuthValues failed: {ex.Message}"); }
}
/// <summary>Prefix on <c>PUNNetworkManager.OnCustomAuthenticationFailed</c>
/// — the watch's UI-error formatter only logs a truncated Unity-
/// stringified version of the debugMessage. Capturing the raw
/// string before the watch's handler runs gives us the FULL
/// Photon error including the URL Photon Cloud actually tried to
/// call (which is the smoking-gun answer to "is the dashboard URL
/// saved or is Photon hitting a stale one").</summary>
public static void OnCustomAuthenticationFailed_Prefix(object __0)
{
try
{
var debugMessage = __0?.ToString() ?? "<null>";
Mod.Log.Error("[photon-diag] OnCustomAuthenticationFailed FULL MESSAGE:");
Mod.Log.Error(debugMessage);
// Also dump current AppSettings — if Photon rejected the auth
// we want to know what the watch's actual final config looked
// like (post-override).
var photonNetwork = FindGameType("PhotonNetwork");
var settings = photonNetwork?.GetProperty("PhotonServerSettings", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
if (settings is not null)
{
Mod.Log.Error("[photon-diag] PhotonServerSettings at failure:");
DumpSettings(settings, settings.GetType(), indent: " ");
}
}
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] OnCustomAuthenticationFailed log failed: {ex.Message}"); }
}
/// <summary>Logs every public field + property on <paramref name="obj"/>
/// — used by the photon-diag patches above to dump AppSettings in one
/// pass without coupling to specific field names that differ across
/// PUN versions (Server/Port vs AppSettings.NameServer in newer
/// PUN 2.x, etc.).</summary>
private static void DumpSettings(object obj, Type t, string indent)
{
// DeclaredOnly so we don't walk Il2CppObjectBase's properties
// (ObjectClass, Pointer, WasCollected) — and, importantly, so
// we don't reflect over any property whose getter internally
// constructs a System.Uri. The Uri ctor is harmony-patched, so
// the FIRST call to a Uri-returning getter triggers MonoMod's
// lazy CompileMethodHook for the ctor, which fatal-CLRs
// (0x80131506) on this build. The override has already run by
// the time DumpSettings is called — the dump is diagnostic
// only, so it's safer to stay scoped to user-land properties.
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
try
{
foreach (var p in t.GetProperties(flags))
{
if (p.GetIndexParameters().Length > 0) continue;
if (!p.CanRead) continue;
// Defensively skip any property whose declared return
// type is Uri (would still trigger ctor on .ToString()
// path even if we tried to read it).
if (p.PropertyType == typeof(Uri)) continue;
object? v;
try { v = p.GetValue(obj); } catch { continue; }
if (v is Uri) continue;
var str = v is null ? "<null>" : v.ToString();
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
{
Mod.Log.Msg($"{indent}{p.Name} (nested):");
DumpSettings(v, v.GetType(), indent + " ");
continue;
}
Mod.Log.Msg($"{indent}{p.Name} = {str}");
}
foreach (var f in t.GetFields(flags))
{
if (f.FieldType == typeof(Uri)) continue;
object? v;
try { v = f.GetValue(obj); } catch { continue; }
if (v is Uri) continue;
var str = v is null ? "<null>" : v.ToString();
if (v is not null && v.GetType().Name.IndexOf("AppSettings", StringComparison.Ordinal) >= 0 && indent.Length < 6)
{
Mod.Log.Msg($"{indent}{f.Name} (nested):");
DumpSettings(v, v.GetType(), indent + " ");
continue;
}
Mod.Log.Msg($"{indent}{f.Name} = {str}");
}
}
catch (Exception ex) { Mod.Log.Warning($"[photon-diag] DumpSettings failed: {ex.Message}"); }
}
// GetCurrentLockToken parked alongside the injector — see
// attic/AuthValuesInjector.cs.attic.
private static Type? FindGameType(string name) => Mod.ResolveType(name); private static Type? FindGameType(string name) => Mod.ResolveType(name);
} }
@@ -2403,6 +1314,12 @@ internal static class DiagnosticPatches
WriteStack(); WriteStack();
} }
public static void BootSequenceRegisterError_Prefix(string error)
{
Write($"[boot-error] BootSequence.RegisterError: {error}");
WriteStack();
}
public static void AnalyticsCheat_Prefix(MethodBase __originalMethod) public static void AnalyticsCheat_Prefix(MethodBase __originalMethod)
{ {
Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}"); Write($"[cheat-analytics] {__originalMethod.DeclaringType?.FullName}.{__originalMethod.Name}");
@@ -2477,218 +1394,3 @@ internal static class ChatPatches
Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\""); Mod.Log.Msg($"[chat-trace] RpcChatEmote (RPC RECEIVED) msg=\"{message}\"");
} }
} }
// Log-only diagnostics for the stuck first-dorm "Change Username?" dialog.
// See the registration site in OnLateInitializeMelon and
// Cfg.TraceRegistrationDialog. Nothing here changes game behaviour — every
// hook only writes to the diagnostics log.
internal static class RegistrationPatches
{
private static bool s_loggedFullyReg;
// RRUI.Data.NUX.RegistrationModel.IsFullyRegistered() — postfix reads
// (does NOT modify) the return value. False ⇒ the dorm raises the
// "Change Username?" prompt; true ⇒ no prompt. Logged once so the file
// stays readable (it's polled from several call sites every boot).
public static void IsFullyRegistered_Postfix(bool __result)
{
if (!s_loggedFullyReg)
{
s_loggedFullyReg = true;
DiagnosticPatches.Write($"[reg-trace] RegistrationModel.IsFullyRegistered() = {__result} ({(__result ? "no prompt — fully registered" : "NOT registered → dorm will raise the Change Username? prompt")})");
}
}
// DormroomSceneManager.<PromptForRegistration>b__6_* lambdas. b__6_1 is
// the dialog-response handler (a dialog was shown). Names reach the
// prefix via __originalMethod so we can tell which branch ran.
public static void PromptLambda_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[reg-trace] DormroomSceneManager prompt lambda fired: {__originalMethod.Name}");
}
// ConfirmUIDialog button handlers. If one of these fires when the user
// taps a dialog button but the modal stays open, the break is in the
// resolve/hide completer (UIDialog`1.Respond's close path) — not input
// routing. "Okay" on the screenshot is Button_Affirmative.
public static bool ButtonAffirmative_Prefix(object __instance)
{
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Affirmative pressed (this is 'Okay')");
if (!IsRegistrationWarningDialog(__instance))
{
return true;
}
if (TryResolveAffirmativeWithoutClose(__instance))
{
DiagnosticPatches.Write("[registration-fix] resolved first-dorm registration warning without the broken close-before-resolve path");
return false;
}
DiagnosticPatches.Write("[registration-fix] registration warning matched, but reflection resolve failed; falling back to original button handler");
return true;
}
public static void ButtonNegative_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Negative pressed");
public static void ButtonNotNow_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_NotNow pressed");
public static void ButtonCancel_Prefix() =>
DiagnosticPatches.Write("[reg-trace] >>> ConfirmUIDialog.Button_Cancel pressed");
private static bool IsRegistrationWarningDialog(object instance)
{
var title = ReadTextField(instance, "titleText");
var body = ReadTextField(instance, "bodyText");
return Contains(title, "Change Username")
&& Contains(body, "permanently lost")
&& Contains(body, "password");
}
private static bool TryResolveAffirmativeWithoutClose(object instance)
{
try
{
var respond = AccessTools.Method(instance.GetType(), "Respond");
if (respond is null)
{
respond = AccessTools.Method(instance.GetType().BaseType, "Respond");
}
if (respond is null)
{
return false;
}
var parameters = respond.GetParameters();
if (parameters.Length != 2)
{
return false;
}
var response = CreateAffirmativeResponse(parameters[0].ParameterType);
respond.Invoke(instance, new[] { response, (object)false });
return true;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] resolve failed: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
private static object CreateAffirmativeResponse(Type responseType)
{
if (responseType.IsEnum)
{
return Enum.ToObject(responseType, 1);
}
return Convert.ChangeType(1, responseType);
}
private static string? ReadTextField(object instance, string fieldName)
{
try
{
var field = AccessTools.Field(instance.GetType(), fieldName);
var textObject = field?.GetValue(instance);
if (textObject is null)
{
return null;
}
var property = AccessTools.Property(textObject.GetType(), "text");
if (property?.GetValue(textObject) is string text)
{
return text;
}
var getter = AccessTools.Method(textObject.GetType(), "get_text");
return getter?.Invoke(textObject, Array.Empty<object>()) as string;
}
catch (Exception ex)
{
DiagnosticPatches.Write($"[registration-fix] could not read {fieldName}: {ex.GetType().Name}: {ex.Message}");
return null;
}
}
private static bool Contains(string? value, string fragment)
{
return value?.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0;
}
}
// Trace hooks for the watch's room-join flow. Registered by
// Mod.RegisterJoinTracePatches — see that method for the verified call
// chain. Everything here is log-only; no prefix returns false, so the
// game's own join logic runs unchanged.
internal static class JoinPatches
{
// AGUI.StackedUI.HomeScreenFlow.Button_DormRoom() — the watch's
// "Dorm Room" button. If this never fires, the button's UnityEvent
// binding is the problem, not the join chain.
public static void ButtonDormRoom_Prefix()
{
DiagnosticPatches.Write("[join-trace] >>> watch DORM ROOM button pressed (HomeScreenFlow.Button_DormRoom)");
}
// Any SessionManager.RunJoinRoom overload. We DON'T read argument
// values here: Harmony's `object[] __args` forces a trampoline that
// boxes the obfuscated IL2CPP reference-type params (KMKPEOGJDFK,
// RoomInstance, …), and MonoMod fatal-CLRs (0x80131506) compiling it
// when the boot-time dorm auto-join first calls this method. Logging
// the overload's parameter *types* (pure reflection metadata, no value
// boxing) is enough to tell which overload fired — the entry overload
// (String,…) is the "DormRoom" press; the others are inner legs.
public static void RunJoinRoom_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] SessionManager.RunJoinRoom [{Sig(__originalMethod)}]");
}
// OJMCBOKJFOF.NHBPIIGDAJP(name|id) — the get-room-by-name / by-id
// resolver that feeds the join. __0 is the single first param: a room
// NAME string (String overload) or room ID (Int64 overload). Reading
// it via `object __0` is safe — it's a string/long, not one of the
// obfuscated ref-types that crashed `object[] __args`. This is the
// value we need to compare boot (works) vs button (fails).
public static void Goto_Prefix(MethodBase __originalMethod, object __0)
{
var arg = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] /goto resolve {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}(\"{arg}\") [{Sig(__originalMethod)}]");
}
// RunJoinRoom promise-chain callbacks (SessionManager <>c lambdas).
// b__165_1 is the reject handler that raises the "An error occured /
// contact recroom.happyfox.com" toast — so if THIS name appears, the
// join failed; the success legs (other b__*) firing means it advanced.
// (Same __args/0x80131506 constraint as above — name only.)
public static void JoinCallback_Prefix(MethodBase __originalMethod)
{
DiagnosticPatches.Write($"[join-trace] RunJoinRoom callback {__originalMethod.DeclaringType?.Name}.{__originalMethod.Name}");
}
// Dedicated capture for the reject handler b__165_1(string error) —
// the answer to "why did the join fail". Single string param, so
// `object __0` is safe (same as OnCustomAuthenticationFailed). This
// is the string that becomes the happyfox toast.
public static void JoinError_Prefix(object __0)
{
var err = __0?.ToString() ?? "<null>";
DiagnosticPatches.Write($"[join-trace] !!! RunJoinRoom REJECTED — error=\"{err}\" (raises the happyfox toast)");
}
// Parameter-type list from reflection metadata only — never reads
// instance values, so it can't trigger the MonoMod trampoline crash.
private static string Sig(MethodBase m)
{
var ps = m.GetParameters();
var names = new List<string>(ps.Length);
foreach (var p in ps) names.Add(p.ParameterType.Name);
return $"{ps.Length} args: {string.Join(",", names)}";
}
}
-464
View File
@@ -1,464 +0,0 @@
// Raise RRO quest party size past the baked 4 — precisely, only for the
// allowlisted quests (Cfg.QuestMaxTeamSizeRooms).
//
// HOW THE CAP IS REALLY BUILT (cracked from the December dump/ISIL):
// GameConfigurationTool.Awake()
// → reads its baked `defaultGameConfiguration` (GameConfigurationAsset, the
// Unity ScriptableObject @0x58)
// → calls GameConfigurationAsset.GDECPANDBHJ() ← builds the runtime
// protobuf GameConfigurationData (PJPAPHKPKGM) FROM the asset
// → stores it @0x88 and applies it (OAHJOLJOKGI) to the team manager.
// Inside GDECPANDBHJ the build loop reads asset.TeamConfigurations[i]
// .MaxTeamSize (a typed int struct-array: [arr+0x20 + i*4]) and writes each
// straight into a new GameTeamConfigurationData.TeamSize (HFBCEJLLDLI @0x10).
//
// So the runtime cap originates from the asset's `TeamConfiguration[]` — a
// READABLE, typed struct array — NOT from anything that needs the obfuscated
// protobuf. Earlier attempts failed because:
// • bumping the asset on a 2s poll happened AFTER GDECPANDBHJ already built &
// cached the protobuf (timing, not the wrong field);
// • patching the protobuf getters (get_FNJLAGHPMFL / get_FBOCAPLIIDH) is
// impossible — Il2CppInterop doesn't emit the generic RepeatedField<T>
// getter, and the simple int getter wasn't emitted either.
//
// THE FIX: Harmony-PREFIX GameConfigurationAsset.GDECPANDBHJ() and bump the
// asset's TeamConfigurations[i].MaxTeamSize to the target IN PLACE, right
// before it builds the protobuf. The built config then carries the new cap and
// the team manager / pre-game roster / open-slots all honour it. Scoped by
// asset.Name to the allowlist (PvP/sandbox configs are left alone).
//
// HOST-AUTHORITATIVE: the quest host builds the config it networks to joiners
// (RpcDeserializeConfig), so the host must run this mod for everyone to see the
// raised cap. (A joiner can't override — that would need the unreachable
// protobuf path.)
using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
namespace DorkNet.ClientMod;
internal static class QuestTeamSize
{
private const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly HashSet<string> _seen = new(StringComparer.Ordinal);
private static bool _loggedBump;
private static bool _activeAllowedQuestConfig;
private static int _teamIndexNormalizeLogCount;
private static int _spawnModeDataNormalizeLogCount;
private static int _requiredSpawnIndexNormalizeLogCount;
private static int _spawnRelaxLogCount;
public static bool TryPatchSpawnIndexNormalizers(HarmonyLib.Harmony harmony)
{
var ok = true;
ok &= TryPatchTeamIndexNormalizer(harmony);
ok &= TryPatchSpawnModeDataNormalizer(harmony);
ok &= TryPatchRequiredSpawnIndexNormalizer(harmony);
return ok;
}
private static bool TryPatchTeamIndexNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var managerType = Mod.ResolveType("GameTeamManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
if (managerType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex: type not found");
return false;
}
var target = AccessTools.Method(managerType, "GetTeamPlayerIndex");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameTeamManager.GetTeamPlayerIndex");
return false;
}
var patch = typeof(QuestTeamSize)
.GetMethod(nameof(NormalizeTeamPlayerIndex_Postfix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, postfix: new HarmonyMethod(patch));
Mod.Log.Msg("[patch-ok] GameTeamManager.GetTeamPlayerIndex");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameTeamManager.GetTeamPlayerIndex: {ex.Message}");
return false;
}
}
private static bool TryPatchSpawnModeDataNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var spawnType = Mod.ResolveType("GameSpawnManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
if (spawnType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ: type not found");
return false;
}
var target = AccessTools.Method(spawnType, "ALCADGJKKHJ");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.ALCADGJKKHJ");
return false;
}
var patch = typeof(QuestTeamSize)
.GetMethod(nameof(NormalizeSpawnModeData_Postfix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, postfix: new HarmonyMethod(patch));
Mod.Log.Msg("[patch-ok] GameSpawnManager.ALCADGJKKHJ");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameSpawnManager.ALCADGJKKHJ: {ex.Message}");
return false;
}
}
private static bool TryPatchRequiredSpawnIndexNormalizer(HarmonyLib.Harmony harmony)
{
try
{
var spawnType = Mod.ResolveType("GameSpawnManager");
var indexType = Mod.ResolveType("CCMLKDOHKJE");
if (spawnType is null || indexType is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer: type not found");
return false;
}
var target = AccessTools.Method(spawnType, "FKEICBPCMPJ");
if (target is null)
{
Mod.Log.Warning("[patch-miss] GameSpawnManager.FKEICBPCMPJ index normalizer");
return false;
}
var patch = typeof(QuestTeamSize)
.GetMethod(nameof(NormalizeRequiredSpawnIndex_Prefix), BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(indexType);
harmony.Patch(target, prefix: new HarmonyMethod(patch));
Mod.Log.Msg("[patch-ok] GameSpawnManager.FKEICBPCMPJ index normalizer");
return true;
}
catch (Exception ex)
{
Mod.Log.Error($"[patch-fail] GameSpawnManager.FKEICBPCMPJ index normalizer: {ex.Message}");
return false;
}
}
// POSTFIX on GameTeamManager.GetTeamPlayerIndex(Player). The RRO quest
// scenes only have spawn points for indexes 0-3. After raising the quest
// team cap, players 5+ legitimately receive INDEX_4+, but those values
// cannot match any baked quest spawn point. Normalize just the enum result
// that spawn code asks for; the actual team roster/cap remains raised.
public static void NormalizeTeamPlayerIndex_Postfix<T>(ref T __result) where T : struct
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
var value = Convert.ToInt32(__result);
if (!TryNormalizeIndex(value, out var normalized)) return;
__result = (T)Enum.ToObject(typeof(T), normalized);
if (_teamIndexNormalizeLogCount < 12)
{
_teamIndexNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized team-player index {value} -> {normalized} for quest spawn lookup " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] team-player index normalize failed: {ex.Message}");
}
}
// POSTFIX on GameSpawnManager.ALCADGJKKHJ(..., out CCMLKDOHKJE index, ...).
// FPBFJMMKPPJ calls this private helper immediately before FKEICBPCMPJ, so
// this is the exact spawn-path value seen in the "TeamIndex INDEX_4" log.
public static void NormalizeSpawnModeData_Postfix<T>(ref T __3) where T : struct
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
var value = Convert.ToInt32(__3);
if (!TryNormalizeIndex(value, out var normalized)) return;
__3 = (T)Enum.ToObject(typeof(T), normalized);
if (_spawnModeDataNormalizeLogCount < 12)
{
_spawnModeDataNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized spawn-mode team-player index {value} -> {normalized} " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] spawn-mode index normalize failed: {ex.Message}");
}
}
// PREFIX on GameSpawnManager.FKEICBPCMPJ(..., CCMLKDOHKJE requiredIndex, ...).
// This is the last guard before the spawn point field compare at +0x88.
public static void NormalizeRequiredSpawnIndex_Prefix<T>(ref T __2) where T : struct
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
var value = Convert.ToInt32(__2);
if (!TryNormalizeIndex(value, out var normalized)) return;
__2 = (T)Enum.ToObject(typeof(T), normalized);
if (_requiredSpawnIndexNormalizeLogCount < 12)
{
_requiredSpawnIndexNormalizeLogCount++;
Mod.Log.Msg($"[questsize] normalized required spawn index {value} -> {normalized} " +
$"activeQuest={_activeAllowedQuestConfig}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] required spawn index normalize failed: {ex.Message}");
}
}
private static bool TryNormalizeIndex(int value, out int normalized)
{
normalized = value % 4;
return value >= 4;
}
// PREFIX on GameConfigurationAsset.GDECPANDBHJ() (asset → protobuf builder).
public static void Build_Prefix(object __instance)
{
try
{
var target = Mod.Cfg.QuestMaxTeamSize;
if (target <= 0 || __instance is null) return;
var name = GetMember(__instance, "Name") as string ?? "";
var allowed = IsAllowed(name);
_activeAllowedQuestConfig = allowed;
if (_seen.Add(name))
Mod.Log.Msg($"[questsize] config build '{name}' allowed={allowed}");
if (!allowed) return;
var arr = GetMember(__instance, "TeamConfigurations");
int len = ArrLen(arr);
if (len <= 0) return;
int bumped = 0;
for (int i = 0; i < len; i++)
{
var elem = ArrItem(arr, i); // boxed TeamConfiguration struct (copy)
if (elem is null) continue;
int cur = ReadInt(elem, "MaxTeamSize");
if (cur > 0 && cur < target &&
WriteInt(elem, "MaxTeamSize", target) &&
SetArrItem(arr!, i, elem)) // write the mutated struct back
bumped++;
}
if (bumped > 0 && !_loggedBump)
{
_loggedBump = true;
Mod.Log.Msg($"[questsize] '{name}': bumped {bumped} team slot(s) → {target}");
}
}
catch (Exception ex) { Mod.Log.Warning($"[questsize] build prefix failed: {ex.Message}"); }
}
// POSTFIX on GameSpawnManager.FKEICBPCMPJ(..., outputList). That method
// filters spawn points by state/game-mode/spectator/team/team-player-index/
// custom-tag before the spawn selector runs. The RRO quest prefabs only
// have indexed start points for players 0-3; once we raise the team size,
// player index 4+ can filter every point out before the built-in overlap
// fallback has a chance to help.
//
// December decompile: FPBFJMMKPPJ calls this filter, then if output.Count
// is still 0 it logs "due to spawn point requirement tests" and falls back
// to the unfiltered source list as an arbitrary spawn set. Filling the
// output list here keeps the normal overlap pass and spawn-selection mode
// alive instead of taking that bad arbitrary fallback.
//
// Harmony binds __0 and __7 by argument position: source list and output
// list. The obfuscated parameter names are not reliable in MelonLoader's
// generated IL2CPP metadata, which is why this intentionally avoids them.
// If the filtered output is empty, copy the already-selected candidate
// scene spawn list back into the output so extra players reuse baked quest
// spawn points instead of being left out in the void.
public static void RelaxEmptySpawnFilter_Postfix(object __0, object __7)
{
try
{
if (Mod.Cfg.QuestMaxTeamSize <= 4) return;
if (Count(__7) > 0) return;
var sourceCount = Count(__0);
if (sourceCount <= 0) return;
var copied = CopyListItems(__0, __7);
if (_spawnRelaxLogCount < 12)
{
_spawnRelaxLogCount++;
Mod.Log.Msg($"[questsize] relaxed empty quest spawn filter; activeQuest={_activeAllowedQuestConfig} " +
$"source={sourceCount} copied={copied} " +
$"sourceType={TypeName(__0)} outputType={TypeName(__7)}");
}
}
catch (Exception ex)
{
Mod.Log.Warning($"[questsize] spawn filter relax failed: {ex.Message}");
}
}
// ── allowlist matching (normalize: lowercase-alnum, strip filler) ──────
private static List<string>? _wanted;
private static bool IsAllowed(string configName)
{
_wanted ??= BuildWanted();
if (_wanted.Count == 0) return false;
var have = Norm(configName);
foreach (var w in _wanted)
if (have.Contains(w)) return true;
return false;
}
private static List<string> BuildWanted()
{
var list = new List<string>();
foreach (var entry in Mod.Cfg.QuestMaxTeamSizeRooms)
{
var nrm = Norm(entry);
if (nrm.Length > 0) list.Add(nrm);
}
return list;
}
private static string Norm(string s)
{
if (string.IsNullOrEmpty(s)) return "";
var sb = new System.Text.StringBuilder(s.Length);
foreach (var c in s)
if (char.IsLetterOrDigit(c)) sb.Append(char.ToLowerInvariant(c));
return sb.ToString().Replace("the", "").Replace("for", "").Replace("of", "");
}
// ── reflection helpers (Il2CppInterop exposes il2cpp members as either a
// property or a field depending on version — try both) ────────────────
private static object? GetMember(object o, string name)
{
var t = o.GetType();
var p = t.GetProperty(name, BF);
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
{ try { return p.GetValue(o); } catch { } }
var f = t.GetField(name, BF);
if (f != null) { try { return f.GetValue(o); } catch { } }
return null;
}
private static int ReadInt(object? o, string name)
{
if (o is null) return 0;
var v = GetMember(o, name);
if (v is int n) return n;
return v != null && int.TryParse(v.ToString(), out var p) ? p : 0;
}
private static bool WriteInt(object o, string name, int val)
{
var t = o.GetType();
var p = t.GetProperty(name, BF);
if (p != null && p.CanWrite) { try { p.SetValue(o, val); return true; } catch { } }
var f = t.GetField(name, BF);
if (f != null) { try { f.SetValue(o, val); return true; } catch { } }
return false;
}
private static int ArrLen(object? arr)
{
if (arr is null) return 0;
if (arr is Array a) return a.Length;
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
return (lp?.GetValue(arr) as int?) ?? 0;
}
private static int Count(object? collection) => ArrLen(collection);
private static object? ArrItem(object? arr, int i)
{
if (arr is null) return null;
if (arr is Array a) return a.GetValue(i);
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
return gi?.Invoke(arr, new object[] { i });
}
// Write a (boxed) struct element back into an Il2CppStructArray via its
// set_Item(int, T) indexer. Reflection unboxes to T on invoke.
private static bool SetArrItem(object arr, int i, object val)
{
if (arr is Array a) { try { a.SetValue(val, i); return true; } catch { return false; } }
foreach (var m in arr.GetType().GetMethods())
{
if (m.Name != "set_Item") continue;
var ps = m.GetParameters();
if (ps.Length == 2 && ps[0].ParameterType == typeof(int))
{
try { m.Invoke(arr, new[] { i, val }); return true; } catch { }
}
}
return false;
}
private static int CopyListItems(object? src, object? dst)
{
if (src is null || dst is null) return 0;
MethodInfo? add = null;
foreach (var method in dst.GetType().GetMethods())
{
if (method.Name != "Add") continue;
if (method.GetParameters().Length != 1) continue;
add = method;
break;
}
if (add is null) return 0;
var n = ArrLen(src);
var copied = 0;
for (var i = 0; i < n; i++)
{
var item = ArrItem(src, i);
if (item is null) continue;
try
{
add.Invoke(dst, new[] { item });
copied++;
}
catch { }
}
return copied;
}
private static string TypeName(object? value) =>
value?.GetType().FullName ?? "<null>";
}
@@ -1,5 +1,5 @@
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// AuthValuesInjector — PARKED 2026-05-28 (december-2020-12-18) // AuthValuesInjector — PARKED 2026-05-28
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// //
// Filename suffix is .cs.attic on purpose so the .NET SDK's globber // Filename suffix is .cs.attic on purpose so the .NET SDK's globber
@@ -8,41 +8,48 @@
// //
// What this used to do // What this used to do
// -------------------- // --------------------
// Built a Photon AuthenticationValues with `userid` + `LoginLock` // Prefix-patched NetworkingPeer.CallAuthenticate so the 2020 watch
// custom-auth parameters from the in-game RecNet account state. The // would attach a Photon Custom-Auth payload (`userid` +
// server's /photon/customauth controller (PhotonCustomAuthController.cs) // `LoginLock`) to every Photon Authenticate op. The server's
// validated those parameters against the player's /player/login lock. // /photon/customauth controller (PhotonCustomAuthController.cs) read
// those parameters and validated them against the player's
// /player/login lock.
// //
// Status in this branch // Why it's parked
// --------------------- // ---------------
// The method existed but was **never actually wired to a harmony // Photon Cloud's Custom Auth probe was getting 403'd at the WAF in
// hook** in this branch's OnLateInitializeMelon. December's Photon // front of `auth.<apex>/photon/customauth` for hosts behind
// AuthValues path goes through PhotonPatches.LogAuthValues_Postfix // Cloudflare or Tunnelto's proxy. The Photon NameServer then returned
// (postfix on PUNNetworkManager.EMGGKBALALH), which only logs — it // `CustomAuthenticationFailed (ReturnCode 32755)` and the watch
// doesn't inject. So this code was effectively dead in december. // bounced back to the dorm before a session could start.
// //
// Removed because: // The cleaner fix turned out to be:
// 1. Photon Cloud is currently configured with Custom Authentication // 1. Turn off Custom Authentication on the Photon AppId in the
// disabled at the AppId level (anonymous connects allowed), so // dashboard (or flip "Anonymous connect" to Allow), so Photon
// no server-side auth probe. // never calls our URL.
// 2. Even when the watch did send AuthValues, the upstream Cloudflare // 2. Stop sending custom-auth params from the client — that's the
// / Localtunnel layer 403'd Photon's auth probe before our // InjectAuthValues=false flag we shipped first. With the
// controller could decide anything. // 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 // How to put it back
// ------------------ // ------------------
// 1. Rename this file to AuthValuesInjector.cs (drop the .attic suffix). // 1. Rename this file to AuthValuesInjector.cs (drop the .attic
// 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the LoadConfig // suffix). It compiles as part of DorkNet.ClientMod.
// line. // 2. Reinstate the `InjectAuthValues` field on Mod.Cfg + the
// 3. Wire the harmony hook in OnLateInitializeMelon. The march branch's // config-load line in LoadConfig.
// pick of NetworkingPeer.CallAuthenticate doesn't fire in IL2CPP, so // 3. Re-add the harmony patch hook in OnLateInitializeMelon:
// consider hooking PUNNetworkManager.EMGGKBALALH postfix instead and // TryPatchByName("NetworkingPeer", "CallAuthenticate",
// mutating its returned AuthValues in place — that's the mangled // prefix: nameof(PhotonPatches.AuthValuesInjector_Prefix));
// method december verifies fires (LogAuthValues_Postfix already // 4. Flip `"InjectAuthValues": true` in
// proves this entry point works). // DorkNet.ClientMod/dorknet-clientmod.json.template (and the
// 4. Put `"InjectAuthValues": true` back in dorknet-clientmod.json.template. // copy under local-releases/).
// 5. If you also want the server to enforce the lock, set // 5. If you also want the server to enforce the lock, set
// Photon__RequireCustomAuth=true on the server. // 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; using System.Reflection;
@@ -85,6 +92,9 @@ internal static class AuthValuesInjector
var token = GetCurrentLockToken(); 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"); var authValuesType = FindGameType("ExitGames.Client.Photon.LoadBalancing.AuthenticationValues");
if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; } if (authValuesType is null) { Mod.Log.Warning("[auth-injector] AuthenticationValues type not found"); return; }
var av = Activator.CreateInstance(authValuesType); var av = Activator.CreateInstance(authValuesType);
@@ -125,8 +135,30 @@ internal static class AuthValuesInjector
catch { return null; } catch { return null; }
} }
// FindGameType in the live Mod.cs is a delegate to Mod.ResolveType. // FindGameType is already defined inside Mod.cs's PhotonPatches
// Use that on restore; this is just a placeholder to keep the file // class (private static) — when re-instating, either drop this
// compilable if someone untreats it without re-checking the wiring. // private copy and inline the lookup, or move the helper to a
private static Type? FindGameType(string name) => null; // 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,15 +1,8 @@
{ {
"ServerHost": "{HOST}", "ServerHost": "{HOST}",
"SingleOriginBaseUrl": "{SINGLE_ORIGIN_BASE_URL}",
"PhotonAppId": "{PHOTON_APPID}", "PhotonAppId": "{PHOTON_APPID}",
"PhotonVoiceAppId": "{PHOTON_VOICE_APPID}", "PhotonVoiceAppId": "{PHOTON_VOICE_APPID}",
"PhotonCloudRegion": "{PHOTON_REGION}", "PhotonCloudRegion": "{PHOTON_REGION}",
"EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS}, "EnableTlsTrustBypass": {ENABLE_TLS_TRUST_BYPASS}
"QuestMaxTeamSize": 0,
"QuestMaxTeamSizeRooms": [
"CrimsonCauldron",
"Crescendo",
"GoldenTrophy",
"IsleOfLostSkulls",
"TheRiseofJumbotron"
]
} }
@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
-312
View File
@@ -1,312 +0,0 @@
namespace DorkNet.Contracts;
public static class DorkNetRouteOwnership
{
private const string AdminHost = "admin";
private static readonly DorkNetRouteGroup[] ServiceRouteGroups =
[
new(
ServiceNames.Identity,
["auth", "accounts"],
[
"/.well-known",
"/account",
"/api/account",
"/api/auth",
"/api/createaccount",
"/api/platformlogin",
"/api/playerreputation",
"/api/players",
"/api/relationships",
"/api/settings",
"/cachedlogin",
"/connect",
"/eac",
"/parentalcontrol",
"/photon",
"/profileimage",
"/settings",
]),
new(
ServiceNames.Rooms,
["rooms", "match", "discovery", "lists", "roomcomments", "roomieintegrations", "studio"],
[
"/api/activities",
"/api/curatedroomplaylists",
"/api/gameconfigs",
"/api/gameconfigurations",
"/api/gamesessions",
"/api/leaderboard",
"/api/playerelo",
"/api/playlists",
"/api/roomkeys",
"/api/rooms",
"/api/royale",
"/discover",
"/featuredrooms",
"/goto",
"/hot_rooms",
"/hot_roomsandplaylists",
"/invite",
"/playlists",
"/player",
"/room",
"/roominstance",
"/rooms",
"/roomsandplaylists",
"/roomserver",
"/search_rooms",
"/search_roomsandplaylists",
"/v1/find",
"/v1/join",
"/v1/leave",
"/v1/session",
]),
new(
ServiceNames.Notify,
["notify", "chat", "platformnotifications"],
[
"/api/messages",
"/api/notification",
"/api/offlineinvite",
"/hub",
"/notify",
"/thread",
]),
new(
ServiceNames.Content,
["cdn", "data", "img", "storage", "strings-cdn", "videos"],
[
"/api/images",
"/api/photos",
"/config",
"/data",
"/img",
"/invention",
"/upload",
"/video",
]),
new(
ServiceNames.Social,
["clubs"],
[
"/announcements",
"/api/announcement",
"/api/clubreporting",
"/api/communityboard",
"/api/groups",
"/api/playercheer",
"/api/playerevents",
"/api/playersubscriptions",
"/club",
"/members",
"/subscription",
]),
new(
ServiceNames.Commerce,
["commerce", "econ"],
[
"/api/avatar",
"/api/catalog",
"/api/compatibility",
"/api/consumables",
"/api/equipment",
"/api/gamerewards",
"/api/inventions",
"/api/purchase",
"/api/storefronts",
"/commerce",
"/econ",
]),
new(
ServiceNames.Platform,
["cards", "cms", "datacollection", "gamelogs", "geo", "leaderboard", "link", "ns", "playersettings", "strings", "thorn"],
[
"/api/cards",
"/api/config",
"/api/events",
"/api/quickplay",
"/api/role",
"/api/version",
"/api/versioncheck",
"/motd",
"/pageview",
"/ping",
"/role",
"/strings",
"/v1/ping",
"/v1/player",
"/v1/regions",
"/v1/services",
]),
new(
ServiceNames.Moderation,
["bugreporting", "moderation"],
[
"/api/admin",
"/api/banappeal",
"/api/bugreporting",
"/api/moderation",
"/api/playerreporting",
"/api/playersbanned",
"/api/sanitize",
"/api/testcasemanagement",
]),
new(
ServiceNames.Web,
["www", AdminHost, "feed"],
[
"/api/site",
],
IncludesApexHost: true),
];
public static IReadOnlyList<DorkNetRouteGroup> RouteGroups => ServiceRouteGroups;
public static IReadOnlyList<string> PublicSubdomains =>
ServiceRouteGroups
.SelectMany(group => group.HostSubdomains)
// api.* is a shared public host: it owns no service by host and is
// dispatched by path (see ResolvePublicService's path-prefix pass),
// so it never appears in a group's HostSubdomains. It must still be
// whitelisted here or HostFiltering rejects every api.{apex} request.
.Append("api")
.Append(AdminHost)
.Append("feed")
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
public static bool IsInfrastructurePath(string path)
{
return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/internal/", StringComparison.OrdinalIgnoreCase);
}
public static string ResolvePublicService(string? host, string path, string apexDomain)
{
var normalizedPath = NormalizePath(path);
var subdomain = Subdomain(host, apexDomain);
if (string.Equals(subdomain, AdminHost, StringComparison.OrdinalIgnoreCase))
{
return ServiceNames.Web;
}
foreach (var group in ServiceRouteGroups.Where(group => group.ServiceName != ServiceNames.Web))
{
if (MatchesHost(subdomain, group.HostSubdomains))
{
return group.ServiceName;
}
}
foreach (var group in ServiceRouteGroups)
{
if (StartsWithAny(normalizedPath, group.PathPrefixes))
{
return group.ServiceName;
}
}
var web = ServiceRouteGroups.First(group => group.ServiceName == ServiceNames.Web);
if ((subdomain is null && web.IncludesApexHost) || MatchesHost(subdomain, web.HostSubdomains))
{
return ServiceNames.Web;
}
return ServiceNames.Monolith;
}
public static bool IsOwnedBy(string serviceName, string? host, string path, string apexDomain)
{
if (IsInfrastructurePath(path))
{
return true;
}
return string.Equals(
ResolvePublicService(host, path, apexDomain),
serviceName,
StringComparison.OrdinalIgnoreCase);
}
public static string? Subdomain(string? host, string apexDomain)
{
if (string.IsNullOrWhiteSpace(host))
{
return null;
}
var normalizedHost = host.Split(':', 2)[0].Trim().ToLowerInvariant();
var normalizedApex = apexDomain.Split(':', 2)[0].Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(normalizedHost)
|| string.IsNullOrWhiteSpace(normalizedApex)
|| string.Equals(normalizedHost, normalizedApex, StringComparison.OrdinalIgnoreCase))
{
return null;
}
var suffix = "." + normalizedApex;
if (normalizedHost.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return normalizedHost[..^suffix.Length];
}
var dot = normalizedHost.IndexOf('.');
return dot > 0 ? normalizedHost[..dot] : null;
}
private static bool StartsWithAny(string path, IEnumerable<string> prefixes)
{
foreach (var prefix in prefixes)
{
if (StartsWithPathSegment(path, prefix))
{
return true;
}
}
return false;
}
private static bool StartsWithPathSegment(string path, string prefix)
{
var normalizedPath = NormalizePath(path);
var normalizedPrefix = NormalizePath(prefix).TrimEnd('/');
if (string.IsNullOrWhiteSpace(normalizedPrefix))
{
normalizedPrefix = "/";
}
return normalizedPath.Equals(normalizedPrefix, StringComparison.OrdinalIgnoreCase)
|| normalizedPath.StartsWith(normalizedPrefix + "/", StringComparison.OrdinalIgnoreCase);
}
private static bool MatchesHost(string? subdomain, IEnumerable<string> candidates)
{
if (string.IsNullOrWhiteSpace(subdomain))
{
return false;
}
return candidates.Contains(subdomain, StringComparer.OrdinalIgnoreCase);
}
private static string NormalizePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return "/";
}
return path[0] == '/' ? path : "/" + path;
}
}
public sealed record DorkNetRouteGroup(
string ServiceName,
string[] HostSubdomains,
string[] PathPrefixes,
bool IncludesApexHost = false);
@@ -1,38 +0,0 @@
namespace DorkNet.Contracts;
public sealed class DorkNetServiceMapOptions
{
public string Identity { get; set; } = "http://localhost:8081";
public string Rooms { get; set; } = "http://localhost:8082";
public string Notify { get; set; } = "http://localhost:8083";
public string Content { get; set; } = "http://localhost:8084";
public string Social { get; set; } = "http://localhost:8085";
public string Commerce { get; set; } = "http://localhost:8086";
public string Platform { get; set; } = "http://localhost:8087";
public string Moderation { get; set; } = "http://localhost:8088";
public string Web { get; set; } = "http://localhost:8089";
public string Monolith { get; set; } = "http://localhost:8090";
public IReadOnlyList<DorkNetServiceEndpoint> Endpoints()
{
DorkNetServiceEndpoint[] endpoints =
[
new(ServiceNames.Identity, Identity),
new(ServiceNames.Rooms, Rooms),
new(ServiceNames.Notify, Notify),
new(ServiceNames.Content, Content),
new(ServiceNames.Social, Social),
new(ServiceNames.Commerce, Commerce),
new(ServiceNames.Platform, Platform),
new(ServiceNames.Moderation, Moderation),
new(ServiceNames.Web, Web),
new(ServiceNames.Monolith, Monolith),
];
return endpoints
.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint.BaseUrl))
.ToArray();
}
}
public sealed record DorkNetServiceEndpoint(string Name, string BaseUrl);
-19
View File
@@ -1,19 +0,0 @@
namespace DorkNet.Contracts;
public sealed record ServiceHealthResponse(
string Service,
string Status,
DateTimeOffset CheckedAtUtc);
public sealed record ServiceCapabilityResponse(
string Service,
string[] Owns,
string[] PlannedPublicRoutes);
public sealed record ServiceProbeResponse(
string Service,
string BaseUrl,
string Status,
int? StatusCode,
string? Error,
DateTimeOffset CheckedAtUtc);
-16
View File
@@ -1,16 +0,0 @@
namespace DorkNet.Contracts;
public static class ServiceNames
{
public const string Gateway = "gateway";
public const string Identity = "identity";
public const string Rooms = "rooms";
public const string Notify = "notify";
public const string Content = "content";
public const string Social = "social";
public const string Commerce = "commerce";
public const string Platform = "platform";
public const string Moderation = "moderation";
public const string Web = "web";
public const string Monolith = "monolith";
}
-290
View File
@@ -1,290 +0,0 @@
using HarmonyLib;
using Il2CppRecRoom.Debugging;
using MelonLoader;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
using UnityEngine;
using UObject = UnityEngine.Object;
[assembly: MelonInfo(typeof(DorkNet.DevMenu.DevMenuMod), "DorkNet DevMenu", "1.0.0", "Dork Squad")]
[assembly: MelonGame(null, null)]
namespace DorkNet.DevMenu;
public class DevMenuMod : MelonMod
{
internal static MelonLogger.Instance Log => Instance!.LoggerInstance;
internal static DevMenuMod? Instance;
internal HarmonyLib.Harmony PatchHarmony => HarmonyInstance;
private static readonly Dictionary<string, Type> ResolvedTypeCache = new();
public static class Cfg
{
public static bool EnableDebugConsole = false;
public static string DebugConsoleToggleKey = "BackQuote";
public static bool DiagnoseDevMenu = false;
public static bool EnableDevWatchButton = false;
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
}
public override void OnInitializeMelon()
{
Instance = this;
Log.Msg("=== DorkNet DevMenu loading ===");
LoadConfig();
if (Cfg.EnableDebugConsole)
{
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
Log.Msg($"[debugconsole] enabled; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
}
}
public override void OnUpdate()
{
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
if (Cfg.DiagnoseDevMenu) DevMenuProbe.Tick();
if (Cfg.EnableDevWatchButton) DevWatchButton.Tick();
}
private static void LoadConfig()
{
try
{
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);
var devPath = Path.Combine(userData, "dorknet-devmenu.json");
var legacyPath = Path.Combine(userData, "dorknet-clientmod.json");
var path = File.Exists(devPath) ? devPath : legacyPath;
if (!File.Exists(path))
{
Log.Msg($"[config] no dev config found; all dev menu features disabled");
return;
}
var doc = JsonDocument.Parse(File.ReadAllText(path));
var r = doc.RootElement;
if (r.TryGetProperty("EnableDebugConsole", out var v)) Cfg.EnableDebugConsole = v.GetBoolean();
if (r.TryGetProperty("DebugConsoleToggleKey", out v)) Cfg.DebugConsoleToggleKey = v.GetString() ?? Cfg.DebugConsoleToggleKey;
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
{
var list = new List<string>();
foreach (var e in v.EnumerateArray())
{
var s = e.GetString();
if (!string.IsNullOrWhiteSpace(s)) list.Add(s!);
}
if (list.Count > 0) Cfg.DevCommands = list.ToArray();
}
Log.Msg($"[config] loaded {Path.GetFileName(path)}: debugConsole={Cfg.EnableDebugConsole}, devWatchButton={Cfg.EnableDevWatchButton}, diagnose={Cfg.DiagnoseDevMenu}");
}
catch (Exception ex)
{
Log.Warning($"[config] load failed, using disabled defaults: {ex.Message}");
}
}
private bool TryPatchByName(string typeName, string methodName, Type[]? args = null, string? prefix = null, string? postfix = null)
{
try
{
var type = ResolveType(typeName);
if (type is null)
{
Log.Warning($"[patch-miss] {typeName}.{methodName}: type not found");
return false;
}
var method = args is null
? AccessTools.Method(type, methodName)
: AccessTools.Method(type, methodName, args);
if (method is null)
{
Log.Warning($"[patch-miss] {typeName}.{methodName}: method not found");
return false;
}
HarmonyMethod? pre = prefix is null ? null : new HarmonyMethod(GetPatchMethod(prefix));
HarmonyMethod? post = postfix is null ? null : new HarmonyMethod(GetPatchMethod(postfix));
Instance!.PatchHarmony.Patch(method, pre, post);
Log.Msg($"[patch-ok] {typeName}.{methodName}");
return true;
}
catch (Exception ex)
{
Log.Warning($"[patch-fail] {typeName}.{methodName}: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
private static MethodInfo GetPatchMethod(string name)
{
foreach (var holder in new[] { 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");
}
internal static Type? ResolveType(string requested)
{
if (ResolvedTypeCache.TryGetValue(requested, out var cached)) return cached;
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
Type? found = null;
try
{
found = asm.GetType(requested, throwOnError: false)
?? asm.GetType("Il2Cpp" + requested, throwOnError: false);
if (found is null)
{
foreach (var t in asm.GetTypes())
{
if (t.FullName == requested ||
t.Name == requested ||
t.FullName?.EndsWith("." + requested, StringComparison.Ordinal) == true ||
t.FullName?.EndsWith(".Il2Cpp" + requested, StringComparison.Ordinal) == true)
{
found = t;
break;
}
}
}
}
catch { }
if (found is null) continue;
ResolvedTypeCache[requested] = found;
return found;
}
return null;
}
}
internal static class DebugConsolePatches
{
private static bool _consoleVisible;
private static bool _warned;
private static KeyCode _toggleKey = KeyCode.BackQuote;
private static bool _keyParsed;
private static MethodInfo? _toggleMethod;
public static bool SuppressCheatDetected_Prefix() => false;
public static void PollToggleKey()
{
try
{
if (!_keyParsed)
{
_keyParsed = true;
if (!Enum.TryParse(DevMenuMod.Cfg.DebugConsoleToggleKey, ignoreCase: true, out _toggleKey))
{
_toggleKey = KeyCode.BackQuote;
DevMenuMod.Log.Warning($"[debugconsole] invalid key '{DevMenuMod.Cfg.DebugConsoleToggleKey}', defaulting to BackQuote");
}
}
if (Input.GetKeyDown(_toggleKey))
{
Toggle();
}
}
catch (Exception ex)
{
if (!_warned)
{
_warned = true;
DevMenuMod.Log.Warning($"[debugconsole] poll failed: {ex.Message}");
}
}
}
private static void Toggle()
{
try
{
var console = UObject.FindObjectOfType<DebugConsole>();
if (console == null)
{
DevMenuMod.Log.Warning("[debugconsole] no live DebugConsole in scene");
return;
}
_toggleMethod ??= AccessTools.Method(typeof(DebugConsole), "ShowInputField", new[] { typeof(bool) })
?? AccessTools.Method(typeof(DebugConsole), "MCNBJFECHLJ", new[] { typeof(bool) })
?? FindToggleByShape(typeof(DebugConsole));
if (_toggleMethod is null)
{
DevMenuMod.Log.Warning("[debugconsole] show/hide method not found");
return;
}
_consoleVisible = !_consoleVisible;
_toggleMethod.Invoke(console, new object[] { _consoleVisible });
DevMenuMod.Log.Msg($"[debugconsole] {(_consoleVisible ? "opened" : "closed")}");
}
catch (Exception ex)
{
DevMenuMod.Log.Warning($"[debugconsole] toggle failed: {ex.Message}");
}
}
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;
found = m;
}
return found;
}
}
internal static class DevMenuProbe
{
private static int _frame;
private static bool _done;
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 180) return;
_done = true;
var homeType = DevMenuMod.ResolveType("AGUI.StackedUI.HomeScreenFlow") ?? DevMenuMod.ResolveType("HomeScreenFlow");
var consoleType = DevMenuMod.ResolveType("RecRoom.Debugging.DebugConsole") ?? typeof(DebugConsole);
DevMenuMod.Log.Msg("=== [devmenu-probe] runtime dev-menu state ===");
DevMenuMod.Log.Msg($"[devmenu-probe] HomeScreenFlow type: {(homeType is null ? "NOT FOUND" : homeType.FullName)}");
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole type: {(consoleType is null ? "NOT FOUND" : consoleType.FullName)}");
try
{
var console = UObject.FindObjectOfType<DebugConsole>();
DevMenuMod.Log.Msg($"[devmenu-probe] DebugConsole live instance: {(console == null ? "null" : "FOUND")}");
}
catch (Exception ex)
{
DevMenuMod.Log.Warning($"[devmenu-probe] console instance probe failed: {ex.Message}");
}
DevMenuMod.Log.Msg("=== [devmenu-probe] done ===");
}
}
-124
View File
@@ -1,124 +0,0 @@
// Native watch "Dev Console" injection — typed against the per-install Il2Cpp
// interop assemblies (referenced in the csproj, dev-tools only, never shipped).
// Pure reflection couldn't drive this build's watch UI (FindObjectOfType /
// property-get / GetComponentsInChildren all failed under Il2CppInterop), so
// we use real types here: clone an existing watch Button3D, relabel it, and
// wire its OnClick to run console commands via DebugConsole.Execute (which
// works headless even though the console UI isn't in the scene). It's the
// game's own Button3D, so it's interactable with the VR laser AND the
// Screen-Mode mouse. Gated behind Cfg.EnableDevWatchButton.
using System;
using Il2CppInterop.Runtime;
using Il2CppAGUI.StackedUI;
using Il2CppRecRoom.Debugging;
using Il2CppTMPro;
using UnityEngine.Events;
using UObject = UnityEngine.Object;
namespace DorkNet.DevMenu;
internal static class DevWatchButton
{
private static int _frame;
private static bool _done;
// Polled from Mod.OnUpdate while Cfg.EnableDevWatchButton is set. Waits for
// the watch's HomeScreenFlow to exist, then injects once.
public static void Tick()
{
if (_done) return;
_frame++;
if (_frame < 120 || (_frame % 30) != 0) return; // settle, then ~2/s
var home = UObject.FindObjectOfType<HomeScreenFlow>();
if (home == null && _frame < 3600) return; // wait for the watch
_done = true;
if (home == null) { DevMenuMod.Log.Warning("[devbtn] HomeScreenFlow never appeared"); return; }
try { Inject(home); }
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] inject failed: {ex}"); }
}
private static void Inject(HomeScreenFlow home)
{
var buttons = home.GetComponentsInChildren<Button3D>(true);
if (buttons == null || buttons.Length == 0)
{
DevMenuMod.Log.Warning("[devbtn] no Button3D found under HomeScreenFlow");
return;
}
// Prefer a source button that HAS a TMP label, so our clones can be
// relabeled (HomeButton etc. are icon-only — no text child).
Button3D? src = null;
for (int i = 0; i < buttons.Length; i++)
if (buttons[i].GetComponentInChildren<TextMeshProUGUI>(true) != null) { src = buttons[i]; break; }
bool labeled = src != null;
if (src == null) src = buttons[0]; // fall back: inject anyway, just no text
DevMenuMod.Log.Msg($"[devbtn] {buttons.Length} watch buttons; clone source='{src.name}' labeled={labeled}");
int n = 0;
foreach (var (label, cmd) in ParsePresets())
{
var clone = UObject.Instantiate(src, src.transform.parent);
clone.name = $"DorkNetDev_{label}";
clone.gameObject.SetActive(true);
clone.transform.localScale = src.transform.localScale;
var tmp = clone.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null) tmp.text = label;
var command = cmd; // capture per-button
clone.OnClick.RemoveAllListeners();
clone.OnClick.AddListener(DelegateSupport.ConvertDelegate<UnityAction>((Action)(() => Run(label, command))));
_clones.Add(clone);
n++;
}
DevMenuMod.Log.Msg($"[devbtn] injected {n} dev command button(s) into the watch bar — open the watch to use them");
DumpCommands();
}
// Log the real console command names from DebugConsoleCommandConfig.Metas
// so we can set DevCommands correctly (the asset loads even though the
// console component isn't in the scene).
private static void DumpCommands()
{
try
{
var cfgs = UObject.FindObjectsOfTypeAll(Il2CppType.Of<DebugConsoleCommandConfig>());
DevMenuMod.Log.Msg($"[devcmds] {cfgs.Length} DebugConsoleCommandConfig asset(s) loaded");
for (int c = 0; c < cfgs.Length; c++)
{
var cfg = cfgs[c].TryCast<DebugConsoleCommandConfig>();
var metas = cfg?.Metas;
if (metas == null) continue;
DevMenuMod.Log.Msg($"[devcmds] {metas.Count} command(s):");
for (int i = 0; i < metas.Count; i++)
{
var m = metas[i];
DevMenuMod.Log.Msg($" {m.MethodName} ({m.DeclaringType})");
}
}
}
catch (Exception ex) { DevMenuMod.Log.Warning($"[devcmds] failed: {ex.Message}"); }
}
private static void Run(string label, string command)
{
DevMenuMod.Log.Msg($"[devbtn] '{label}' -> DebugConsole.Execute(\"{command}\")");
try { DebugConsole.Execute(command); }
catch (Exception ex) { DevMenuMod.Log.Warning($"[devbtn] Execute('{command}') failed: {ex.Message}"); }
}
// Presets come from Cfg.DevCommands as "Label=command" strings.
private static System.Collections.Generic.IEnumerable<(string label, string cmd)> ParsePresets()
{
foreach (var entry in DevMenuMod.Cfg.DevCommands)
{
if (string.IsNullOrWhiteSpace(entry)) continue;
var eq = entry.IndexOf('=');
if (eq > 0) yield return (entry.Substring(0, eq).Trim(), entry.Substring(eq + 1).Trim());
else yield return (entry.Trim(), entry.Trim());
}
}
private static readonly System.Collections.Generic.List<Button3D> _clones = new();
}
-68
View File
@@ -1,68 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>DorkNet.DevMenu</RootNamespace>
<AssemblyName>DorkNet.DevMenu</AssemblyName>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<NoWarn>$(NoWarn);CS8602;CS8604;CS0436</NoWarn>
</PropertyGroup>
<PropertyGroup>
<MelonLoaderVersion Condition="'$(MelonLoaderVersion)' == ''">v0.6.6</MelonLoaderVersion>
<MelonLoaderCacheRoot>$(MSBuildThisFileDirectory)..\DorkNet.ClientMod\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>
<PropertyGroup>
<Il2CppAsmDir Condition="'$(Il2CppAsmDir)' == ''">C:\Users\Alexa\Downloads\DorkRoom-2020.12\MelonLoader\Il2CppAssemblies</Il2CppAsmDir>
</PropertyGroup>
<ItemGroup Condition="Exists('$(Il2CppAsmDir)')">
<Reference Include="Assembly-CSharp"><HintPath>$(Il2CppAsmDir)\Assembly-CSharp.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2Cppmscorlib"><HintPath>$(Il2CppAsmDir)\Il2Cppmscorlib.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.CoreModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.UI"><HintPath>$(Il2CppAsmDir)\UnityEngine.UI.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.InputLegacyModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.InputLegacyModule.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Unity.TextMeshPro"><HintPath>$(Il2CppAsmDir)\Unity.TextMeshPro.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2CppRecRoom.Promises.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Promises.Runtime.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2CppRecRoom.Datastructures.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Datastructures.Runtime.dll</HintPath><Private>false</Private></Reference>
</ItemGroup>
</Project>
@@ -1,11 +0,0 @@
{
"EnableDebugConsole": false,
"DebugConsoleToggleKey": "BackQuote",
"DiagnoseDevMenu": false,
"EnableDevWatchButton": false,
"DevCommands": [
"Help=help",
"Fly=fly",
"NoClip=noclip"
]
}
-18
View File
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
-392
View File
@@ -1,392 +0,0 @@
using DorkNet.Contracts;
using DorkNet.ServiceDefaults;
using Microsoft.Extensions.Options;
using Yarp.ReverseProxy.Configuration;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("appsettings.Local.json",
optional: true, reloadOnChange: true);
builder.AddDorkNetServiceDefaults(ServiceNames.Gateway);
builder.Services.Configure<DorkNetServiceMapOptions>(
builder.Configuration.GetSection("DorkNet:Services"));
var serviceMap = builder.Configuration
.GetSection("DorkNet:Services")
.Get<DorkNetServiceMapOptions>() ?? new DorkNetServiceMapOptions();
var apexDomain = builder.Configuration["Domain:Apex"]
?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN")
?? "localhost";
builder.Services
.AddReverseProxy()
.LoadFromMemory(
BuildRoutes(apexDomain, serviceMap),
BuildClusters(serviceMap));
var app = builder.Build();
app.MapDorkNetServiceDefaults();
var publicHosts = BuildPublicHosts(apexDomain);
app.Use(async (ctx, next) =>
{
if (DorkNetRouteOwnership.IsInfrastructurePath(ctx.Request.Path.Value ?? "/")
|| IsAllowedPublicHost(ctx.Request.Host.Host, publicHosts))
{
await next();
return;
}
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
await ctx.Response.WriteAsync("Unknown DorkNet host.");
});
app.MapGet("/internal/services", (IOptions<DorkNetServiceMapOptions> options) =>
Results.Ok(options.Value.Endpoints()));
app.MapGet("/internal/routes", () =>
Results.Ok(new
{
ApexDomain = apexDomain,
Routes = BuildRoutes(apexDomain, serviceMap).Select(r => new
{
r.RouteId,
r.ClusterId,
r.Match.Hosts,
r.Match.Path,
r.Order,
}),
}));
app.MapGet("/internal/services/health", async (
IOptions<DorkNetServiceMapOptions> options,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
var client = httpClientFactory.CreateClient();
var probes = new List<ServiceProbeResponse>();
foreach (var endpoint in options.Value.Endpoints())
{
probes.Add(await ProbeServiceAsync(client, endpoint, ct));
}
return Results.Ok(probes);
});
app.MapReverseProxy();
app.Run();
static async Task<ServiceProbeResponse> ProbeServiceAsync(
HttpClient client,
DorkNetServiceEndpoint endpoint,
CancellationToken ct)
{
var checkedAt = DateTimeOffset.UtcNow;
var baseUrl = endpoint.BaseUrl.TrimEnd('/');
try
{
using var response = await client.GetAsync($"{baseUrl}/internal/healthz", ct);
return new ServiceProbeResponse(
endpoint.Name,
endpoint.BaseUrl,
response.IsSuccessStatusCode ? "ok" : "unhealthy",
(int)response.StatusCode,
null,
checkedAt);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return new ServiceProbeResponse(
endpoint.Name,
endpoint.BaseUrl,
"unreachable",
null,
ex.Message,
checkedAt);
}
}
static IReadOnlyList<RouteConfig> BuildRoutes(
string apexDomain,
DorkNetServiceMapOptions services)
{
var routes = new List<RouteConfig>();
var order = 0;
var webGroup = DorkNetRouteOwnership.RouteGroups
.First(group => group.ServiceName == ServiceNames.Web);
AddHostRoute(
routes,
ref order,
webGroup.ServiceName,
BaseUrlFor(services, webGroup.ServiceName),
"web-admin-host",
[SubdomainHost("admin", apexDomain)]);
AddHostRoute(
routes,
ref order,
webGroup.ServiceName,
BaseUrlFor(services, webGroup.ServiceName),
"web-feed-host",
[SubdomainHost("feed", apexDomain)]);
AddHostPathRoutes(
routes,
ref order,
webGroup.ServiceName,
BaseUrlFor(services, webGroup.ServiceName),
WebBrowserHosts(apexDomain),
[
"/admin",
"/assets",
"/brand",
"/feed",
"/join",
"/p",
"/photo",
"/players",
"/rooms",
"/u",
]);
foreach (var group in DorkNetRouteOwnership.RouteGroups.Where(group => group.ServiceName != ServiceNames.Web))
{
AddHostRoute(
routes,
ref order,
group.ServiceName,
BaseUrlFor(services, group.ServiceName),
$"{group.ServiceName}-hosts",
group.HostSubdomains.Select(subdomain => SubdomainHost(subdomain, apexDomain)).ToArray());
}
foreach (var group in DorkNetRouteOwnership.RouteGroups)
{
AddPathRoutes(
routes,
ref order,
group.ServiceName,
BaseUrlFor(services, group.ServiceName),
group.PathPrefixes);
}
AddHostRoute(
routes,
ref order,
webGroup.ServiceName,
BaseUrlFor(services, webGroup.ServiceName),
"web-hosts",
BuildWebHosts(webGroup, apexDomain));
if (!string.IsNullOrWhiteSpace(services.Monolith))
{
routes.Add(Route(ServiceNames.Monolith, "monolith-fallback", "/{**catch-all}", order++));
}
return routes;
}
static IReadOnlyList<ClusterConfig> BuildClusters(DorkNetServiceMapOptions services)
{
var clusters = new List<ClusterConfig>();
foreach (var endpoint in services.Endpoints())
{
AddCluster(clusters, endpoint.Name, endpoint.BaseUrl);
}
return clusters;
}
static string[] WebBrowserHosts(string apexDomain)
{
return [apexDomain, SubdomainHost("www", apexDomain)];
}
static HashSet<string> BuildPublicHosts(string apexDomain)
{
var hosts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
apexDomain,
"localhost",
"127.0.0.1",
};
foreach (var subdomain in DorkNetRouteOwnership.PublicSubdomains)
{
hosts.Add(SubdomainHost(subdomain, apexDomain));
}
return hosts;
}
static bool IsAllowedPublicHost(string? host, HashSet<string> publicHosts)
{
if (string.IsNullOrWhiteSpace(host))
{
return true;
}
return publicHosts.Contains(host.Split(':', 2)[0].Trim());
}
static void AddHostRoute(
List<RouteConfig> routes,
ref int order,
string serviceName,
string baseUrl,
string routeId,
string[] hosts)
{
if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0)
{
return;
}
routes.Add(Route(
serviceName,
routeId,
"/{**catch-all}",
order++,
hosts));
}
static void AddPathRoutes(
List<RouteConfig> routes,
ref int order,
string serviceName,
string baseUrl,
IEnumerable<string> paths)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return;
}
foreach (var path in paths)
{
var routeId = $"{serviceName}-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}";
var exactPath = NormalizeRoutePrefix(path);
routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++));
routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++));
}
}
static void AddHostPathRoutes(
List<RouteConfig> routes,
ref int order,
string serviceName,
string baseUrl,
string[] hosts,
IEnumerable<string> paths)
{
if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0)
{
return;
}
foreach (var path in paths)
{
var routeId = $"{serviceName}-browser-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}";
var exactPath = NormalizeRoutePrefix(path);
routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++, hosts));
routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++, hosts));
}
}
static string BaseUrlFor(DorkNetServiceMapOptions services, string serviceName)
{
return serviceName switch
{
ServiceNames.Identity => services.Identity,
ServiceNames.Rooms => services.Rooms,
ServiceNames.Notify => services.Notify,
ServiceNames.Content => services.Content,
ServiceNames.Social => services.Social,
ServiceNames.Commerce => services.Commerce,
ServiceNames.Platform => services.Platform,
ServiceNames.Moderation => services.Moderation,
ServiceNames.Web => services.Web,
ServiceNames.Monolith => services.Monolith,
_ => string.Empty,
};
}
static string[] BuildWebHosts(DorkNetRouteGroup group, string apexDomain)
{
var hosts = new List<string>();
if (group.IncludesApexHost)
{
hosts.Add(apexDomain);
}
hosts.AddRange(group.HostSubdomains
.Where(subdomain => !string.Equals(subdomain, "admin", StringComparison.OrdinalIgnoreCase))
.Select(subdomain => SubdomainHost(subdomain, apexDomain)));
return hosts.ToArray();
}
static string SubdomainHost(string subdomain, string apexDomain)
{
return $"{subdomain}.{apexDomain}";
}
static string NormalizeRoutePrefix(string path)
{
if (string.IsNullOrWhiteSpace(path) || path == "/")
{
return "/";
}
return (path[0] == '/' ? path : "/" + path).TrimEnd('/');
}
static RouteConfig Route(
string clusterId,
string routeId,
string path,
int order,
string[]? hosts = null)
{
return new RouteConfig
{
RouteId = routeId,
ClusterId = clusterId,
Order = order,
Match = new RouteMatch
{
Hosts = hosts,
Path = path,
},
Transforms =
[
new Dictionary<string, string>
{
["RequestHeaderOriginalHost"] = "true",
},
],
};
}
static void AddCluster(
List<ClusterConfig> clusters,
string serviceName,
string baseUrl)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return;
}
var address = baseUrl.EndsWith('/') ? baseUrl : baseUrl + "/";
clusters.Add(new ClusterConfig
{
ClusterId = serviceName,
Destinations = new Dictionary<string, DestinationConfig>
{
["primary"] = new() { Address = address },
},
});
}
-23
View File
@@ -1,23 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"DorkNet": {
"Services": {
"Identity": "http://localhost:8081",
"Rooms": "http://localhost:8082",
"Notify": "http://localhost:8083",
"Content": "http://localhost:8084",
"Social": "http://localhost:8085",
"Commerce": "http://localhost:8086",
"Platform": "http://localhost:8087",
"Moderation": "http://localhost:8088",
"Web": "http://localhost:8089",
"Monolith": "http://localhost:8090"
}
}
}
-7
View File
@@ -81,13 +81,6 @@ public class RecNetAccount
// Likewise. // Likewise.
[JsonPropertyName("hasBirthday")] [JsonPropertyName("hasBirthday")]
public bool HasBirthday { get; set; } = true; public bool HasBirthday { get; set; } = true;
// 2020.12 Account.Deserialize reads this key (verified via Cpp2IL ISIL
// dump of CCEOLAOLEKJ.PPGFHEDFBEA — the obfuscated Account class). The
// 2020.03 client doesn't read it, but emitting it is harmless there.
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }
/// <summary> /// <summary>
@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -1,90 +0,0 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
namespace DorkNet.Server.Tests;
public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
{
private const string JwtSecret = "endpoint-contract-secret-000000000000000000000000000000000000000000";
private readonly string _dataRoot;
public DorkNetServerFactory()
{
_dataRoot = Path.Combine(Path.GetTempPath(), "dorknet-server-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dataRoot);
}
public string ApexDomain => "dork.test";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.UseSetting("Domain:Apex", ApexDomain);
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["AllowedHosts"] = "*",
["Database:Provider"] = "sqlite",
["Database:SqlitePath"] = Path.Combine(_dataRoot, "dorknet-test.db"),
["Domain:Apex"] = ApexDomain,
["Jwt:Secret"] = JwtSecret,
["S3:Endpoint"] = "",
["S3:AccessKey"] = "",
["S3:SecretKey"] = "",
["DorkNet:DefaultClientVersion"] = "december_2020_12_18",
["DorkNet:SupportedVersions:0"] = "december_2020_12_18",
});
});
builder.ConfigureServices(services =>
{
services.PostConfigure<HostFilteringOptions>(options =>
{
options.AllowedHosts =
[
ApexDomain,
$"*.{ApexDomain}",
"localhost",
"127.0.0.1",
];
options.AllowEmptyHosts = true;
options.IncludeFailureMessage = true;
});
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)),
ValidateIssuer = true,
ValidIssuer = "https://api.rec.net/",
ValidateAudience = false,
ClockSkew = TimeSpan.Zero,
};
});
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
try
{
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, recursive: true);
}
}
catch
{
// Test cleanup must not hide the original test failure.
}
}
}
@@ -1,222 +0,0 @@
using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
namespace DorkNet.Server.Tests;
public static partial class EndpointContractDiscovery
{
public static IReadOnlyList<EndpointContract> Discover()
{
var serverAssembly = typeof(Program).Assembly;
var controllerBaseType = typeof(ControllerBase);
return serverAssembly.GetTypes()
.Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t))
.SelectMany(DiscoverControllerRoutes)
.OrderBy(route => route.Method, StringComparer.Ordinal)
.ThenBy(route => route.SamplePath, StringComparer.Ordinal)
.ThenBy(route => route.Source, StringComparer.Ordinal)
.ToArray();
}
private static IEnumerable<EndpointContract> DiscoverControllerRoutes(Type controller)
{
var controllerName = ControllerName(controller);
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
.OfType<IRouteTemplateProvider>()
.Select(a => a.Template)
.Where(t => !string.IsNullOrWhiteSpace(t))
.Cast<string>()
.DefaultIfEmpty("")
.ToArray();
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
{
var httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType<HttpMethodAttribute>()
.ToArray();
if (httpAttrs.Length == 0)
{
continue;
}
foreach (var attr in httpAttrs)
{
var attrMethods = attr.HttpMethods.Any()
? attr.HttpMethods
: new[] { "ANY" };
foreach (var httpMethod in attrMethods)
{
foreach (var controllerTemplate in controllerRouteTemplates)
{
var template = CombineTemplates(controllerTemplate, attr.Template);
template = ReplaceRouteTokens(template, controllerName, method.Name);
yield return new EndpointContract(
httpMethod.ToUpperInvariant(),
template,
ToSamplePath(template),
$"{controller.FullName}.{method.Name}",
RequiresAuthorization(controller, method),
IsAdminEndpoint(controller, method),
ConsumesContentTypes(controller, method));
}
}
}
}
}
private static bool RequiresAuthorization(Type controller, MethodInfo method)
{
var attributes = controller.GetCustomAttributes(inherit: true)
.Concat(method.GetCustomAttributes(inherit: true))
.ToArray();
if (attributes.OfType<IAllowAnonymous>().Any())
{
return false;
}
return attributes.OfType<IAuthorizeData>().Any();
}
private static bool IsAdminEndpoint(Type controller, MethodInfo method)
{
return controller.FullName?.Contains(".Controllers.Admin.", StringComparison.Ordinal) == true
|| method.Name.Contains("Admin", StringComparison.OrdinalIgnoreCase);
}
private static string[] ConsumesContentTypes(Type controller, MethodInfo method)
{
var consumes = method.GetCustomAttributes(inherit: true)
.Concat(controller.GetCustomAttributes(inherit: true))
.OfType<ConsumesAttribute>()
.SelectMany(a => a.ContentTypes)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return consumes.Length == 0
? ["application/json"]
: consumes;
}
private static string ControllerName(Type controller)
{
const string suffix = "Controller";
return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
? controller.Name[..^suffix.Length]
: controller.Name;
}
private static string CombineTemplates(string? controllerTemplate, string? actionTemplate)
{
controllerTemplate ??= "";
actionTemplate ??= "";
if (actionTemplate.StartsWith("~/", StringComparison.Ordinal))
{
return "/" + actionTemplate[2..].TrimStart('/');
}
if (actionTemplate.StartsWith("/", StringComparison.Ordinal))
{
return "/" + actionTemplate.TrimStart('/');
}
var left = controllerTemplate.Trim('/');
var right = actionTemplate.Trim('/');
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
{
return "/";
}
if (string.IsNullOrEmpty(left))
{
return "/" + right;
}
if (string.IsNullOrEmpty(right))
{
return "/" + left;
}
return "/" + left + "/" + right;
}
private static string ReplaceRouteTokens(string template, string controllerName, string actionName)
{
return template
.Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
.Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
}
private static string ToSamplePath(string template)
{
var path = RouteParameterRegex().Replace(template, match =>
{
var name = match.Groups["name"].Value.ToLowerInvariant();
var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
if (name.Contains("playerid") || name.Contains("accountid") || name == "id")
{
return EndpointContract.TestPlayerToken;
}
if (constraint.Contains("long") || constraint.Contains("int") || name.EndsWith("id"))
{
return "1";
}
if (constraint.Contains("bool"))
{
return "true";
}
if (constraint.Contains("guid"))
{
return "00000000-0000-0000-0000-000000000001";
}
if (name.Contains("roomname"))
{
return "DormRoom";
}
if (name.Contains("locale"))
{
return "en";
}
return "sample";
});
path = Regex.Replace(path, "/+", "/");
return path.StartsWith('/') ? path : "/" + path;
}
[GeneratedRegex(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
private static partial Regex RouteParameterRegex();
}
public sealed record EndpointContract(
string Method,
string Template,
string SamplePath,
string Source,
bool RequiresAuthorization,
bool IsAdminEndpoint,
string[] Consumes)
{
public const string TestPlayerToken = "__test_player_id__";
public string PathFor(GameClientSession session)
{
return SamplePath.Replace(
TestPlayerToken,
session.PlayerId.ToString(),
StringComparison.Ordinal);
}
}
@@ -1,411 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace DorkNet.Server.Tests;
public sealed class GameClientEndpointContractTests :
IClassFixture<DorkNetServerFactory>
{
private readonly DorkNetServerFactory _factory;
public GameClientEndpointContractTests(DorkNetServerFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Game_client_contract_probe_reaches_every_controller_route()
{
var routes = EndpointContractDiscovery.Discover();
Assert.NotEmpty(routes);
using var client = _factory.CreateClient(new()
{
AllowAutoRedirect = false,
});
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
client.Timeout = TimeSpan.FromSeconds(20);
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
var failures = new List<string>();
foreach (var route in routes)
{
var result = await ProbeAsync(client, route, session, _factory.ApexDomain);
if (!result.Passed)
{
failures.Add(
$"{route.Method,-6} {result.Host}{result.Path} {route.Source} expected {result.Expected}, got {result.Observed}"
+ (string.IsNullOrWhiteSpace(result.ResponseExcerpt) ? "" : $" body={result.ResponseExcerpt}"));
}
}
Assert.True(
failures.Count == 0,
$"Endpoint contract failures ({failures.Count}/{routes.Count}):{Environment.NewLine}"
+ string.Join(Environment.NewLine, failures));
}
private static async Task<EndpointProbeResult> ProbeAsync(
HttpClient client,
EndpointContract route,
GameClientSession session,
string apexDomain)
{
var path = route.PathFor(session);
path = AddClientQuery(path, route, session);
var host = HostFor(path, apexDomain);
using var request = new HttpRequestMessage(
ToHttpMethod(route.Method),
new Uri($"http://{host}{path}"));
request.Headers.Accept.ParseAdd("application/json");
request.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
if (NeedsRequestBody(request.Method))
{
request.Content = CreateContent(route, session);
}
try
{
using var response = await client.SendAsync(request);
var responseText = await response.Content.ReadAsStringAsync();
var statusCode = (int)response.StatusCode;
var passed = ResponseMatchesContract(route, response.StatusCode);
return new EndpointProbeResult(
route.Method,
host,
path,
ExpectedContract(route),
statusCode,
null,
Excerpt(responseText),
passed);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return new EndpointProbeResult(
route.Method,
host,
path,
ExpectedContract(route),
null,
ex.Message,
null,
false);
}
}
private static bool ResponseMatchesContract(
EndpointContract route,
HttpStatusCode statusCode)
{
if ((int)statusCode >= 500)
{
return false;
}
if (statusCode is HttpStatusCode.MethodNotAllowed
or HttpStatusCode.NotImplemented
or HttpStatusCode.UpgradeRequired)
{
return false;
}
if (route.RequiresAuthorization
&& statusCode is HttpStatusCode.Unauthorized)
{
return false;
}
return true;
}
private static string ExpectedContract(EndpointContract route)
{
if (route.IsAdminEndpoint)
{
return "admin endpoint may reject non-admin test player, but must not 5xx/405/426";
}
if (route.RequiresAuthorization)
{
return "authenticated test player should reach the action without 401/5xx/405/426; 403 is valid for owner/admin-only resources";
}
return "game-client request should not 5xx/405/426";
}
private static HttpMethod ToHttpMethod(string method)
{
return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase)
? HttpMethod.Get
: new HttpMethod(method);
}
private static bool NeedsRequestBody(HttpMethod method)
{
return method == HttpMethod.Post
|| method == HttpMethod.Put
|| method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase);
}
private static HttpContent CreateContent(
EndpointContract route,
GameClientSession session)
{
var consumes = route.Consumes.Select(c => c.ToLowerInvariant()).ToArray();
if (consumes.Any(c => c.Contains("application/x-www-form-urlencoded")))
{
return new FormUrlEncodedContent(new Dictionary<string, string>
{
["account_id"] = session.PlayerId.ToString(),
["AccountId"] = session.PlayerId.ToString(),
["clubId"] = "1",
["ClubId"] = "1",
["description"] = "Endpoint contract description",
["deviceId"] = session.DeviceId,
["grant_type"] = "password",
["image"] = "endpoint-contract.png",
["imageName"] = "endpoint-contract.png",
["fileName"] = "endpoint-contract.png",
["message"] = "Endpoint contract message",
["name"] = "EndpointContract",
["newPassword"] = "endpoint-contract-password",
["oldPassword"] = "",
["password"] = "endpoint-contract-password",
["ping"] = "42",
["platformId"] = session.PlatformId,
["playerId"] = session.PlayerId.ToString(),
["playerIds"] = session.PlayerId.ToString(),
["query"] = "DormRoom",
["region"] = "us",
["targetPlayerId"] = session.PlayerId.ToString(),
["text"] = "Endpoint contract message",
["username"] = "EndpointContract",
["device_id"] = session.DeviceId,
["platform"] = "0",
["platform_id"] = session.PlatformId,
["Ids"] = session.PlayerId.ToString(),
["PlayerIds"] = session.PlayerId.ToString(),
});
}
if (consumes.Any(c => c.Contains("multipart/form-data")))
{
var content = new MultipartFormDataContent();
content.Add(new StringContent("endpoint-contract"), "name");
content.Add(new StringContent(session.PlayerId.ToString()), "playerId");
content.Add(new StringContent("endpoint-contract.png"), "imageName");
content.Add(new StringContent("endpoint-contract.png"), "fileName");
content.Add(new ByteArrayContent([]), "file", "endpoint-contract.bin");
return content;
}
if (consumes.Any(c => c.Contains("text/plain")))
{
return new StringContent("", Encoding.UTF8, "text/plain");
}
var json = JsonSerializer.Serialize(new
{
PlayerId = session.PlayerId,
AccountId = session.PlayerId,
ClubId = 1,
Description = "Endpoint contract description",
DeviceId = session.DeviceId,
FileName = "endpoint-contract.png",
Id = session.PlayerId,
Image = "endpoint-contract.png",
ImageName = "endpoint-contract.png",
Message = "Endpoint contract message",
Platform = 0,
PlatformId = session.PlatformId,
PlayerIds = new[] { session.PlayerId },
Name = "EndpointContract",
Password = "endpoint-contract-password",
Query = "DormRoom",
RoomName = "DormRoom",
TargetPlayerId = session.PlayerId,
Text = "Endpoint contract message",
});
return new StringContent(json, Encoding.UTF8, "application/json");
}
private static string AddClientQuery(
string path,
EndpointContract route,
GameClientSession session)
{
var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?";
var lower = path.ToLowerInvariant();
var query = new List<string>();
if (lower.Contains("search", StringComparison.Ordinal)
|| lower.Contains("query", StringComparison.Ordinal))
{
query.Add("query=EndpointContract");
}
if (lower == "/members/bulk")
{
query.Add("clubId=1");
query.Add($"playerIds={session.PlayerId}");
}
if (lower == "/player/photonregionpings")
{
query.Add("region=us");
query.Add("ping=42");
}
if (route.Source.Contains("CachedLogins", StringComparison.Ordinal)
|| lower.Contains("platformlogin/cached", StringComparison.Ordinal)
|| lower.Contains("savedlogins", StringComparison.Ordinal))
{
query.Add("platform=0");
query.Add($"platformId={Uri.EscapeDataString(session.PlatformId)}");
}
if (route.Source.Contains("Bulk", StringComparison.Ordinal)
&& !query.Any(q => q.StartsWith("Ids=", StringComparison.OrdinalIgnoreCase)))
{
query.Add($"Ids={session.PlayerId}");
}
if (query.Count == 0)
{
return path;
}
return path + separator + string.Join("&", query);
}
private static string HostFor(string path, string apexDomain)
{
var lower = path.ToLowerInvariant();
if (lower.StartsWith("/connect/")
|| lower.StartsWith("/.well-known/")
|| lower.StartsWith("/eac/")
|| lower.StartsWith("/cachedlogin/")
|| lower.StartsWith("/photon/")
|| lower.StartsWith("/api/platformlogin/")
|| lower.StartsWith("/api/version")
|| lower.StartsWith("/api/auth/"))
{
return $"auth.{apexDomain}";
}
if (lower.StartsWith("/account/"))
{
return $"accounts.{apexDomain}";
}
if (lower.StartsWith("/roomserver/")
|| lower.StartsWith("/rooms")
|| lower.StartsWith("/playlists")
|| lower.StartsWith("/featuredrooms")
|| lower.StartsWith("/hot_rooms")
|| lower.StartsWith("/search_rooms")
|| lower.StartsWith("/api/rooms")
|| lower.StartsWith("/api/roomkeys")
|| lower.StartsWith("/api/gamesessions")
|| lower.StartsWith("/api/curatedroomplaylists"))
{
return $"rooms.{apexDomain}";
}
if (lower.StartsWith("/v1/find")
|| lower.StartsWith("/v1/join")
|| lower.StartsWith("/v1/leave")
|| lower.StartsWith("/v1/session")
|| lower.StartsWith("/roominstance/")
|| lower.StartsWith("/goto/")
|| lower.StartsWith("/player/"))
{
return $"match.{apexDomain}";
}
if (lower.StartsWith("/thread"))
{
return $"chat.{apexDomain}";
}
if (lower.StartsWith("/club")
|| lower.StartsWith("/announcements/")
|| lower.StartsWith("/subscription/")
|| lower.StartsWith("/members/")
|| lower.StartsWith("/api/clubreporting/"))
{
return $"clubs.{apexDomain}";
}
if (lower.StartsWith("/api/storefronts/")
|| lower.StartsWith("/api/purchase/")
|| lower.StartsWith("/api/subscription/"))
{
return $"commerce.{apexDomain}";
}
if (lower.StartsWith("/hub/") || lower.StartsWith("/api/notification/"))
{
return $"notify.{apexDomain}";
}
if (lower.StartsWith("/v1/regions"))
{
return $"geo.{apexDomain}";
}
if (lower.StartsWith("/v1/services") || lower.StartsWith("/v1/ping"))
{
return $"ns.{apexDomain}";
}
if (lower.StartsWith("/upload"))
{
return $"storage.{apexDomain}";
}
if (lower.StartsWith("/img/") || lower.StartsWith("/room/") || lower.StartsWith("/data/"))
{
return $"cdn.{apexDomain}";
}
if (lower.StartsWith("/api/admin/"))
{
return $"admin.{apexDomain}";
}
return $"api.{apexDomain}";
}
private static string? Excerpt(string? body)
{
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
var compact = body.ReplaceLineEndings(" ").Trim();
return compact.Length <= 240 ? compact : compact[..240] + "...";
}
}
public sealed record EndpointProbeResult(
string Method,
string Host,
string Path,
string Expected,
int? StatusCode,
string? Error,
string? ResponseExcerpt,
bool Passed)
{
public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response";
}
-65
View File
@@ -1,65 +0,0 @@
using System.Net.Http.Headers;
using System.Text.Json;
namespace DorkNet.Server.Tests;
public sealed record GameClientSession(
long PlayerId,
string AccessToken,
string DeviceId,
string PlatformId);
public static class GameClientSessionFactory
{
public static async Task<GameClientSession> CreateAsync(
HttpClient client,
string apexDomain)
{
var deviceId = $"endpoint-contract-device-{Guid.NewGuid():N}";
var platformId = $"endpoint-contract-platform-{Guid.NewGuid():N}";
var tokenRequest = new HttpRequestMessage(
HttpMethod.Post,
new Uri($"http://auth.{apexDomain}/connect/token"));
tokenRequest.Headers.Accept.ParseAdd("application/json");
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = "EndpointContract",
["device_id"] = deviceId,
["platform"] = "0",
["platform_id"] = platformId,
});
using var tokenResponse = await client.SendAsync(tokenRequest);
if (!tokenResponse.IsSuccessStatusCode)
{
var body = await tokenResponse.Content.ReadAsStringAsync();
throw new InvalidOperationException(
$"Test player token request failed with {(int)tokenResponse.StatusCode} {tokenResponse.StatusCode}: {body}");
}
using var tokenJson = await JsonDocument.ParseAsync(
await tokenResponse.Content.ReadAsStreamAsync());
var accessToken = tokenJson.RootElement.GetProperty("access_token").GetString()
?? throw new InvalidOperationException("Token response did not include access_token.");
var userInfoRequest = new HttpRequestMessage(
HttpMethod.Get,
new Uri($"http://auth.{apexDomain}/connect/userinfo"));
userInfoRequest.Headers.Accept.ParseAdd("application/json");
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var userInfoResponse = await client.SendAsync(userInfoRequest);
userInfoResponse.EnsureSuccessStatusCode();
using var userInfoJson = await JsonDocument.ParseAsync(
await userInfoResponse.Content.ReadAsStreamAsync());
var playerId = long.Parse(userInfoJson.RootElement.GetProperty("sub").GetString()!);
return new GameClientSession(playerId, accessToken, deviceId, platformId);
}
}
@@ -1,56 +0,0 @@
using System.Text.Json;
using DorkNet.Server.Compat;
using DorkNet.Server.Controllers.Auth;
using DorkNet.Server.Versions.Late2020;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace DorkNet.Server.Tests;
public sealed class VersionCheckControllerTests
{
[Fact]
public void December_2020_build_is_valid_with_minimal_service_config()
{
var controller = CreateController();
var result = Assert.IsType<OkObjectResult>(controller.Check("20201210", "0"));
Assert.Equal(0, ReadVersionStatus(result.Value));
}
[Fact]
public void Unknown_build_still_requires_update()
{
var controller = CreateController();
var result = Assert.IsType<OkObjectResult>(controller.Check("unknown", "0"));
Assert.Equal(2, ReadVersionStatus(result.Value));
}
private static VersionCheckController CreateController()
{
var config = new ConfigurationBuilder().Build();
var registry = new VersionRegistry(
[new Late2020VersionPlugin()],
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"december_2020_12_18",
},
"december_2020_12_18");
return new VersionCheckController(
config,
registry,
NullLogger<VersionCheckController>.Instance);
}
private static int ReadVersionStatus(object? value)
{
var json = JsonSerializer.Serialize(value);
using var document = JsonDocument.Parse(json);
return document.RootElement.GetProperty("VersionStatus").GetInt32();
}
}
-28
View File
@@ -1,28 +0,0 @@
namespace DorkNet.Server.Compat;
/// <summary>Identifies a specific Rec Room client build.
/// <para>
/// <see cref="Key"/> is the wire-format identifier the client mod
/// stamps on every request via the <c>X-DorkNet-Version</c> header.
/// Format is <c>{branch}_{semver}</c>: <c>march_2020_03_10</c>,
/// <c>december_2020_12_18</c>. Keep it lowercase, underscore-separated;
/// it shows up in admin logs and error responses.
/// </para>
/// <para>
/// <see cref="Generation"/> is the wire-format-compatibility group:
/// every version inside the same generation shares the same wire
/// shapes (JSON DTO casing, route names, etc.) and therefore the same
/// strategy implementations. Adding a new build that's protocol-compatible
/// with an existing generation = a one-line registration; only a new
/// generation entails new strategy code.
/// </para>
/// </summary>
public sealed record ClientVersion(string Key, string Generation)
{
/// <summary>Sentinel returned when a request has no
/// <c>X-DorkNet-Version</c> header AND no
/// <c>DorkNet:DefaultClientVersion</c> is configured. Controllers
/// can branch on this to either fall through to a sensible default
/// or 426 the request.</summary>
public static readonly ClientVersion Unknown = new("unknown", "unknown");
}
@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Http;
namespace DorkNet.Server.Compat;
/// <summary>Helpers to read the active <see cref="ClientVersion"/>
/// off the current request. The version is set by
/// <c>VersionDetectionMiddleware</c> early in the pipeline so any
/// downstream consumer (controllers, services, filters) can resolve
/// it without re-parsing the header.</summary>
public static class HttpContextExtensions
{
private const string ClientVersionKey = "DorkNet.ClientVersion";
/// <summary>Stamp the resolved version on the request. Called by
/// <c>VersionDetectionMiddleware</c>; not for general use.</summary>
internal static void SetClientVersion(this HttpContext ctx, ClientVersion v)
=> ctx.Items[ClientVersionKey] = v;
/// <summary>The client version for this request. Returns
/// <see cref="ClientVersion.Unknown"/> if the middleware hasn't
/// run (e.g. a unit test that calls a controller method directly)
/// rather than null, so callers can safely branch on
/// <c>Generation</c> without null-checks.</summary>
public static ClientVersion ClientVersion(this HttpContext ctx)
=> ctx.Items.TryGetValue(ClientVersionKey, out var v)
&& v is ClientVersion cv
? cv
: Compat.ClientVersion.Unknown;
}
-39
View File
@@ -1,39 +0,0 @@
namespace DorkNet.Server.Compat;
/// <summary>One client-version-family plugin. Each implementation
/// declares which exact version <see cref="VersionKeys"/> it handles
/// and which protocol-compatibility <see cref="Generation"/> they map
/// to. The plugin's job at registration time is to also wire any
/// generation-specific strategy services into DI via
/// <see cref="RegisterStrategies"/> so controllers / services
/// downstream can resolve the right <c>IWireFormat</c>,
/// <c>IRouteOverride</c>, etc. for the active request's generation.
///
/// <para>Adding a new version with the SAME wire shapes as an existing
/// generation = just listing its key in that generation's plugin
/// <see cref="VersionKeys"/>. Only a new generation needs new
/// strategy classes.</para>
/// </summary>
public interface IVersionPlugin
{
/// <summary>All client-build keys this plugin handles. Multiple
/// keys can share one plugin when several Rec Room builds shipped
/// the same wire format. Convention: lowercase, underscore-separated,
/// matching the literal the client mod sends in
/// <c>X-DorkNet-Version</c>.</summary>
IReadOnlyCollection<string> VersionKeys { get; }
/// <summary>Wire-format-compatibility group this plugin implements.
/// Multiple plugins can share a generation (rare, but allowed when
/// e.g. a Quest build and a PC build of the same era are
/// protocol-identical and we just want separate
/// <see cref="VersionKeys"/> bookkeeping).</summary>
string Generation { get; }
/// <summary>Register any generation-specific strategy services
/// into DI. Called once at startup by the bootstrap code in
/// <c>Program.cs</c>. Use keyed registrations (keyed by
/// <see cref="Generation"/>) so requests for other generations
/// don't collide.</summary>
void RegisterStrategies(IServiceCollection services);
}
@@ -1,144 +0,0 @@
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using DorkNet.Server.Services;
namespace DorkNet.Server.Compat;
/// <summary>Extracts the client version from <c>X-DorkNet-Version</c>
/// on every request, validates it against this deployment's allow-list,
/// and either:
/// <list type="bullet">
/// <item>Stamps <see cref="HttpContextExtensions.ClientVersion"/> with
/// the resolved <see cref="ClientVersion"/> and forwards the request, OR</item>
/// <item>Returns <c>HTTP 426 Upgrade Required</c> with a JSON body
/// describing the mismatch when the client's version isn't in
/// <see cref="VersionRegistry.SupportedVersionKeys"/>.</item>
/// </list>
///
/// <para>Headerless requests fall back to
/// <see cref="VersionRegistry.DefaultVersionKey"/> when set. That's
/// the transition pathway — once every shipped client mod stamps the
/// header, set <c>DorkNet:DefaultClientVersion</c> to empty and
/// headerless requests get rejected too.</para>
///
/// <para>Some routes intentionally don't carry a client identity
/// (the public-facing site at the apex, the admin SPA at
/// <c>admin.{apex}</c>, health checks). Those are excluded by host
/// + path prefix below — they always skip the version gate and
/// receive <see cref="ClientVersion.Unknown"/>. The apex is whatever
/// <see cref="DomainConfig.Apex"/> resolved to at startup, so forks
/// running on a different domain get the same exemption automatically.</para>
/// </summary>
public sealed class VersionDetectionMiddleware(
RequestDelegate next,
VersionRegistry registry,
DomainConfig domain,
ILogger<VersionDetectionMiddleware> logger)
{
private const string HeaderName = "X-DorkNet-Version";
public async Task InvokeAsync(HttpContext ctx)
{
if (IsExempt(ctx))
{
ctx.SetClientVersion(ClientVersion.Unknown);
await next(ctx);
return;
}
var headerVal = ctx.Request.Headers[HeaderName].ToString();
var versionKey = string.IsNullOrWhiteSpace(headerVal)
? registry.DefaultVersionKey
: headerVal.Trim();
// No header AND no default configured — reject. Once every
// client mod sends the header this is the path that catches
// probes / misconfigured clients.
if (string.IsNullOrWhiteSpace(versionKey))
{
await WriteMismatch(ctx, "client_version_missing", got: null);
return;
}
var resolved = registry.Resolve(versionKey);
if (resolved == ClientVersion.Unknown)
{
// The client claims a version but no plugin knows it. That
// means either a typo in the header or a build of Rec Room
// we haven't onboarded yet. Either way, reject — server
// can't reliably serve a version it doesn't have a
// strategy bundle for.
await WriteMismatch(ctx, "client_version_unknown", got: versionKey);
return;
}
if (!registry.IsSupported(versionKey))
{
// Known version, but this specific deployment is
// configured to accept a different set. e.g. a March
// client hitting a December-only server.
await WriteMismatch(ctx, "client_version_not_accepted", got: versionKey);
return;
}
ctx.SetClientVersion(resolved);
await next(ctx);
}
private bool IsExempt(HttpContext ctx)
{
// Hosts that don't serve the watch — these are humans on a
// browser or admin tooling, not the game client.
var host = ctx.Request.Host.Host.ToLowerInvariant();
var apex = domain.Apex.ToLowerInvariant();
if (host == apex || host == "www." + apex) return true;
if (host.StartsWith("admin.")) return true;
if (host.StartsWith("site.")) return true;
if (host.StartsWith("feed.")) return true;
// Server health / readiness probes.
var path = ctx.Request.Path.Value;
if (path is null) return false;
if (path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)) return true;
if (path.StartsWith("/api/admin/", StringComparison.OrdinalIgnoreCase)) return true;
if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase)) return true;
// The boot-time version gate itself — the whole POINT of this
// endpoint is to receive a mismatched-version client and reply
// with the watch's native "Update Required" payload. Gating it
// here would 426 the request before VersionCheckController
// could respond.
if (path.StartsWith("/api/versioncheck/", StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
private async Task WriteMismatch(HttpContext ctx, string error, string? got)
{
logger.LogWarning(
"[version-gate] rejecting request path={Path} host={Host} reason={Reason} got={Got} supported=[{Supported}]",
ctx.Request.Path, ctx.Request.Host.Host, error, got ?? "(none)",
string.Join(",", registry.SupportedVersionKeys));
ctx.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
ctx.Response.ContentType = "application/json; charset=utf-8";
var body = new
{
error,
got,
supported = registry.SupportedVersionKeys.ToArray(),
message = error switch
{
"client_version_missing" =>
"Client did not send an X-DorkNet-Version header. Update your DorkNet client mod.",
"client_version_unknown" =>
$"Client claims version '{got}', which no version plugin in this server recognises.",
"client_version_not_accepted" =>
$"Client version '{got}' is known but this deployment is configured to accept only: " +
string.Join(", ", registry.SupportedVersionKeys) + ".",
_ => error,
},
};
await JsonSerializer.SerializeAsync(ctx.Response.Body, body);
}
}
-69
View File
@@ -1,69 +0,0 @@
namespace DorkNet.Server.Compat;
/// <summary>Resolves a client-build key (e.g. <c>december_2020_12_18</c>)
/// to its <see cref="ClientVersion"/>. Built once at startup from all
/// registered <see cref="IVersionPlugin"/> instances.
///
/// <para>Singleton — the version-key → generation mapping is fixed at
/// boot time, and the registry is read on every request by the
/// version-detection middleware.</para>
/// </summary>
public sealed class VersionRegistry
{
private readonly Dictionary<string, ClientVersion> _byKey;
/// <summary>The set of version keys this deployment is willing to
/// serve. Comes from <c>DorkNet:SupportedVersions</c> in config.
/// Anything else gets a 426 from the middleware.</summary>
public IReadOnlySet<string> SupportedVersionKeys { get; }
/// <summary>Falls back to this when a request arrives without an
/// <c>X-DorkNet-Version</c> header — set via
/// <c>DorkNet:DefaultClientVersion</c> in config. Empty/null means
/// "no fallback; reject headerless requests". Set during the
/// transition window where the old client mod doesn't yet stamp
/// the header.</summary>
public string? DefaultVersionKey { get; }
public VersionRegistry(
IEnumerable<IVersionPlugin> plugins,
IReadOnlySet<string> supportedVersionKeys,
string? defaultVersionKey)
{
_byKey = new Dictionary<string, ClientVersion>(StringComparer.OrdinalIgnoreCase);
foreach (var plugin in plugins)
{
foreach (var key in plugin.VersionKeys)
{
if (_byKey.ContainsKey(key))
throw new InvalidOperationException(
$"VersionPlugin collision: '{key}' is claimed by more than one IVersionPlugin. " +
$"Each version key maps to exactly one plugin.");
_byKey[key] = new ClientVersion(key, plugin.Generation);
}
}
SupportedVersionKeys = supportedVersionKeys;
DefaultVersionKey = defaultVersionKey;
}
/// <summary>Resolve a version key (case-insensitive) to its
/// <see cref="ClientVersion"/>, or <see cref="ClientVersion.Unknown"/>
/// if no plugin claims it. The middleware decides whether unknown
/// → reject or → fall back to <see cref="DefaultVersionKey"/>.</summary>
public ClientVersion Resolve(string? key)
{
if (string.IsNullOrWhiteSpace(key)) return ClientVersion.Unknown;
return _byKey.TryGetValue(key, out var v) ? v : ClientVersion.Unknown;
}
/// <summary>True when the given version key is in
/// <see cref="SupportedVersionKeys"/>. Used by the middleware to
/// 426 cross-version connection attempts (e.g. a March client
/// hitting a server configured for December only).</summary>
public bool IsSupported(string key)
=> SupportedVersionKeys.Contains(key);
/// <summary>Every known plugin-registered version key, for
/// diagnostic responses and admin tooling.</summary>
public IEnumerable<ClientVersion> AllKnown => _byKey.Values;
}
@@ -128,8 +128,6 @@ public class AccountApiController(
var name = (displayName ?? value ?? string.Empty).Trim(); var name = (displayName ?? value ?? string.Empty).Trim();
if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" }); if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
await playerService.UpdateDisplayNameAsync(p.Id, name); await playerService.UpdateDisplayNameAsync(p.Id, name);
var updated = await playerService.GetByIdAsync(p.Id);
if (updated is not null) await notifications.AccountChanged(updated);
return Ok(new RecNetResult { Success = true, Error = string.Empty }); return Ok(new RecNetResult { Success = true, Error = string.Empty });
} }
@@ -155,7 +153,6 @@ public class AccountApiController(
if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" }); if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" });
p.Username = newName; p.Username = newName;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.AccountChanged(p);
return Ok(new RecNetResult { Success = true, Error = string.Empty }); return Ok(new RecNetResult { Success = true, Error = string.Empty });
} }
@@ -201,12 +198,12 @@ public class AccountApiController(
[HttpPost("api/account/v1/profileimage")] [HttpPost("api/account/v1/profileimage")]
[HttpPost("api/account/me/profileimage")] [HttpPost("api/account/me/profileimage")]
[HttpPost("account/me/profileimage", Order = 10)] [HttpPost("account/me/profileimage")]
[HttpPost("profileimage", Order = 10)] [HttpPost("profileimage")]
[HttpPut("api/account/v1/profileimage")] [HttpPut("api/account/v1/profileimage")]
[HttpPut("api/account/me/profileimage")] [HttpPut("api/account/me/profileimage")]
[HttpPut("account/me/profileimage", Order = 10)] [HttpPut("account/me/profileimage")]
[HttpPut("profileimage", Order = 10)] [HttpPut("profileimage")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetProfileImage( public async Task<IActionResult> SetProfileImage(
[FromForm(Name = "imageName")] string? imageName, [FromForm(Name = "imageName")] string? imageName,
@@ -225,10 +222,44 @@ public class AccountApiController(
"[api-account] profile image set: player={PlayerId} -> {Name}", "[api-account] profile image set: player={PlayerId} -> {Name}",
player.Id, player.Id,
player.ProfileImageName ?? "<cleared>"); player.ProfileImageName ?? "<cleared>");
await notifications.AccountChanged(player); await NotifyAccountImageChangedAsync(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty }); 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/namegen")]
[HttpGet("api/account/v1/namegeneration")] [HttpGet("api/account/v1/namegeneration")]
public IActionResult NameGen() => Ok(new public IActionResult NameGen() => Ok(new
@@ -198,32 +198,6 @@ public class AvatarGiftsController(
} }
} }
if (!string.IsNullOrWhiteSpace(gift.EquipmentPrefabName) ||
!string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid))
{
var itemSlug = !string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid)
? StoreService.EquipmentSkinSlug(gift.EquipmentPrefabName, gift.EquipmentModificationGuid)
: gift.EquipmentPrefabName;
if (!string.IsNullOrWhiteSpace(itemSlug))
{
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == itemSlug);
if (row is null)
{
db.PlayerInventory.Add(new PlayerInventoryEntity
{
PlayerId = pid,
ItemSlug = itemSlug,
Quantity = 1,
});
}
else
{
row.Quantity = Math.Max(row.Quantity, 1);
}
}
}
// Apply currency / XP / level rewards bundled with the gift. // Apply currency / XP / level rewards bundled with the gift.
// The previous implementation only handled the avatar item path, // The previous implementation only handled the avatar item path,
// so token gifts from the admin SPA marked themselves consumed // so token gifts from the admin SPA marked themselves consumed
@@ -278,17 +252,7 @@ public class AvatarGiftsController(
_ => 50, // Legendary _ => 50, // Legendary
}; };
// Wire shape verified against the 2020.12 watch's private static object ToWire(GiftPackageEntity g) => new
// NLEKGNENMCO+LOCNECLOHCA gift DTO deserializer (Cpp2IL ISIL
// NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946). All keys read
// via Util.GetKey<T> (mandatory): Id, Message, FromPlayerId,
// AvatarItemType, AvatarItemDesc, ConsumableItemDesc,
// EquipmentPrefabName, EquipmentModificationGuid, Platform,
// PlatformsToSpawnOn, BalanceType, CurrencyType, Currency, Xp,
// GiftRarity, GiftContext. Missing any one → KeyNotFoundException
// surfaces as "Failed to download gifts: Malformed Response" from
// BootSequence and BLOCKS LOGIN.
public static object ToWire(GiftPackageEntity g) => new
{ {
g.Id, g.Id,
g.FromPlayerId, g.FromPlayerId,
@@ -306,23 +270,6 @@ public class AvatarGiftsController(
g.GiftRarity, g.GiftRarity,
g.Message, g.Message,
g.Platform, g.Platform,
// PlatformsToSpawnOn — required by the watch's gift DTO
// deserializer. This is a SINGLE int (4-byte field at offset
// 0x6C in LOCNECLOHCA, separate enum from Platform itself).
// The watch reads it with strict Util.GetKey<int>, so sending
// an empty array crashes Convert.ChangeType([], typeof(int))
// with "Object must implement IConvertible". -1 mirrors the
// sibling Platform field's "every platform" sentinel — no
// platform-gated gift restrictions on a private server.
PlatformsToSpawnOn = -1,
// BalanceType — required mandatory key on the gift DTO. The
// enum identifies whether the gift's payload is a Currency
// balance update (0) vs Inventory item (1) vs XP (2). For
// currency-bearing gifts we'd send 0; the safe default for
// our seed gifts (which are item-only) is 0 too since the
// watch only reads it to dispatch the balance-update side
// and we never carry one. Wrong type doesn't crash.
BalanceType = 0,
g.PackageMaterial, g.PackageMaterial,
g.PackageVariant, g.PackageVariant,
g.Consumed, g.Consumed,
@@ -85,16 +85,8 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
if (!config.GetValue("Avatar:EnableUnlockedItems", true)) if (!config.GetValue("Avatar:EnableUnlockedItems", true))
return Ok(new List<UnlockedAvatarItemDto>()); return Ok(new List<UnlockedAvatarItemDto>());
// Defaults raised (30 → 100, 4 → 20) — the old 4-per-slot silently var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120);
// dropped any newly-purchased item once a slot held 4+ entries (the var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20);
// "I bought it but it's gone after relog" symptom). The clamp
// ranges stay at [1,120] / [1,20] — those reflect empirical watch
// testing on render-budget headroom (per the prior maintainer note
// about BrowsableAvatarItem / DynamicAvatarItemImposter thrash
// above this count); raising the clamp ceiling without retesting
// could crash the watch's wardrobe drawer.
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 100), 1, 120);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 20), 1, 20);
var pid = this.CurrentPlayerId(); var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>()); if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
@@ -104,7 +96,7 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
.Select(a => new { a.InventoryJson, a.OutfitSelections }) .Select(a => new { a.InventoryJson, a.OutfitSelections })
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
var owned = ParseInventoryEntries(avatar?.InventoryJson); var owned = ParseInventory(avatar?.InventoryJson);
var ownedHairDyeSlugs = await db.PlayerInventory var ownedHairDyeSlugs = await db.PlayerInventory
.Where(i => i.PlayerId == me && i.Quantity > 0) .Where(i => i.PlayerId == me && i.Quantity > 0)
.Select(i => i.ItemSlug) .Select(i => i.ItemSlug)
@@ -117,54 +109,28 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
var catalog = _catalog.Value; var catalog = _catalog.Value;
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections); var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
// Recency tiebreaker: GrantWardrobeAsync / gift-consume APPEND new
// GUIDs to the end of InventoryJson, so the original index is also
// the purchase order. When per-slot truncation has to drop entries,
// we want it to drop the OLDEST (starter seed) items rather than the
// one the user just bought.
var ordered = owned var ordered = owned
.Select((entry, idx) => (Entry: entry, OriginalIndex: idx)) .OrderByDescending(guid => equipped.Contains(guid))
.OrderByDescending(x => equipped.Contains(x.Entry.BaseGuid)) .ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue)
.ThenBy(x => catalog.SlotByGuid.TryGetValue(x.Entry.BaseGuid, out var slot) ? slot : int.MaxValue) .ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase);
.ThenByDescending(x => x.OriginalIndex)
.Select(x => x.Entry);
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count); var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
var perSlot = new Dictionary<int, int>(); var perSlot = new Dictionary<int, int>();
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems)); var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
foreach (var entry in ordered) foreach (var guid in ordered)
{ {
if (result.Count >= maxOutfitItems) break; if (result.Count >= maxOutfitItems) break;
// Intersect against the safelist. Items not in the bundled // Intersect against the safelist. Items not in the bundled
// lookup would crash the watch's wardrobe parser, so they're // lookup would crash the watch's wardrobe parser, so they're
// silently dropped here. The catalog is keyed by the BARE GUID, // silently dropped here.
// so colored variants (stored as "{guid},{swatch},{mask},") must if (!catalog.Items.TryGetValue(guid, out var item)) continue;
// look up by BaseGuid — keying by the full comma string would
// miss and silently drop every owned colored item (the contest
// hat "I bought it but it never shows in my wardrobe" bug).
if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue;
var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1; var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1;
perSlot.TryGetValue(slot, out var count); perSlot.TryGetValue(slot, out var count);
if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue; if (count >= maxPerSlot && !equipped.Contains(guid)) continue;
perSlot[slot] = count + 1; perSlot[slot] = count + 1;
// Preserve the bought color: a bare entry (FullDesc == BaseGuid) result.Add(item);
// emits the catalog DTO as-is ("{guid},,,"); a colored entry
// overrides AvatarItemDesc with the stored swatch/mask desc so
// the watch renders the variant the player paid for instead of
// falling back to the default swatch.
result.Add(string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase)
? item
: new UnlockedAvatarItemDto
{
AvatarItemType = item.AvatarItemType,
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
PlatformMask = item.PlatformMask,
FriendlyName = item.FriendlyName,
Tooltip = item.Tooltip,
Rarity = item.Rarity,
});
if (result.Count >= maxItems) break; if (result.Count >= maxItems) break;
} }
@@ -200,49 +166,6 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog
=> _catalog.Value.Items; => _catalog.Value.Items;
/// <summary>GET <c>api/avatar/v1/lockeditems</c> — metadata for
/// specific avatar item descriptions the caller does not own. The
/// store UI calls this with item descs from purchasable rows and
/// expects the same item-detail shape used by unlocked items,
/// minus ownership. We resolve against the safe runtime catalog so
/// unknown GUIDs never reach the client parser.</summary>
[HttpGet("api/avatar/v1/lockeditems")]
[AllowAnonymous]
public async Task<IActionResult> LockedItems()
{
var requested = RequestedAvatarItemGuids().ToList();
if (requested.Count == 0) return Ok(Array.Empty<object>());
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pid = this.CurrentPlayerId();
if (pid is long me)
{
var invJson = await db.Avatars
.Where(a => a.PlayerId == me)
.Select(a => a.InventoryJson)
.FirstOrDefaultAsync();
foreach (var guid in ParseInventory(invJson))
owned.Add(guid);
}
var catalog = _catalog.Value.Items;
var rows = requested
.Where(guid => !owned.Contains(guid))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
.Where(item => item is not null)
.Select(item => new
{
item!.AvatarItemType,
item.AvatarItemDesc,
item.FriendlyName,
item.Tooltip,
item.Rarity,
})
.ToArray();
return Ok(rows);
}
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot. /// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
/// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to /// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to
/// build a starter loadout (one per slot) and a starter inventory /// build a starter loadout (one per slot) and a starter inventory
@@ -253,103 +176,47 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
public static bool TryGetOutfitType(string guid, out int outfitType) => public static bool TryGetOutfitType(string guid, out int outfitType) =>
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType); _catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
private IEnumerable<string> RequestedAvatarItemGuids() private static List<string> ParseInventory(string? json)
{ {
foreach (var pair in Request.Query) if (string.IsNullOrWhiteSpace(json)) return new();
foreach (var raw in pair.Value)
foreach (var part in (raw ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var value = part.Trim();
if (string.IsNullOrWhiteSpace(value)) continue;
var comma = value.IndexOf(',');
if (comma >= 0) value = value[..comma];
if (Guid.TryParse(value, out _)) yield return value;
}
}
/// <summary>An owned wardrobe entry: the bare AvatarItem GUID used as
/// the catalog / slot / equipped key, plus the full RecNet desc exactly
/// as stored in InventoryJson. For a plain item the two are identical;
/// for a colored variant the desc is "{guid},{swatch},{mask}," so the
/// swatch the player paid for survives the round-trip to the wardrobe.</summary>
private readonly record struct OwnedEntry(string BaseGuid, string FullDesc);
/// <summary>Parse InventoryJson into normalized owned entries. Accepts
/// both the plain JSON string array of descs (the shape
/// GrantWardrobeAsync / PurchaseAsync write) and the legacy
/// [{itemId,...}] object shape. Strips a leading "wardrobe-" slug
/// prefix, derives the bare GUID (substring before the first comma) for
/// catalog lookups, and keeps the full desc so a colored variant keeps
/// its swatch. Dedupes by full desc, so multiple colors of the same
/// base item remain distinct wardrobe tiles while exact duplicates
/// collapse.</summary>
private static List<OwnedEntry> ParseInventoryEntries(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
var raw = new List<string>();
try try
{ {
var strings = JsonSerializer.Deserialize<List<string>>(json); var strings = JsonSerializer.Deserialize<List<string>>(json);
if (strings is not null) raw.AddRange(strings); if (strings is not null) return strings;
} }
catch { } catch { }
if (raw.Count == 0) try
{ {
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())
{ {
using var doc = JsonDocument.Parse(json); string? value = null;
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new(); if (element.ValueKind == JsonValueKind.String)
foreach (var element in doc.RootElement.EnumerateArray()) value = element.GetString();
{ else if (element.ValueKind == JsonValueKind.Object &&
if (element.ValueKind == JsonValueKind.String) element.TryGetProperty("itemId", out var itemId) &&
raw.Add(element.GetString() ?? string.Empty); itemId.ValueKind == JsonValueKind.String)
else if (element.ValueKind == JsonValueKind.Object && value = itemId.GetString();
element.TryGetProperty("itemId", out var itemId) &&
itemId.ValueKind == JsonValueKind.String) if (string.IsNullOrWhiteSpace(value)) continue;
raw.Add(itemId.GetString() ?? string.Empty); 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);
} }
catch { return new(); }
return result
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
} }
catch { return new(); }
const string wardrobePrefix = "wardrobe-";
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<OwnedEntry>();
foreach (var entry in raw)
{
var value = entry;
if (string.IsNullOrWhiteSpace(value)) continue;
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
value = value[wardrobePrefix.Length..];
var comma = value.IndexOf(',');
var baseGuid = comma >= 0 ? value[..comma] : value;
if (string.IsNullOrWhiteSpace(baseGuid)) continue;
if (!seen.Add(value)) continue;
result.Add(new OwnedEntry(baseGuid, value));
}
return result;
}
/// <summary>Bare-GUID view of the owned set, for callers that only need
/// ownership membership (e.g. the locked-items diff), not the swatch.</summary>
private static List<string> ParseInventory(string? json) =>
ParseInventoryEntries(json)
.Select(e => e.BaseGuid)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
/// <summary>Ensure a stored desc carries the ≥4 comma-separated parts
/// (guid + 3 customizations) that the watch's
/// <c>AvatarItem.FromRecNetString</c> requires — anything shorter throws
/// inside the wardrobe parser. Stored colored descs are already
/// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy
/// value.</summary>
private static string ToWatchDesc(string storedDesc)
{
var commas = storedDesc.Count(c => c == ',');
return commas >= 3 ? storedDesc : storedDesc + new string(',', 3 - commas);
} }
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections) private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
@@ -415,13 +282,10 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
{ {
if (string.IsNullOrEmpty(row.Guid)) continue; if (string.IsNullOrEmpty(row.Guid)) continue;
safe.Add(row.Guid); safe.Add(row.Guid);
// Prefer the baked display_name from the JSON if present // Rec Room's bundled item names are wrapped in parens like
// (tools/add-display-names.py writes it once per refresh). // "(Hat_Wizard)" — strip those and the underscores for a
// Falls back to the raw-name cleanup if the field is missing // friendlier UI label.
// — e.g. partial dumps or older fixture files. var pretty = PrettyName(row.Name);
var pretty = !string.IsNullOrEmpty(row.DisplayName)
? row.DisplayName
: PrettyName(row.Name);
items[row.Guid] = new UnlockedAvatarItemDto items[row.Guid] = new UnlockedAvatarItemDto
{ {
// AvatarItemType is the wire enum the watch parses // AvatarItemType is the wire enum the watch parses
@@ -485,22 +349,11 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
{ {
[JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty; [JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty;
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
// Algorithmically derived from `name` by
// tools/add-display-names.py — Rec Room's official UI strings
// live in Unity asset bundles we can't read from a code dump,
// so this is a best-effort cleanup. Optional — server falls
// back to PrettyName() if missing.
[JsonPropertyName("display_name")] public string? DisplayName { get; set; }
[JsonPropertyName("outfit_type")] public int OutfitType { get; set; } [JsonPropertyName("outfit_type")] public int OutfitType { get; set; }
[JsonPropertyName("outfit_set")] public int OutfitSet { get; set; } [JsonPropertyName("outfit_set")] public int OutfitSet { get; set; }
[JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; } [JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; }
[JsonPropertyName("hides_hair")] public bool HidesHair { get; set; } [JsonPropertyName("hides_hair")] public bool HidesHair { get; set; }
[JsonPropertyName("hides_hand")] public bool HidesHand { get; set; } [JsonPropertyName("hides_hand")] public bool HidesHand { get; set; }
// hides_left_ear / hides_right_ear were added to AvatarItemData
// in the 2020.12.18 client build. Optional in the JSON so older
// dumps still deserialize.
[JsonPropertyName("hides_left_ear")] public bool HidesLeftEar { get; set; }
[JsonPropertyName("hides_right_ear")] public bool HidesRightEar { get; set; }
[JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; } [JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; }
[JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; } [JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; }
[JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; } [JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; }
@@ -67,19 +67,9 @@ public class CommunityBoardController(
.Where(r => ids.Contains(r.Id)) .Where(r => ids.Contains(r.Id))
.Select(r => new { r.Id, r.Name, r.ImageName }) .Select(r => new { r.Id, r.Name, r.ImageName })
.ToDictionaryAsync(r => r.Id); .ToDictionaryAsync(r => r.Id);
// 2020.12 FeaturedRoomGroup deserializer (NMPFCIJPODA.PPGFHEDFBEA)
// reads required keys: FeaturedRoomGroupId, Name, Rooms (NOT
// FeaturedRooms). Missing/renamed → KeyNotFoundException →
// breaks the boot promise chain on the community-board fetch.
featuredRoomGroup = new featuredRoomGroup = new
{ {
FeaturedRoomGroupId = 0L,
Name = group.Name ?? string.Empty, Name = group.Name ?? string.Empty,
Rooms = 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(),
// Keep the old key around for any 2020.03 clients hitting prod.
FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r) FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r)
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty } ? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty }) : new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
@@ -1,157 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Compatibility;
[ApiController]
public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBase
{
[HttpPost("api/banappeal/generateCode")]
[Authorize]
public async Task<IActionResult> GenerateBanAppealCode()
{
var pid = this.RequireCurrentPlayerId();
var code = $"BA-{RandomNumberGenerator.GetInt32(100000, 999999)}";
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid,
Key = $"banappeal:{DateTime.UtcNow:yyyyMMddHHmmss}",
Value = code,
});
await db.SaveChangesAsync();
return Ok(new { value = code });
}
[HttpPost("api/CampusCard/v1/UpdateAndGetSubscription")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> UpdateAndGetCampusCardSubscription()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadCampusCardRequestAsync();
var targetId = req.PlatformAccountSubscribedPlayerId > 0
? req.PlatformAccountSubscribedPlayerId
: pid;
if (targetId != pid && req.Subscription)
{
var exists = await db.Subscriptions.AnyAsync(s =>
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
if (!exists)
{
db.Subscriptions.Add(new SubscriptionEntity
{
SubscriberPlayerId = pid,
TargetPlayerId = targetId,
});
await db.SaveChangesAsync();
}
}
else if (targetId != pid && !req.Subscription)
{
await db.Subscriptions
.Where(s => s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId)
.ExecuteDeleteAsync();
}
// 2020.12 deserializer for this response expects `subscription` to be a
// nested OBJECT (Subscription DTO with subscriptionId/recNetPlayerId/...
// fields), NOT a boolean. Sending a bool throws InvalidCastException
// on the client ("Unable to cast object of type 'Boolean' to type
// 'Dictionary`2'"). Send null when there's no active subscription —
// the client's generic-cast helper returns default(T) which is fine.
return Ok(new
{
subscription = (object?)null,
platformAccountSubscribedPlayerId = targetId,
});
}
[HttpPost("api/clubreporting/v1/report")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ClubReport()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadClubReportAsync();
var club = req.ClubId > 0
? await db.Clubs.FirstOrDefaultAsync(c => c.Id == req.ClubId)
: null;
if (club is null) return Ok(new { Success = false, Message = "club_not_found" });
var message = $"Club report: {req.Message ?? string.Empty}".Trim();
if (message.Length > 1000) message = message[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = pid,
TargetPlayerId = club.CreatorPlayerId,
Category = req.ReportCategory,
Message = message,
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty });
}
private async Task<CampusCardRequest> ReadCampusCardRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new CampusCardRequest
{
Subscription = bool.TryParse(form["subscription"].FirstOrDefault(), out var s) && s,
PlatformAccountSubscribedPlayerId = long.TryParse(
form["platformAccountSubscribedPlayerId"].FirstOrDefault(), out var id) ? id : 0,
};
}
try
{
return await JsonSerializer.DeserializeAsync<CampusCardRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new CampusCardRequest();
}
catch (JsonException) { return new CampusCardRequest(); }
}
private async Task<ClubReportRequest> ReadClubReportAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new ClubReportRequest
{
ClubId = long.TryParse(form["clubId"].FirstOrDefault()
?? form["ClubId"].FirstOrDefault(), out var clubId) ? clubId : 0,
ReportCategory = int.TryParse(form["reportCategory"].FirstOrDefault()
?? form["ReportCategory"].FirstOrDefault(), out var cat) ? cat : 5,
Message = form["message"].FirstOrDefault() ?? form["Message"].FirstOrDefault(),
};
}
try
{
return await JsonSerializer.DeserializeAsync<ClubReportRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new ClubReportRequest();
}
catch (JsonException) { return new ClubReportRequest(); }
}
private sealed class CampusCardRequest
{
public bool Subscription { get; set; }
public long PlatformAccountSubscribedPlayerId { get; set; }
}
private sealed class ClubReportRequest
{
public long ClubId { get; set; }
public int ReportCategory { get; set; } = 5;
public string? Message { get; set; }
}
}
@@ -21,13 +21,6 @@ public class ConfigController(ConfigService configService) : ControllerBase
[HttpGet("amplitude")] [HttpGet("amplitude")]
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty }); public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
/// 2020.12 client deserializes this endpoint as a raw Boolean.
/// This server does not run the timed free-gift campaign, so the
/// button is hidden.</summary>
[HttpGet("freegiftbutton")]
public IActionResult FreeGiftButton() => Content("false", "application/json");
/// <summary> /// <summary>
/// `Config.GetCohortNUXButtonConfigs(cohortId)` — returns the four /// `Config.GetCohortNUXButtonConfigs(cohortId)` — returns the four
/// "What would you like to do first?" buttons shown in the OOBE /// "What would you like to do first?" buttons shown in the OOBE
@@ -1,10 +1,9 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.GameConfigurations; namespace DorkNet.Server.Controllers.API.GameConfigurations;
[ApiController] [ApiController]
public class GameConfigurationsController(ServerSettingsService serverSettings) : ControllerBase public class GameConfigurationsController : ControllerBase
{ {
// Actual path used by the 2020 build // Actual path used by the 2020 build
[HttpGet("api/gameconfigs/v1/all")] [HttpGet("api/gameconfigs/v1/all")]
@@ -12,6 +11,5 @@ public class GameConfigurationsController(ServerSettingsService serverSettings)
// Legacy path we originally assumed // Legacy path we originally assumed
[HttpGet("api/gameconfigurations/v1/all")] [HttpGet("api/gameconfigurations/v1/all")]
[HttpGet("api/gameconfigurations/v1")] [HttpGet("api/gameconfigurations/v1")]
public async Task<ActionResult<IReadOnlyList<GameConfigurationSetting>>> GetAll() public ActionResult<List<object>> GetAll() => Ok(new List<object>());
=> Ok(await serverSettings.GetGameConfigurationsAsync());
} }
@@ -1,151 +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;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.GameRewards;
[ApiController]
[Authorize]
public class GameRewardsController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("api/gamerewards/v1/pending")]
public async Task<IActionResult> Pending()
{
var pid = Me;
var rows = await db.GameRewardSelections
.Where(r => r.PlayerId == pid && r.SelectedAt == null)
.OrderBy(r => r.CreatedAt)
.Take(20)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/gamerewards/v1/request")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> RequestReward()
{
var req = await ReadRewardRequestAsync();
var row = new GameRewardSelectionEntity
{
PlayerId = Me,
RewardType = req.RewardType,
GiftContext = req.GiftContext,
Message = string.IsNullOrWhiteSpace(req.Message) ? "Choose a reward" : req.Message[..Math.Min(req.Message.Length, 256)],
};
db.GameRewardSelections.Add(row);
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
[HttpPost("api/gamerewards/v1/select")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> SelectReward()
{
var req = await ReadRewardSelectAsync();
var pid = Me;
var row = await db.GameRewardSelections
.FirstOrDefaultAsync(r => r.Id == req.RewardSelectionId && r.PlayerId == pid && r.SelectedAt == null);
if (row is null) return Ok(new { Success = false, Error = "reward_not_found" });
row.SelectedAt = DateTime.UtcNow;
row.SelectedGiftDropId = req.GiftDropId;
await level.GrantCurrencyAsync(pid, 2, 25, $"gameReward:{row.Id}");
await level.AwardXpAsync(pid, 25, $"gameReward:{row.Id}");
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
private static object ToWire(GameRewardSelectionEntity row) => new
{
RewardSelectionId = row.Id,
row.Message,
row.GiftContext,
row.RewardType,
GiftDrop1 = GiftDrop(row.Id * 10 + 1, "Tokens", currency: 25),
GiftDrop2 = GiftDrop(row.Id * 10 + 2, "XP", currency: 0),
GiftDrop3 = GiftDrop(row.Id * 10 + 3, "Bonus Tokens", currency: 25),
row.CreatedAt,
};
private static object GiftDrop(long id, string name, int currency) => new
{
GiftDropId = id,
FriendlyName = name,
Tooltip = string.Empty,
ConsumableItemDesc = string.Empty,
AvatarItemDesc = string.Empty,
AvatarItemType = 0,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
IsQuery = false,
Unique = false,
SubscribersOnly = false,
Rarity = 0,
CurrencyType = 2,
Currency = currency,
Context = 0,
ItemSetId = 0,
ItemSetFriendlyName = string.Empty,
};
private async Task<RewardRequest> ReadRewardRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new RewardRequest
{
RewardType = int.TryParse(form["rewardType"].FirstOrDefault(), out var rt) ? rt : 0,
GiftContext = int.TryParse(form["giftContext"].FirstOrDefault(), out var gc) ? gc : 0,
Message = form["Message"].FirstOrDefault() ?? form["message"].FirstOrDefault(),
};
}
try
{
return await JsonSerializer.DeserializeAsync<RewardRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RewardRequest();
}
catch (JsonException) { return new RewardRequest(); }
}
private async Task<RewardSelect> ReadRewardSelectAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new RewardSelect
{
RewardSelectionId = long.TryParse(form["rewardSelectionId"].FirstOrDefault(), out var id) ? id : 0,
GiftDropId = int.TryParse(form["giftDropId"].FirstOrDefault(), out var giftDropId) ? giftDropId : 0,
};
}
try
{
return await JsonSerializer.DeserializeAsync<RewardSelect>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RewardSelect();
}
catch (JsonException) { return new RewardSelect(); }
}
private sealed class RewardRequest
{
public int RewardType { get; set; }
public int GiftContext { get; set; }
public string? Message { get; set; }
}
private sealed class RewardSelect
{
public long RewardSelectionId { get; set; }
public int GiftDropId { get; set; }
}
}
@@ -29,10 +29,10 @@ namespace DorkNet.Server.Controllers.API.Images;
/// Returning [] (the previous catch-all) made the watch silently /// Returning [] (the previous catch-all) made the watch silently
/// drop the photo. /// drop the photo.
/// ///
/// Saved photos are stored as <see cref="PhotoEntity"/> rows. The 2020 /// Saved photos are stored as <see cref="PhotoEntity"/> rows with
/// client sends camera share state inside an imgMeta JSON form field /// IsPublic=false by default. A separate Share button (POST
/// (accessibility=1 for public ShareCamera uploads), while newer callers /// api/images/v4/share/{id}) flips IsPublic=true and surfaces them on
/// can still send top-level IsPublic. /// feed.rec.net.
/// </summary> /// </summary>
[ApiController] [ApiController]
public class ImagesController( public class ImagesController(
@@ -82,17 +82,12 @@ public class ImagesController(
// Pull optional metadata from the form (the 2020 watch sometimes // Pull optional metadata from the form (the 2020 watch sometimes
// includes a caption / room context, sometimes posts bytes only). // includes a caption / room context, sometimes posts bytes only).
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null; var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
var imageMeta = ParseImageMeta(form);
var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty; var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty;
var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty; var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(taggedIds) && imageMeta?.PlayerIds.Count > 0)
taggedIds = string.Join(",", imageMeta.PlayerIds);
long roomId = 0; long roomId = 0;
if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid; if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid;
if (roomId == 0 && imageMeta?.RoomId is > 0) roomId = imageMeta.RoomId.Value;
if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0; if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0;
var explicitPublic = form is null ? null : TryReadBool(form["IsPublic"].ToString()); var isPublic = string.Equals(form?["IsPublic"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
var isPublic = explicitPublic ?? (imageMeta?.Accessibility == 1 || imageMeta?.SavedImageType == 1);
var isProfileThumbnail = IsProfileThumbnail(form); var isProfileThumbnail = IsProfileThumbnail(form);
LogUploadForm("uploadsaved", pid, form, isProfileThumbnail); LogUploadForm("uploadsaved", pid, form, isProfileThumbnail);
@@ -285,23 +280,16 @@ public class ImagesController(
/// <summary>GET <c>api/images/v1/slideshow</c> — the watch's lobby /// <summary>GET <c>api/images/v1/slideshow</c> — the watch's lobby
/// slideshow info. Response shape per <c>SlideshowInfoDTO.Deserialize</c>: /// slideshow info. Response shape per <c>SlideshowInfoDTO.Deserialize</c>:
/// <c>{ValidTill (DateTime, required), Images (list, required)}</c>. /// <c>{ValidTill (DateTime, required), Images (list, required)}</c>.
/// A short <c>ValidTill</c> lets the Rec Center board pick up newly /// Far-future <c>ValidTill</c> stops the watch from re-polling.</summary>
/// shared camera photos without needing a server restart.</summary>
[HttpGet("api/images/v1/slideshow")] [HttpGet("api/images/v1/slideshow")]
public async Task<IActionResult> SlideshowInfo() public IActionResult SlideshowInfo() => Ok(new
{ {
var rows = await db.Photos // WireDates.FarFuture (2099), NOT year 9999 — a 9999 ValidTill
.Where(p => p.IsPublic && p.DeletedAt == null) // overflows the watch's local-timezone date parse in some DST
.OrderByDescending(p => p.CreatedAt) // timezones → "malformed RecNet response". See WireDates.
.Take(50) ValidTill = WireDates.FarFuture,
.ToListAsync(); Images = Array.Empty<object>(),
});
return Ok(new
{
ValidTill = DateTime.UtcNow.AddMinutes(5),
Images = rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)),
});
}
/// <summary>Binary image fallback for clients that request image bytes /// <summary>Binary image fallback for clients that request image bytes
/// through api/images/{name} instead of img.rec.net/{name}. Specific JSON /// through api/images/{name} instead of img.rec.net/{name}. Specific JSON
@@ -417,36 +405,6 @@ public class ImagesController(
public long PhotoId { get; set; } public long PhotoId { get; set; }
} }
public sealed class BulkCheeredRequest
{
public List<long>? SavedImageIds { get; set; }
public List<long>? ImageIds { get; set; }
}
[HttpPost("api/images/v4/cheered/bulk")]
[Authorize]
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
{
var pid = this.RequireCurrentPlayerId();
var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new())
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var cheered = await db.Cheers
.Where(c => c.FromPlayerId == pid && ids.Contains(c.TargetPhotoId))
.Select(c => c.TargetPhotoId)
.ToListAsync();
var set = cheered.ToHashSet();
return Ok(ids.Select(id => new
{
SavedImageId = id,
IsCheered = set.Contains(id),
}));
}
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo. /// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row /// Was previously Ack-only; now persists a <c>CheerEntity</c> row
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>. /// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
@@ -808,104 +766,6 @@ public class ImagesController(
return false; return false;
} }
private sealed class ParsedImageMeta
{
public int? SavedImageType { get; init; }
public int? Accessibility { get; init; }
public long? RoomId { get; init; }
public IReadOnlyList<long> PlayerIds { get; init; } = Array.Empty<long>();
}
private static ParsedImageMeta? ParseImageMeta(IFormCollection? form)
{
if (form is null) return null;
var meta = form["imgMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
meta = form["imageMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
return null;
try
{
using var doc = JsonDocument.Parse(meta);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return null;
return new ParsedImageMeta
{
SavedImageType = TryGetInt(doc.RootElement, "savedImageType", "SavedImageType", "imageType", "ImageType"),
Accessibility = TryGetInt(doc.RootElement, "accessibility", "Accessibility"),
RoomId = TryGetLong(doc.RootElement, "roomId", "RoomId"),
PlayerIds = TryGetLongArray(doc.RootElement, "playerIds", "PlayerIds"),
};
}
catch (JsonException)
{
return null;
}
}
private static bool? TryReadBool(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (bool.TryParse(value, out var parsed)) return parsed;
if (value == "1") return true;
if (value == "0") return false;
return null;
}
private static int? TryGetInt(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n))
return n;
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out n))
return n;
}
return null;
}
private static long? TryGetLong(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var n))
return n;
if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out n))
return n;
}
return null;
}
private static IReadOnlyList<long> TryGetLongArray(JsonElement root, params string[] names)
{
foreach (var name in names)
{
if (!root.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
continue;
return value.EnumerateArray()
.Select(x => x.ValueKind == JsonValueKind.Number && x.TryGetInt64(out var n)
? n
: x.ValueKind == JsonValueKind.String && long.TryParse(x.GetString(), out n)
? n
: 0L)
.Where(x => x > 0)
.Distinct()
.ToArray();
}
return Array.Empty<long>();
}
/// <summary>Build the response shape every images/v* endpoint /// <summary>Build the response shape every images/v* endpoint
/// returns. PascalCase keys + redundant aliases so whichever JSON /// returns. PascalCase keys + redundant aliases so whichever JSON
/// parser the 2020 watch uses, it finds the field it wants. The /// parser the 2020 watch uses, it finds the field it wants. The
@@ -917,49 +777,24 @@ public class ImagesController(
private object BuildImageInfo(PhotoEntity p, long accountId) private object BuildImageInfo(PhotoEntity p, long accountId)
{ {
var cdnHost = domain.Sub("cdn"); var cdnHost = domain.Sub("cdn");
// Accessibility maps from IsPublic so the 2020.12 watch's IBANLCLBGLM
// deserialiser reads a non-default value (0=Private, 1=Public).
var accessibility = p.IsPublic ? 1 : 0;
return new return new
{ {
// 2020.12 watch's IBANLCLBGLM strict keys (per // Primary keys the 2020 watch's ImageInfo deserialiser reads.
// docs/recroom-2020-client-response-contracts.md:1048).
// Missing any of these throws KeyNotFoundException in LitJson
// and the gallery empties out.
Id = p.Id,
ImageName = p.BlobName, ImageName = p.BlobName,
AccountId = (int)accountId, AccountId = (int)accountId,
// Server-side mirror of AccountId as PlayerId — some
// call-sites read PlayerId (lowercase id) instead of
// AccountId. Including both is harmless on the watch.
PlayerId = (int)accountId,
RoomId = p.RoomId,
// PlayerEventId is always 0 here (photo-event linkage isn't
// tracked yet); the field still has to be present so the
// strict deserialiser doesn't bail.
PlayerEventId = 0L,
Accessibility = accessibility,
// AccessibilityLocked = whether the watch can flip the
// privacy toggle. We don't gate on this server-side so the
// value is always false (UI is free to toggle).
AccessibilityLocked = false,
// ImageType: 0=ScreenShot, 1=ProfileImage, 2=ImageOfMe, …
// we don't differentiate yet — surface 0 unconditionally.
Type = 0,
ImageType = 0, ImageType = 0,
CreatedAt = p.CreatedAt, CreatedAt = p.CreatedAt,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
CheerCount = p.CheerCount,
// We don't track per-photo comments yet — surface 0 for
// the strict-key requirement.
CommentCount = 0,
// Bonus fields — useful for other clients (and our own feed UI). // Bonus fields — useful for other clients (and our own feed UI).
Id = p.Id,
Filename = p.BlobName, Filename = p.BlobName,
Url = $"https://{cdnHost}/{p.BlobName}", Url = $"https://{cdnHost}/{p.BlobName}",
Caption = p.Caption, Caption = p.Caption,
Description = p.Caption, Description = p.Caption,
RoomId = p.RoomId,
IsPublic = p.IsPublic, IsPublic = p.IsPublic,
CheerCount = p.CheerCount,
ViewCount = p.ViewCount, ViewCount = p.ViewCount,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
}; };
} }
@@ -19,15 +19,13 @@ namespace DorkNet.Server.Controllers.API.Inventions;
/// are POST. <c>delete</c>, <c>publish</c>, <c>unpublish</c>, /// are POST. <c>delete</c>, <c>publish</c>, <c>unpublish</c>,
/// <c>download</c> are also GET. /// <c>download</c> are also GET.
/// ///
/// Wire DTO matches the watch's <c>OBBBPCBIMME.PPGFHEDFBEA</c> /// Wire DTO matches <c>RecNet.Invention</c>
/// deserializer (Cpp2IL ISIL <c>OBBBPCBIMME.txt:588-704</c>): keys /// (<c>Cpp2IL_CS/.../RecNet/Invention.cs</c>) byte-for-byte: keys
/// <c>InventionId, ReplicationId, CreatorPlayerId, Name, Description, /// <c>InventionId, ReplicationId, CreatorPlayerId, Name, Description,
/// ImageName, CurrentVersionNumber, IsPublished, AllowTrial, /// ImageName, CurrentVersionNumber, IsPublished, ModifiedAt,
/// ModifiedAt, CreatedAt, FirstPublishedAt, CreationRoomId, /// CreatedAt, FirstPublishedAt, CreationRoomId,
/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount, /// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount,
/// CreatorPermission, GeneralPermission, IsAGInvention, Price, /// CreatorPermission, GeneralPermission, IsAgInvention</c>.
/// HideFromPlayer</c>. Casing is significant — note IsAGInvention
/// has a capital G.
/// </summary> /// </summary>
[ApiController] [ApiController]
public class InventionsController( public class InventionsController(
@@ -73,7 +71,6 @@ public class InventionsController(
/// <summary><c>GetLocalPlayerInventions</c> — same as Saved but /// <summary><c>GetLocalPlayerInventions</c> — same as Saved but
/// at v1 path. Watch hits both depending on tab.</summary> /// at v1 path. Watch hits both depending on tab.</summary>
[HttpGet("api/inventions/v1/mine")] [HttpGet("api/inventions/v1/mine")]
[HttpGet("api/inventions/v2/mine")]
[Authorize] [Authorize]
public Task<ActionResult> Mine() => Saved(100); public Task<ActionResult> Mine() => Saved(100);
@@ -170,11 +167,11 @@ public class InventionsController(
/// <summary>GET <c>api/inventions/v1/creatorIds</c> — the distinct /// <summary>GET <c>api/inventions/v1/creatorIds</c> — the distinct
/// creator account ids of every invention placed in a room /// creator account ids of every invention placed in a room
/// (<c>GetCreatorIdsOfInventionsInRoom</c>). The watch uses this to /// (<c>GetCreatorIdsOfInventionsInRoom</c>, called by the 2020.03
/// resolve which creators it must request invention permissions for /// watch on room load). It uses these to resolve which creators it
/// before spawning a room's invention-based geometry. Returns /// must request invention permissions for before spawning a room's
/// <c>List&lt;int&gt;</c> — empty when the room has no tracked /// invention-based geometry. Returns <c>List&lt;int&gt;</c> — empty
/// inventions.</summary> /// when the room has no tracked inventions.</summary>
[HttpGet("api/inventions/v1/creatorIds")] [HttpGet("api/inventions/v1/creatorIds")]
public async Task<IActionResult> CreatorIdsInRoom([FromQuery] long roomId) public async Task<IActionResult> CreatorIdsInRoom([FromQuery] long roomId)
{ {
@@ -222,59 +219,8 @@ public class InventionsController(
.ToListAsync(); .ToListAsync();
} }
/// <summary>GET <c>api/inventions/v1/fulllineageowner</c> —
/// raw Boolean. The client uses this to decide whether a set of
/// referenced inventions can be saved together under one owner.
/// DorkNet tracks current creator ownership, so the concrete rule
/// here is: every requested invention id must exist and share the
/// same CreatorPlayerId.</summary>
[HttpGet("api/inventions/v1/fulllineageowner")]
public async Task<IActionResult> FullLineageOwner()
{
var ids = Request.Query
.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Content("true", "application/json");
var owners = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.Select(i => new { i.Id, i.CreatorPlayerId })
.ToListAsync();
var ok = owners.Count == ids.Count
&& owners.Select(o => o.CreatorPlayerId).Distinct().Count() <= 1;
return Content(ok ? "true" : "false", "application/json");
}
// ── Single fetch ───────────────────────────────────────────────────── // ── Single fetch ─────────────────────────────────────────────────────
/// <summary>GET <c>/api/inventions/v1/room?id={roomId}</c> — every
/// invention used by the room. The 2020.12 watch fires this on
/// EVERY room load right after fetching the room data blob; it uses
/// the response to pre-resolve invention references the blob will
/// spawn. Without this, imported rooms with invention-based
/// geometry (floors, walls, custom shapes) render with everything
/// missing — the watch silently skips invention spawns when it
/// has no list, and you fall through the void.
///
/// We match on <see cref="InventionEntity.CreationRoomId"/> which
/// the zip importer populates from each Invention.json's
/// <c>CreationRoomId</c> field. Returns an array of
/// <see cref="ToWire"/>-shaped Invention objects (same wire shape
/// the single-fetch endpoint returns) so the watch's existing
/// deserializer works.</summary>
[HttpGet("api/inventions/v1/room")]
public async Task<ActionResult> ByRoom([FromQuery] long id)
{
var inventions = await ResolveRoomInventionsAsync(id);
return Ok(inventions.Select(ToWire).ToList());
}
[HttpGet("api/inventions/v1")] [HttpGet("api/inventions/v1")]
public async Task<ActionResult> SingleByQuery([FromQuery] long inventionId) public async Task<ActionResult> SingleByQuery([FromQuery] long inventionId)
{ {
@@ -338,87 +284,53 @@ public class InventionsController(
// ── Create / version ───────────────────────────────────────────────── // ── Create / version ─────────────────────────────────────────────────
public sealed class SaveInventionV4Request public sealed record SaveInventionRequest(
{ string Name, string? Description, string? ImageName,
public string? Name { get; set; } int? Permission, string? Tags, string BlobName,
public string? Description { get; set; } long? CreationRoomId, int? InstantiationCost, int? LightsCost);
public string? ImageName { get; set; }
public int InstantiationCost { get; set; }
public int LightsCost { get; set; }
public int AiCost { get; set; }
public long? CreationRoomId { get; set; }
public string? InventionDataFilename { get; set; }
public List<long>? ReferencedInventions { get; set; }
public int CreatorAccountRole { get; set; }
}
/// <summary>POST <c>api/inventions/v4/save</c> — 2020.12 maker-pen [HttpPost("api/inventions/v3/save")]
/// save flow. v4 wraps the new invention plus its first version in
/// a Status/Invention/InventionVersion object.</summary>
[HttpPost("api/inventions/v4/save")]
[Authorize] [Authorize]
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req) public async Task<ActionResult> Save([FromBody] SaveInventionRequest req)
{ {
var pid = CurrentPlayerId; var pid = CurrentPlayerId;
var name = (req.Name ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("missing name");
if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { Status = 1, Error = "missing name" }); if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
var blobName = (req.InventionDataFilename ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(blobName)) return BadRequest(new { Status = 1, Error = "missing inventionDataFilename" });
var inv = new InventionEntity var inv = new InventionEntity
{ {
CreatorPlayerId = pid, CreatorPlayerId = pid,
ReplicationId = Guid.NewGuid().ToString("D"), ReplicationId = Guid.NewGuid().ToString("D"),
Name = name, Name = req.Name.Trim(),
Description = req.Description ?? string.Empty, Description = req.Description ?? string.Empty,
ImageName = req.ImageName ?? string.Empty, ImageName = req.ImageName ?? string.Empty,
Permission = 0, Permission = Math.Clamp(req.Permission ?? 0, 0, 2),
CurrentBlobName = blobName, CurrentBlobName = req.BlobName,
TagsCsv = string.Empty, TagsCsv = req.Tags ?? string.Empty,
CreationRoomId = req.CreationRoomId, CreationRoomId = req.CreationRoomId,
CreatorPermission = 100, CreatorPermission = 100, // Unlimited for the creator
GeneralPermission = 0, GeneralPermission = 0, // Unassigned (private) until publish
IsPublished = false, IsPublished = false,
CurrentVersionNumber = 1, CurrentVersionNumber = 1,
}; };
db.Inventions.Add(inv); db.Inventions.Add(inv);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Snapshot the v1 version row.
var v1 = new InventionVersionEntity var v1 = new InventionVersionEntity
{ {
InventionId = inv.Id, InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"), ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = 1, VersionNumber = 1,
BlobName = blobName, BlobName = req.BlobName,
InstantiationCost = Math.Max(0, req.InstantiationCost), InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = Math.Max(0, req.LightsCost), LightsCost = req.LightsCost ?? 0,
}; };
db.InventionVersions.Add(v1); db.InventionVersions.Add(v1);
if (req.ReferencedInventions is { Count: > 0 })
{
foreach (var referencedId in req.ReferencedInventions.Distinct().Take(200))
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = $"invention:{inv.Id}:references:{referencedId}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
}
}
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
return Ok(new await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
{ return Ok(ToWire(inv));
Status = 0,
Invention = ToWireV4(inv),
InventionVersion = ToVersionWire(v1),
});
} }
public sealed record AddVersionRequest( public sealed record AddVersionRequest(
@@ -507,7 +419,7 @@ public class InventionsController(
return Ok(ToWire(inv)); return Ok(ToWire(inv));
} }
[HttpGet("api/inventions/v3/publish")] [HttpGet("api/inventions/v2/publish")]
[Authorize] [Authorize]
public async Task<ActionResult> Publish( public async Task<ActionResult> Publish(
[FromQuery] long inventionId, [FromQuery] int permissionLevel) [FromQuery] long inventionId, [FromQuery] int permissionLevel)
@@ -645,23 +557,6 @@ public class InventionsController(
_ => 0, _ => 0,
}; };
// Wire shape verified against the 2020.12 watch's
// OBBBPCBIMME.PPGFHEDFBEA deserializer (Cpp2IL ISIL OBBBPCBIMME.txt
// lines 588-704). All keys read via Util.GetKey<T> are required —
// a missing key throws KeyNotFoundException → the watch logs
// "Received malformed RecNet response" and aborts the join/fetch.
//
// Required (in disasm order):
// InventionId, ReplicationId, CreatorPlayerId, Name, Description,
// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
// ModifiedAt, CreatedAt, NumPlayersHaveUsedInRoom, NumDownloads,
// CheerCount, CreatorPermission, GeneralPermission, IsAGInvention,
// Price, HideFromPlayer.
// Optional (Util.GetKeyOrDefault): FirstPublishedAt, CreationRoomId.
//
// Casing is significant — LitJson + Util.GetKey are case-sensitive.
// In particular IsAGInvention has a capital G (matches the asset's
// "IsAGInvention" property, not the entity column IsAgInvention).
private static object ToWire(InventionEntity i) => new private static object ToWire(InventionEntity i) => new
{ {
InventionId = i.Id, InventionId = i.Id,
@@ -674,7 +569,6 @@ public class InventionsController(
i.ImageName, i.ImageName,
i.CurrentVersionNumber, i.CurrentVersionNumber,
i.IsPublished, i.IsPublished,
AllowTrial = true,
ModifiedAt = i.UpdatedAt, ModifiedAt = i.UpdatedAt,
i.CreatedAt, i.CreatedAt,
i.FirstPublishedAt, i.FirstPublishedAt,
@@ -684,13 +578,9 @@ public class InventionsController(
i.CheerCount, i.CheerCount,
i.CreatorPermission, i.CreatorPermission,
i.GeneralPermission, i.GeneralPermission,
IsAGInvention = i.IsAgInvention, i.IsAgInvention,
Price = 0,
HideFromPlayer = false,
}; };
private static object ToWireV4(InventionEntity i) => ToWire(i);
private static object ToVersionWire(InventionVersionEntity v) => new private static object ToVersionWire(InventionVersionEntity v) => new
{ {
v.InventionId, v.InventionId,
@@ -75,27 +75,15 @@ public class InventoryController(
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req) public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
{ {
var pid = Me; var pid = Me;
// Catalog-backed skins use a compound slug so we can round-trip // The slug for equipment is the modification GUID (or prefab
// the client's separate PrefabName + ModificationGuid fields. // name fallback when no mod is applied).
var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid) var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid)
? StoreService.EquipmentSkinSlug(req.PrefabName, req.ModificationGuid) ? req.ModificationGuid
: req.PrefabName; : req.PrefabName;
if (string.IsNullOrWhiteSpace(slug)) return BadRequest("missing prefab/mod identifier"); if (string.IsNullOrWhiteSpace(slug)) return BadRequest("missing prefab/mod identifier");
var row = await db.PlayerInventory var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug); .FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null && !string.IsNullOrWhiteSpace(req.ModificationGuid))
{
// Legacy rows created before catalog-backed equipment skins
// used the raw modification GUID as ItemSlug.
row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.ModificationGuid);
}
if (row is null && !string.IsNullOrWhiteSpace(req.PrefabName))
{
row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.PrefabName);
}
if (row is null) if (row is null)
{ {
// Player doesn't own this; reject rather than silently // Player doesn't own this; reject rather than silently
@@ -110,15 +98,8 @@ public class InventoryController(
// ── Consumables ────────────────────────────────────────────────────── // ── Consumables ──────────────────────────────────────────────────────
// 2020.12 client bumped the URL to v2 AND changed the response shape from
// per-item to BulkConsumable: each entry groups N instances of the same
// ConsumableItemDesc with parallel Ids[] / CreatedAts[] arrays. Deserializer
// is PLHFAEBLDAE.PPGFHEDFBEA reading PASCALCASE keys: Ids, CreatedAts,
// ConsumableItemDesc, Count, InitialCount, IsActive, ActiveDurationMinutes,
// IsTransferable. Returning the v1 per-item shape on v2 throws "Malformed
// Response" and the chain calls BootSequence.OINIBKFNFAJ — fatal kick.
[HttpGet("api/consumables/v1/getUnlocked")] [HttpGet("api/consumables/v1/getUnlocked")]
public async Task<IActionResult> GetUnlockedConsumablesV1() public async Task<IActionResult> GetUnlockedConsumables()
{ {
var pid = Me; var pid = Me;
var rows = await (from inv in db.PlayerInventory var rows = await (from inv in db.PlayerInventory
@@ -129,33 +110,6 @@ public class InventoryController(
return Ok(rows.Select(r => ToConsumableWire(r.inv, r.item))); return Ok(rows.Select(r => ToConsumableWire(r.inv, r.item)));
} }
[HttpGet("api/consumables/v2/getUnlocked")]
public async Task<IActionResult> GetUnlockedConsumablesV2()
{
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();
// Group by ConsumableItemDesc — one BulkConsumable per consumable type.
var bulk = rows
.GroupBy(r => StoreService.TryGetConsumableItemDesc(r.item.Slug, out var d) ? d : r.item.Slug)
.Select(g => new
{
Ids = g.Select(r => r.inv.Id).ToArray(),
CreatedAts = g.Select(r => r.inv.AcquiredAt).ToArray(),
ConsumableItemDesc = g.Key,
Count = g.Sum(r => r.inv.Quantity),
InitialCount = g.Sum(r => r.inv.Quantity),
IsActive = false,
ActiveDurationMinutes = 0,
IsTransferable = false,
});
return Ok(bulk);
}
public sealed class ConsumableItemRequest public sealed class ConsumableItemRequest
{ {
public long Id { get; set; } public long Id { get; set; }
@@ -202,30 +156,17 @@ public class InventoryController(
// ── Wire serializers ───────────────────────────────────────────────── // ── Wire serializers ─────────────────────────────────────────────────
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item) private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item) => new
{ {
var prefabName = item.Slug; PrefabName = item.Slug, // for store-derived items the slug doubles as prefab name
var modificationGuid = inv.ItemSlug; ModificationGuid = inv.ItemSlug,
var rarity = 0; PlatformMask = -1, // All
if (StoreService.TryGetEquipmentPayload(item.Slug, out var prefab, out var mod, out var skin)) IsPlatformLocked = false,
{ FriendlyName = item.DisplayName,
prefabName = prefab; Tooltip = item.Description,
modificationGuid = mod; Rarity = 0, // Common (no rarity column on StoreItem yet)
rarity = skin?.rarity ?? 0; Favorited = inv.IsActive,
} };
return new
{
PrefabName = prefabName,
ModificationGuid = modificationGuid,
PlatformMask = -1, // All
IsPlatformLocked = false,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
Rarity = rarity,
Favorited = inv.IsActive,
};
}
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item) private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
{ {
@@ -78,15 +78,13 @@ public class LeaderboardController(
public async Task<IActionResult> SetStat([FromBody] SetStatRequest req) public async Task<IActionResult> SetStat([FromBody] SetStatRequest req)
{ {
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var roomId = ResolveRoomId(req.RoomId, pid);
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel); s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null) if (row is null)
{ {
row = new LeaderboardStatEntity row = new LeaderboardStatEntity
{ {
PlayerId = pid, PlayerId = pid,
RoomId = roomId,
StatChannel = req.StatChannel, StatChannel = req.StatChannel,
Value = req.StatValue, Value = req.StatValue,
}; };
@@ -105,10 +103,11 @@ public class LeaderboardController(
// when it's zero (rare — direct stat report outside a room // when it's zero (rare — direct stat report outside a room
// context) fall back to the player's current presence so a // context) fall back to the player's current presence so a
// stray score still gets bucketed somewhere visible. // stray score still gets bucketed somewhere visible.
var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0);
if (roomId > 0) if (roomId > 0)
{ {
var meta = await db.LeaderboardChannelMeta var meta = await db.LeaderboardChannelMeta
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == req.StatChannel); .FirstOrDefaultAsync(c => c.Channel == req.StatChannel);
if (meta is null) if (meta is null)
{ {
// First time this channel reports. Stamp a meta row so // First time this channel reports. Stamp a meta row so
@@ -125,6 +124,17 @@ public class LeaderboardController(
ValueFormat = "count", 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(); await db.SaveChangesAsync();
@@ -151,22 +161,18 @@ public class LeaderboardController(
if (pid == 0) if (pid == 0)
return Ok(new { playerId = 0, score = 0L, rank = 0 }); return Ok(new { playerId = 0, score = 0L, rank = 0 });
var roomId = ResolveRoomId(req.RoomId, pid);
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel); s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null) if (row is null)
return Ok(new { playerId = (int)pid, score = 0L, rank = 0 }); return Ok(new { playerId = (int)pid, score = 0L, rank = 0 });
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel); var higherCount = await db.LeaderboardStats
var betterCount = await db.LeaderboardStats .CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value);
.CountAsync(s => s.RoomId == roomId
&& s.StatChannel == req.StatChannel
&& (lowerIsBetter ? s.Value < row.Value : s.Value > row.Value));
return Ok(new return Ok(new
{ {
playerId = (int)pid, playerId = (int)pid,
score = row.Value, score = row.Value,
rank = betterCount + 1, rank = higherCount + 1,
}); });
} }
@@ -188,13 +194,12 @@ public class LeaderboardController(
{ {
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0; var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
var window = Math.Clamp(req.WindowSize, 1, 50); var window = Math.Clamp(req.WindowSize, 1, 50);
var roomId = ResolveRoomId(req.RoomId, pid);
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
var query = db.LeaderboardStats var all = await db.LeaderboardStats
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel); .Where(s => s.StatChannel == req.StatChannel)
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value); .OrderByDescending(s => s.Value)
var all = await query.Select(s => new { s.PlayerId, s.Value }).ToListAsync(); .Select(s => new { s.PlayerId, s.Value })
.ToListAsync();
if (all.Count == 0) if (all.Count == 0)
{ {
// Empty case must still come back as a SingleLeaderboard // Empty case must still come back as a SingleLeaderboard
@@ -246,14 +251,12 @@ public class LeaderboardController(
var end = Math.Max(start, req.RankEnd); var end = Math.Max(start, req.RankEnd);
var skip = start - 1; var skip = start - 1;
var take = Math.Clamp(end - skip, 1, 200); var take = Math.Clamp(end - skip, 1, 200);
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
var roomId = ResolveRoomId(req.RoomId, pid);
var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel);
var query = db.LeaderboardStats var rows = await db.LeaderboardStats
.Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel); .Where(s => s.StatChannel == req.StatChannel)
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value); .OrderByDescending(s => s.Value)
var rows = await query.Skip(skip).Take(take).ToListAsync(); .Skip(skip).Take(take)
.ToListAsync();
var entries = rows.Select((s, i) => new var entries = rows.Select((s, i) => new
{ {
playerId = (int)s.PlayerId, playerId = (int)s.PlayerId,
@@ -268,15 +271,14 @@ public class LeaderboardController(
/// by the watch's full-leaderboard scoreboard view.</summary> /// by the watch's full-leaderboard scoreboard view.</summary>
[HttpGet("/leaderboard/Top")] [HttpGet("/leaderboard/Top")]
[HttpGet("/api/leaderboard/Top")] [HttpGet("/api/leaderboard/Top")]
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] long roomId = 0, [FromQuery] int take = 50) public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] int take = 50)
{ {
take = Math.Clamp(take, 1, 200); take = Math.Clamp(take, 1, 200);
var resolvedRoomId = ResolveRoomId(roomId, this.CurrentPlayerId() ?? 0); var rows = await db.LeaderboardStats
var lowerIsBetter = await IsLowerIsBetterAsync(resolvedRoomId, channel); .Where(s => s.StatChannel == channel)
var query = db.LeaderboardStats .OrderByDescending(s => s.Value)
.Where(s => s.RoomId == resolvedRoomId && s.StatChannel == channel); .Take(take)
query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value); .ToListAsync();
var rows = await query.Take(take).ToListAsync();
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic / // FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the // FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
// same Entry list for all four buckets — friends-vs-global // same Entry list for all four buckets — friends-vs-global
@@ -296,20 +298,4 @@ public class LeaderboardController(
NextResetUTC = DateTime.UtcNow.AddDays(7), NextResetUTC = DateTime.UtcNow.AddDays(7),
}); });
} }
private long ResolveRoomId(long requestedRoomId, long playerId)
{
if (requestedRoomId > 0) return requestedRoomId;
if (playerId > 0) return playerPresence.GetRoom(playerId)?.RoomId ?? 0;
return 0;
}
private async Task<bool> IsLowerIsBetterAsync(long roomId, int channel)
{
var live = await db.LeaderboardChannelMeta
.Where(c => c.RoomId == roomId && c.Channel == channel)
.Select(c => (bool?)c.LowerIsBetter)
.FirstOrDefaultAsync();
return live ?? false;
}
} }
@@ -22,7 +22,7 @@ public class MessagesController(
DorkNetDbContext db, DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
PrivateInstanceService privateInstances, PrivateInstanceService privateInstances,
PlayerPresenceService presence, OnlinePresenceService onlinePresence,
ServerSettingsService serverSettings, ServerSettingsService serverSettings,
ILogger<MessagesController> logger) : ControllerBase ILogger<MessagesController> logger) : ControllerBase
{ {
@@ -36,12 +36,12 @@ public class MessagesController(
[HttpGet("get")] [HttpGet("get")]
[HttpGet("/api/messages/v1/get")] [HttpGet("/api/messages/v1/get")]
[HttpGet("/api/messages/v3/get")] [HttpGet("/api/messages/v3/get")]
[HttpGet("/api/messages/v1/onlineStatus")]
public async Task<ActionResult<List<MessageDto>>> GetMessages([FromQuery] int take = 100) public async Task<ActionResult<List<MessageDto>>> GetMessages([FromQuery] int take = 100)
{ {
take = Math.Clamp(take, 1, 200); take = Math.Clamp(take, 1, 200);
var rows = await db.Messages var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me) .Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me)
.Where(m => m.Type >= 0)
// Defensive filter for the empty-body ghost rows that // Defensive filter for the empty-body ghost rows that
// accumulated before SendFormMessage rejected them. The // accumulated before SendFormMessage rejected them. The
// watch's MessagesScreenItem renders TextMessage (type 30) // watch's MessagesScreenItem renders TextMessage (type 30)
@@ -65,7 +65,7 @@ public class MessagesController(
public async Task<ActionResult<List<MessageDto>>> Unread() public async Task<ActionResult<List<MessageDto>>> Unread()
{ {
var rows = await db.Messages var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null && m.Type >= 0) .Where(m => m.RecipientPlayerId == Me && m.ReadAt == null)
// Same ghost-row filter as GetMessages above — keep the // Same ghost-row filter as GetMessages above — keep the
// unread badge count honest by hiding empty TextMessages. // unread badge count honest by hiding empty TextMessages.
.Where(m => m.Type != 30 || (m.Body != null && m.Body != "")) .Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
@@ -82,7 +82,7 @@ public class MessagesController(
public List<long>? MessageIds { get; set; } public List<long>? MessageIds { get; set; }
} }
/// <summary>POST <c>api/messages/v3/delete</c> — delete one or /// <summary>POST <c>api/messages/v3/delete</c> — hard-delete one or
/// more messages addressed to the caller. The 2020 watch's inbox /// more messages addressed to the caller. The 2020 watch's inbox
/// "delete" action posts JSON <c>{"MessageIds":[8]}</c> (plural /// "delete" action posts JSON <c>{"MessageIds":[8]}</c> (plural
/// array, verified from the live trace where the body was /// array, verified from the live trace where the body was
@@ -90,9 +90,7 @@ public class MessagesController(
/// some older / SPA call sites still post <c>id</c> or /// some older / SPA call sites still post <c>id</c> or
/// <c>messageId</c> as a form/query. Accept all three shapes and /// <c>messageId</c> as a form/query. Accept all three shapes and
/// scope every delete to <c>RecipientPlayerId == Me</c> so the /// scope every delete to <c>RecipientPlayerId == Me</c> so the
/// sender can't unsend. Game invites are tombstoned instead of /// sender can't unsend.</summary>
/// hard-deleted because the watch accepts an invite by firing this
/// delete request and <c>/goto/invite/{id}</c> in parallel.</summary>
[HttpPost("/api/messages/v3/delete")] [HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v1/delete")] [HttpPost("/api/messages/v1/delete")]
[Consumes("application/json")] [Consumes("application/json")]
@@ -130,27 +128,10 @@ public class MessagesController(
{ {
ids = ids.Distinct().ToList(); ids = ids.Distinct().ToList();
if (ids.Count == 0) return BadRequest(new { error = "missing_id" }); if (ids.Count == 0) return BadRequest(new { error = "missing_id" });
var deleted = await db.Messages
var inviteRows = await db.Messages
.Where(m => ids.Contains(m.Id)
&& m.RecipientPlayerId == Me
&& (m.Type == 6 || m.Type == 7))
.ToListAsync();
var now = DateTime.UtcNow;
foreach (var invite in inviteRows)
{
invite.Type = -invite.Type;
invite.ReadAt ??= now;
}
var hardDeleted = await db.Messages
.Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me) .Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me)
.Where(m => m.Type != 6 && m.Type != 7 && m.Type != -6 && m.Type != -7)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
if (inviteRows.Count > 0) return Ok(new { success = true, error = "", deleted });
await db.SaveChangesAsync();
return Ok(new { success = true, error = "", deleted = hardDeleted + inviteRows.Count });
} }
/// <summary>POST <c>api/messages/v3/markRead</c> — bulk /// <summary>POST <c>api/messages/v3/markRead</c> — bulk
@@ -297,71 +278,14 @@ public class MessagesController(
if (playerId <= 0 || playerId == Me) if (playerId <= 0 || playerId == Me)
return BadRequest(new { error = "invalid_recipient" }); return BadRequest(new { error = "invalid_recipient" });
// Resolve which room the invite is for. Two code paths: // Only the OWNER of the private instance can invite into it.
// // Otherwise an attacker who somehow learned a roomInstanceId
// PRIVATE instance: the sender created an invite-only match // could invite themselves (or anyone) freely.
// (via /goto/room/X?CreatePrivateInstance=1). The
// roomInstanceId the watch sends maps to a registered
// PrivateInstance and the owner or someone already inside
// that exact live instance can invite. That keeps access
// control intact while still letting a guest in someone
// else's dorm invite a friend from the in-game watch.
//
// PUBLIC room: the sender is in a public room / a dorm /
// any non-private room. RoomInstanceId is just
// roomId*100_000 — synthetic, never registered. We have to
// resolve which Room they're in via PlayerPresenceService
// (heartbeats keep this up to date) so we can stamp RoomId
// on the message and the recipient's /goto/invite/{N} path
// knows where to send them. No ownership check applies for
// public rooms.
long resolvedRoomId;
string roomName;
var inst = await privateInstances.GetAsync(roomInstanceId); var inst = await privateInstances.GetAsync(roomInstanceId);
if (inst is not null) if (inst is null)
{ return BadRequest(new { error = "instance_not_found", roomInstanceId });
var senderPresence = presence.GetRoom(Me); if (inst.OwnerPlayerId != Me)
var senderIsInInstance = senderPresence is not null return Forbid();
&& senderPresence.RoomInstanceId == roomInstanceId;
if (inst.OwnerPlayerId != Me && !senderIsInInstance)
{
logger.LogWarning(
"[invite] from={From} tried to invite to private instance={Instance} ownedBy={Owner} while presenceInstance={PresenceInstance}; rejecting",
Me, roomInstanceId, inst.OwnerPlayerId, senderPresence?.RoomInstanceId);
return Forbid();
}
resolvedRoomId = inst.RoomId;
roomName = !string.IsNullOrWhiteSpace(inst.Name)
? inst.Name
: await db.Rooms
.Where(r => r.Id == inst.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Private match";
}
else
{
// Public-room path. Trust the sender's current presence —
// the watch only fires SendInvite from the social menu
// while you're in a room, and heartbeats refresh presence
// every few seconds. We DON'T trust the raw roomInstanceId
// from the form because it's user-controlled and a
// malicious mod could send a fabricated one.
var senderPresence = presence.GetRoom(Me);
if (senderPresence is null || senderPresence.RoomId <= 0)
{
logger.LogWarning(
"[invite] from={From} to={To} instance={Instance}: no private instance AND no presence room — rejecting",
Me, playerId, roomInstanceId);
return BadRequest(new { error = "sender_not_in_a_room", roomInstanceId });
}
resolvedRoomId = senderPresence.RoomId;
roomName = !string.IsNullOrWhiteSpace(senderPresence.Name)
? senderPresence.Name
: await db.Rooms
.Where(r => r.Id == senderPresence.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Room";
}
// Persist a real RecNet.Message.MessageType.GameInviteV2 (=6) // Persist a real RecNet.Message.MessageType.GameInviteV2 (=6)
// so the watch's GameInviteController calls // so the watch's GameInviteController calls
@@ -374,6 +298,10 @@ public class MessagesController(
// second backfills the JSON Body once we know it. We can't // second backfills the JSON Body once we know it. We can't
// pre-allocate the Id because EF Core's identity columns only // pre-allocate the Id because EF Core's identity columns only
// populate on SaveChanges. // 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 var entry = new MessageEntity
{ {
@@ -383,19 +311,18 @@ public class MessagesController(
// until we overwrite it post-save with the real JSON. // until we overwrite it post-save with the real JSON.
Body = string.Empty, Body = string.Empty,
Type = 6, Type = 6,
RoomId = resolvedRoomId, RoomId = inst.RoomId,
}; };
db.Messages.Add(entry); db.Messages.Add(entry);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Only pin the invitee row for PRIVATE instances — public-room // InviteAsync AFTER the message save so we can pin the freshly-
// invites have no registered instance to track invitees against. // allocated Message.Id onto the invitee row's
// GoToInvite handles the missing-mapping case by falling through // LatestInviteMessageId. This is what /goto/invite/{N} falls
// to the message's RoomId + BuildResponseAsync(roomName). // back to when the watch's accept flow has already raced
if (inst is not null) // 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); await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id);
}
// Body shape MUST be the JSON the watch's Message.Deserialize // Body shape MUST be the JSON the watch's Message.Deserialize
// builds RoomInviteDetails from (verified at // builds RoomInviteDetails from (verified at
@@ -492,20 +419,6 @@ public class MessagesController(
return Ok(Array.Empty<object>()); return Ok(Array.Empty<object>());
} }
// NOTE: There is intentionally NO separate handler for
// `/api/messages/v2/send`. The controller route is
// `api/[controller]/v2`, so SendFormMessage's relative
// `[HttpPost("send")]` already binds `api/messages/v2/send` for
// form posts (and SendMessage binds it for JSON, disambiguated by
// [Consumes]). A previous standalone `SendSingle` action with an
// explicit `[HttpPost("/api/messages/v2/send")]` duplicated that
// binding, which made every form post to v2/send match two
// endpoints → AmbiguousMatchException (500). That broke the very
// thing it was meant to add: the watch's PartyUpRequest /
// "send join request" (MessageType 120), which posts the same
// ToPlayerId/Type/Data form shape SendFormMessage already accepts.
// Do not re-add a v2/send action; extend SendFormMessage instead.
/// <summary>POST <c>api/offlineinvite/v1/send</c> — send an invite /// <summary>POST <c>api/offlineinvite/v1/send</c> — send an invite
/// message to a player who's offline; surfaces in their inbox on /// message to a player who's offline; surfaces in their inbox on
/// next login. Wire shape: form-urlencoded <c>RecipientId</c> + /// next login. Wire shape: form-urlencoded <c>RecipientId</c> +
@@ -555,23 +468,17 @@ public class MessagesController(
/// <summary>The boot sequence asks for online friends. We /// <summary>The boot sequence asks for online friends. We
/// derive that from the SignalR hub's connected-player set — /// derive that from the SignalR hub's connected-player set —
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>. /// the response shape is <c>[ {AccountId, Online}, ... ]</c>.</summary>
///
/// Both <c>favoriteFriendOnlineStatus</c> and the more general
/// <c>onlineStatus</c> URLs alias to this handler since they
/// share the wire shape; pre-2026-05-25 the onlineStatus route
/// was wrongly aliased to GetMessages and returned the inbox
/// list instead, breaking the friends-online HUD.</summary>
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")] [HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
[HttpGet("/api/messages/v1/onlineStatus")]
public async Task<ActionResult> FavoriteFriendOnlineStatus() public async Task<ActionResult> FavoriteFriendOnlineStatus()
{ {
// Honors the global-friends toggle (everyone is a friend when on). // Honors the global-friends toggle (everyone is a friend when on).
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, Me); var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, Me);
var online = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(friendIds.Select(id => new return Ok(friendIds.Select(id => new
{ {
AccountId = id, AccountId = id,
Online = presence.IsRecentlyActive(id), Online = online.Contains(id),
})); }));
} }
@@ -136,9 +136,12 @@ public class InGameModerationController(
foreach (var target in ids) foreach (var target in ids)
{ {
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target); logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
// Push the ModerationKick shape the 2020.12 client expects: // Push a SignalR ModerationKick notification — the watch's
// Msg.Reason must be a ModerationBlockDetail object. // PlayerReporting.OnModerationKick handler boots the player
await notifications.KickPlayerAsync(target, "Instant kick by moderator."); // back to their dorm.
await notifications.NotifyAsync(
target,
Models.Notification.PushNotificationId.ModerationKick);
} }
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
} }
@@ -16,7 +16,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
{ {
error = "use_signalr_hub", error = "use_signalr_hub",
hint = $"connect to {domain.Url("notify", "/hub/v1")} instead", hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
}); });
/// <summary>GET <c>api/notification/hub/v1</c> — returns the /// <summary>GET <c>api/notification/hub/v1</c> — returns the
@@ -27,7 +27,7 @@ public class NotificationController(DomainConfig domain) : ControllerBase
[HttpGet("/api/notification/hub/v1")] [HttpGet("/api/notification/hub/v1")]
public IActionResult HubInfo() => Ok(new public IActionResult HubInfo() => Ok(new
{ {
Url = domain.Url("notify", "/hub/v1"), Url = $"https://{domain.Sub("notify")}/hub/v1",
Protocol = "signalr", Protocol = "signalr",
Version = 1, Version = 1,
}); });
@@ -1,57 +0,0 @@
using DorkNet.Server.Auth;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Pageview;
/// <summary>
/// Endpoints behind the watch's <c>COFHGNFJMOG</c> page-view + link
/// system. The 2020.12 client fires <c>POST pageview/consume</c> on
/// every screen change in the boot/menu flow expecting a response
/// that tells it (a) whether there's an in-app deep-link URL to
/// resolve and (b) how long the answer is fresh for. Returning a 404
/// here surfaces as red toast spam in the menus and (per
/// <c>BootSequence.OINIBKFNFAJ</c>) can short-circuit the avatar /
/// inventory bootstrap.
///
/// <para>Response shape verified at
/// <c>Cpp2IL_ISIL/.../MPHABHIMOOO.txt:45,50</c> — the watch's
/// <c>MPHABHIMOOO.PPGFHEDFBEA(Dictionary)</c> reads two camelCase
/// keys:
/// <list type="bullet">
/// <item><c>url</c> — string. Empty means "no deep-link to load".</item>
/// <item><c>freshnessSeconds</c> — int. How long the client can
/// cache this response before re-polling.</item>
/// </list>
/// Returning a long freshness (1 hour) keeps a private server's
/// chattiness low when there's no real linking layer.</para>
///
/// <para>Sibling endpoints from the same watch class
/// (<c>actionlink/*</c>, <c>datalink/*</c>, <c>referral</c>) are out
/// of scope for now — they fire only on explicit user share/click,
/// not in the boot path. Add them as separate routes when they show
/// up in the production log.</para>
/// </summary>
[ApiController]
public class PageviewController(ILogger<PageviewController> logger) : ControllerBase
{
/// <summary>POST <c>pageview/consume</c> — record a screen
/// transition. The body is empty in 2020.12.
/// <c>Promise&lt;MPHABHIMOOO&gt;</c> result; the watch only uses
/// the <c>url</c> / <c>freshnessSeconds</c> fields. We
/// intentionally return an empty url + a 1-hour freshness — the
/// private server has no in-app deep-link system, so there's
/// nothing to navigate to and no reason to be polled more often.</summary>
[HttpPost("pageview/consume", Order = 10)]
[HttpPost("/pageview/consume")]
[HttpGet("pageview/consume", Order = 10)]
[HttpGet("/pageview/consume")]
public IActionResult Consume()
{
logger.LogDebug("[pageview] consume callerId={Caller}", this.CurrentPlayerId() ?? 0);
return Ok(new
{
url = string.Empty,
freshnessSeconds = 3600,
});
}
}
@@ -62,35 +62,17 @@ public class PlayerCheerController(
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin /// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
/// a cheer category as the caller's selected badge. Stored on the /// a cheer category as the caller's selected badge. Stored on the
/// PlayerSetting key <c>SelectedCheer</c> (string-form int). /// PlayerSetting key <c>SelectedCheer</c> (string-form int).</summary>
///
/// Form field is <c>CheerCategory</c> — NOT <c>Cheer</c>. The 2020.12
/// watch's AIGGBBFKAKL.SetSelectedCheer (at
/// <c>AIGGBBFKAKL.txt:1083-1084</c>) does
/// <c>AddField("CheerCategory", value)</c> when POSTing to
/// <c>api/PlayerCheer/v1/SetSelectedCheer</c>. A mismatched binding
/// silently parses to 0, which the watch reads back as "no badge",
/// so the player's pinned cheer reverts every time.
///
/// The posted value is the watch's PIDFCOGLFHN category enum (e.g.
/// <c>9000</c> for a specific category) — we store it verbatim and
/// round-trip through <see cref="Reputation.SelectedCheer"/>; the
/// watch maps it back to the visual badge.</summary>
[HttpPost("api/PlayerCheer/v1/SetSelectedCheer")] [HttpPost("api/PlayerCheer/v1/SetSelectedCheer")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetSelectedCheer( public async Task<IActionResult> SetSelectedCheer([FromForm(Name = "Cheer")] int cheer = 0)
[FromForm(Name = "CheerCategory")] int? cheerCategory = null,
[FromForm(Name = "Cheer")] int? legacyCheer = null)
{ {
// Accept either field name — `CheerCategory` for 2020.12, `Cheer`
// as a defensive fallback for older / re-skinned clients.
var value = cheerCategory ?? legacyCheer ?? 0;
var existing = await db.PlayerSettings var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer"); .FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer");
if (existing is null) if (existing is null)
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = value.ToString() }); db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = cheer.ToString() });
else else
existing.Value = value.ToString(); existing.Value = cheer.ToString();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
} }
@@ -184,19 +184,13 @@ public class PlayerEventsController(
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the /// <summary>POST <c>api/playerevents/v1/respond</c> — set the
/// caller's RSVP state for an event. <c>Response</c> is the /// caller's RSVP state for an event. <c>Response</c> is the
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass). /// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
/// Idempotent — re-responding overwrites. /// Idempotent — re-responding overwrites.</summary>
///
/// Wire return is a BARE int (<c>DGOPHENCPOC</c> enum value:
/// 0=Success, plus various error codes). Returning an object
/// here throws InvalidCastException on the 2020.12 deserialiser
/// — verified at <c>recroom-2020-client-response-contracts.md</c>
/// under the playerevents/v1/respond section.</summary>
[HttpPost("api/playerevents/v1/respond")] [HttpPost("api/playerevents/v1/respond")]
public async Task<IActionResult> RespondForm( public async Task<IActionResult> RespondForm(
[FromForm(Name = "EventId")] long? eventId, [FromForm(Name = "EventId")] long? eventId,
[FromForm(Name = "Response")] int response = 0) [FromForm(Name = "Response")] int response = 0)
{ {
if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/); if (eventId is not long evt || evt <= 0) return Ok(new { success = false });
var existing = await db.PlayerEventResponses var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt); .FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null) if (existing is null)
@@ -204,20 +198,19 @@ public class PlayerEventsController(
else else
existing.Response = response; existing.Response = response;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(0 /*Success*/); return Ok(new { success = true, error = "" });
} }
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop /// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
/// the caller's RSVP row. Same bare-int wire shape as /// the caller's RSVP row.</summary>
/// <see cref="RespondForm"/>.</summary>
[HttpPost("api/playerevents/v1/deleteResponse")] [HttpPost("api/playerevents/v1/deleteResponse")]
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId) public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
{ {
if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/); if (eventId is not long evt) return Ok(new { success = true });
await db.PlayerEventResponses await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt) .Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
return Ok(0 /*Success*/); return Ok(new { success = true, error = "" });
} }
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard /// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
@@ -306,15 +299,11 @@ public class PlayerEventsController(
public string? Details { get; set; } public string? Details { get; set; }
} }
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
/// moderation report against an event. Wire response is
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
/// <c>{Reported:true}</c> throws on the strict deserialiser.</summary>
[HttpPost("api/playerevents/v1/report")] [HttpPost("api/playerevents/v1/report")]
public async Task<IActionResult> Report([FromBody] PlayerEventReportRequest req) public async Task<IActionResult> Report([FromBody] PlayerEventReportRequest req)
{ {
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId); var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return Ok(new { Success = false, Message = "no_such_event" }); if (ev is null) return NotFound();
var msg = (req.Details ?? string.Empty).Trim(); var msg = (req.Details ?? string.Empty).Trim();
db.Reports.Add(new ReportEntity db.Reports.Add(new ReportEntity
{ {
@@ -325,6 +314,6 @@ public class PlayerEventsController(
Message = msg[..Math.Min(1000, msg.Length)], Message = msg[..Math.Min(1000, msg.Length)],
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty }); return Ok(new { Reported = true });
} }
} }
@@ -25,17 +25,6 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting;
[Authorize] [Authorize]
public class ReportsController(DorkNetDbContext db) : ControllerBase public class ReportsController(DorkNetDbContext db) : ControllerBase
{ {
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
[AllowAnonymous]
public IActionResult VoteToKickReasons() => Ok(new[]
{
new { ReportCategory = 1, Reason = "Harassment" },
new { ReportCategory = 2, Reason = "Inappropriate behavior" },
new { ReportCategory = 3, Reason = "Cheating" },
new { ReportCategory = 4, Reason = "Spam" },
new { ReportCategory = 5, Reason = "Other" },
});
[HttpPost("api/PlayerReporting/v1/report")] [HttpPost("api/PlayerReporting/v1/report")]
[HttpPost("api/PlayerReporting/v1/playerreport")] [HttpPost("api/PlayerReporting/v1/playerreport")]
[HttpPost("api/PlayerReporting/v2/report")] [HttpPost("api/PlayerReporting/v2/report")]
@@ -47,7 +47,7 @@ public class PlayersController(PlayerService playerService, NotificationService
if (!updated) return NotFound(); if (!updated) return NotFound();
var player = await playerService.GetByIdAsync(CurrentPlayerId); var player = await playerService.GetByIdAsync(CurrentPlayerId);
await notificationService.AccountChanged(player!); await notificationService.NotifyAsync(CurrentPlayerId, Models.Notification.PushNotificationId.SubscriptionUpdateProfile, PlayerService.ToProfile(player!));
return Ok(PlayerService.ToProfile(player!)); return Ok(PlayerService.ToProfile(player!));
} }
@@ -63,7 +63,6 @@ public class PlayersController(PlayerService playerService, NotificationService
await playerService.UpdateBioAsync(id, req.Bio); await playerService.UpdateBioAsync(id, req.Bio);
var player = await playerService.GetByIdAsync(id); var player = await playerService.GetByIdAsync(id);
await notificationService.AccountChanged(player!);
return Ok(PlayerService.ToProfile(player!)); return Ok(PlayerService.ToProfile(player!));
} }
} }
@@ -1,304 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.API.Rooms.V2;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Playlists;
/// <summary>
/// 2020.12 playlist CRUD surface — every <c>playlists/*</c> route the
/// watch hits beyond the basic listing/detail reads in
/// <see cref="RoomsController"/>. URL templates are bare (no
/// <c>/roomserver/</c> prefix) because the 2020.12 client emits them
/// that way on the Rooms host; the same routes are NOT mirrored under
/// <c>/roomserver/</c> here to avoid duplicating definitions that already
/// exist on <c>RoomsController</c>.
///
/// Wire shape conventions:
/// • Single mutation responses use <c>BMFAGMFKODA</c> — the union
/// <c>KMKPEOGJDFK</c> entry + <c>Rooms</c> + <c>Tags</c> arrays
/// (same shape that <c>PlaylistDetails</c> in <see cref="RoomsController"/>
/// returns). See <see cref="BuildDetailsResponseAsync"/>.
/// • List responses (cheeredby/me, createdby/me, etc.) use bare
/// <c>List&lt;KMKPEOGJDFK&gt;</c> — the union entry alone.
/// • Bookmark / cheer toggles return <c>{success: true}</c>; the
/// watch's reads come back through <c>interactionby/me</c>.
/// </summary>
[ApiController]
public class PlaylistsController(
PlaylistService playlists,
DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// ── Create / delete ──────────────────────────────────────────────
public sealed class CreatePlaylistRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public string? Tags { get; set; }
}
[HttpPost("/playlists")]
[Authorize]
public async Task<IActionResult> Create([FromBody] CreatePlaylistRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest(new { error = "missing_name" });
var created = await playlists.CreateAsync(Me, req.Name, req.Description, req.ImageName, req.Tags);
return await BuildDetailsResponseAsync(created.Id);
}
[HttpDelete("/playlists/{playlistId:long}")]
[Authorize]
public async Task<IActionResult> Delete(long playlistId)
{
try
{
var ok = await playlists.DeleteAsync(playlistId, Me);
return ok ? Ok() : NotFound();
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Per-field mutations (each returns BMFAGMFKODA) ───────────────
public sealed class StringFieldRequest { public string? Value { get; set; } }
public sealed class IntFieldRequest { public int? Value { get; set; } }
public sealed class BoolFieldRequest { public bool? Value { get; set; } }
public sealed class TagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
public Task<IActionResult> PlaylistName(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Name")] string? formValue) =>
ApplyMutation(playlistId, p => p.Name = (req?.Value ?? formValue ?? p.Name).Trim());
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
public Task<IActionResult> PlaylistDescription(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Description")] string? formValue) =>
ApplyMutation(playlistId, p => p.Description = req?.Value ?? formValue ?? p.Description);
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
public Task<IActionResult> PlaylistImage(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "ImageName")] string? formValue) =>
ApplyMutation(playlistId, p => p.ImageName = req?.Value ?? formValue ?? p.ImageName);
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
public Task<IActionResult> PlaylistTags(long playlistId, [FromBody] TagsRequest? req,
[FromForm(Name = "Tags")] string? formValue) =>
ApplyMutation(playlistId, p =>
{
var csv = req?.Tags
?? (req?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
?? formValue
?? p.TagsCsv;
p.TagsCsv = csv;
});
/// <summary>Accessibility / visibility / level-voting / restrictions /
/// warning are persisted on the playlist row even though the 2020
/// PlaylistEntity doesn't have dedicated columns for each — the wire
/// shape always reports the union-entry defaults, so for now the
/// mutation is a no-op acknowledgement that returns the unchanged
/// playlist. Real per-field columns can be added when we have a
/// playlist-settings UI to drive them.</summary>
[HttpPost("/playlists/{playlistId:long}/accessibility")]
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPost("/playlists/{playlistId:long}/warning")]
[Authorize]
public Task<IActionResult> PlaylistAck(long playlistId) =>
ApplyMutation(playlistId, _ => { /* no-op: field not persisted yet */ });
private async Task<IActionResult> ApplyMutation(long playlistId, Action<PlaylistEntity> mutator)
{
try
{
var updated = await playlists.ModifyAsync(playlistId, Me, mutator);
if (updated is null) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Member-room mutations ────────────────────────────────────────
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
{
try
{
if (!await playlists.AddRoomAsync(playlistId, roomId, Me)) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
{
try
{
if (!await playlists.RemoveRoomAsync(playlistId, roomId, Me)) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Cheer / favorite toggles ─────────────────────────────────────
[HttpPost("/playlists/{playlistId:long}/interactionby/me/cheer")]
[HttpPut("/playlists/{playlistId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> Cheer(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, cheered: true);
return Ok(new { success = true });
}
[HttpDelete("/playlists/{playlistId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> Uncheer(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, cheered: false);
return Ok(new { success = true });
}
[HttpPost("/playlists/{playlistId:long}/interactionby/me/favorite")]
[HttpPut("/playlists/{playlistId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> Favorite(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, favorited: true);
return Ok(new { success = true });
}
[HttpDelete("/playlists/{playlistId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> Unfavorite(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, favorited: false);
return Ok(new { success = true });
}
// ── My-playlists tabs (bare-list KMKPEOGJDFK) ───────────────────
[HttpGet("/playlists/createdby/me")]
[Authorize]
public async Task<IActionResult> CreatedByMe()
{
var rows = await playlists.CreatedByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
[HttpGet("/playlists/cheeredby/me")]
[Authorize]
public async Task<IActionResult> CheeredByMe()
{
var rows = await playlists.CheeredByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
[HttpGet("/playlists/favoritedby/me")]
[Authorize]
public async Task<IActionResult> FavoritedByMe()
{
var rows = await playlists.FavoritedByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
/// <summary>GET <c>/playlists/visitedby/me</c> — we don't track
/// per-player playlist visits yet (visits are tracked at the Room
/// level via RoomVisitEntity). Return empty so the watch's
/// playlists tab renders cleanly; populated once we add a
/// PlaylistVisitEntity or derive from member-room visits.</summary>
[HttpGet("/playlists/visitedby/me")]
[Authorize]
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
// ── Bulk lookup ──────────────────────────────────────────────────
/// <summary>GET <c>/playlists/bulk?id=A&amp;id=B</c> — bulk-by-id
/// lookup for the watch's playlist cache warmup. Bare list of
/// KMKPEOGJDFK entries, one per resolved id (missing ids
/// silently dropped).</summary>
[HttpGet("/playlists/bulk")]
public async Task<IActionResult> Bulk()
{
var ids = Request.Query["id"].Concat(Request.Query["ids"])
.SelectMany(v => (v ?? string.Empty).Split(',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(s => long.TryParse(s, out var n) ? n : 0)
.Where(n => n != 0)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await playlists.BulkAsync(ids);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
// ── Detail-response builder (BMFAGMFKODA shape) ─────────────────
/// <summary>
/// BMFAGMFKODA wire shape: KMKPEOGJDFK union entry + Rooms list
/// (List&lt;KLCOGEIGEBJ&gt;) + Tags list (List&lt;DPHPFLGAICI&gt;).
/// Mirrors the inline logic in <see cref="RoomsController.PlaylistDetails"/>
/// so the BARE-PATH mutation routes here return the same shape the
/// watch's <c>PlaylistDetails.Deserialize</c> reads.
/// </summary>
private async Task<IActionResult> BuildDetailsResponseAsync(long playlistId)
{
var p = await playlists.GetByIdAsync(playlistId);
if (p is null) return NotFound();
var roomIds = await playlists.RoomIdsAsync(playlistId);
List<RoomEntity> memberRooms = new();
Dictionary<long, IReadOnlyList<RoomSceneEntity>> scenesByRoom = new();
if (roomIds.Count > 0)
{
var roomRows = await db.Rooms.Where(r => roomIds.Contains(r.Id)).ToListAsync();
var byId = roomRows.ToDictionary(r => r.Id);
memberRooms = roomIds
.Select(rid => byId.TryGetValue(rid, out var r) ? r : null)
.Where(r => r is not null).Select(r => r!).ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex).ToListAsync();
scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
}
var roomsWire = memberRooms
.Select(r => RoomsController.BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
var tagsWire = string.IsNullOrWhiteSpace(p.TagsCsv)
? new List<object>()
: p.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.ToList();
var result = RoomsController.BuildPlaylistUnionEntry(p);
result["Rooms"] = roomsWire;
result["Tags"] = tagsWire;
return Ok(result);
}
}
@@ -21,7 +21,8 @@ namespace DorkNet.Server.Controllers.API.ProgressionApi;
public class ProgressionController( public class ProgressionController(
DorkNetDbContext db, DorkNetDbContext db,
LevelService level, LevelService level,
ServerSettingsService serverSettings) : ControllerBase ServerSettingsService serverSettings,
StoreService store) : ControllerBase
{ {
private long Me => this.RequireCurrentPlayerId(); private long Me => this.RequireCurrentPlayerId();
private long? MeOrNull => this.CurrentPlayerId(); private long? MeOrNull => this.CurrentPlayerId();
@@ -60,16 +61,18 @@ public class ProgressionController(
var mapId = CurrentChallengeMapId(); var mapId = CurrentChallengeMapId();
var weekly = await serverSettings.GetWeeklyChallengesAsync(); var weekly = await serverSettings.GetWeeklyChallengesAsync();
var challengeDefs = weekly.Challenges; var defs = weekly.Challenges;
var keys = challengeDefs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray();
var rows = pid is long me var keys = defs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray();
var rows = pid is long me && keys.Length > 0
? await db.ObjectiveProgress ? await db.ObjectiveProgress
.Where(o => o.PlayerId == me && keys.Contains(o.Key)) .Where(o => o.PlayerId == me && keys.Contains(o.Key))
.ToDictionaryAsync(o => o.Key, o => o.IsCompleted) .ToDictionaryAsync(o => o.Key, o => o.IsCompleted)
: new Dictionary<string, bool>(); : new Dictionary<string, bool>();
bool Done(int idx) => rows.TryGetValue(keys[idx], out var v) && v; bool Done(int idx) => rows.TryGetValue(ChallengeProgressKey(mapId, idx), out var v) && v;
var reward = weekly.Reward;
return Ok(new return Ok(new
{ {
ChallengeMapId = mapId, ChallengeMapId = mapId,
@@ -77,13 +80,8 @@ public class ProgressionController(
StartAt = start, StartAt = start,
EndAt = start.AddDays(7), EndAt = start.AddDays(7),
ServerTime = now, ServerTime = now,
// 2020.12 ChallengeMap.Deserialize (FNEEJMCEPOL.PPGFHEDFBEA) reads
// FallbackGiftName as a required key. Missing → KeyNotFoundException
// → "Received malformed RecNet response" → blocks the weekly-challenge
// promise chain at boot.
FallbackGiftName = string.Empty,
ChallengeThemeString = "Weekly", ChallengeThemeString = "Weekly",
Challenges = challengeDefs.Select(c => new Challenges = defs.Select(c => new
{ {
ChallengeId = ChallengeId(mapId, c.Index), ChallengeId = ChallengeId(mapId, c.Index),
c.Name, c.Name,
@@ -92,32 +90,65 @@ public class ProgressionController(
c.Tooltip, c.Tooltip,
Complete = Done(c.Index), Complete = Done(c.Index),
}), }),
Gift = new Gift = BuildGiftDto(mapId, reward),
{
GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : mapId,
weekly.Reward.AvatarItemDesc,
weekly.Reward.ConsumableItemDesc,
weekly.Reward.EquipmentPrefabName,
weekly.Reward.EquipmentModificationGuid,
weekly.Reward.Xp,
weekly.Reward.Level,
weekly.Reward.GiftContext,
weekly.Reward.GiftRarity,
},
}); });
} }
/// <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 sealed class UpdateChallengeRequest
{ {
public int Id { get; set; } public int Id { get; set; }
public int ChallengeMapId { get; set; } public int ChallengeMapId { get; set; }
public int ChallengeId { get; set; } public int ChallengeId { get; set; }
public int Progress { get; set; } public int Progress { get; set; }
public string? Config { get; set; }
public bool? Complete { get; set; }
} }
[HttpPost("api/challenge/v1/updateProgress")]
[HttpPost("api/challenge/v2/updateProgress")] [HttpPost("api/challenge/v2/updateProgress")]
[Authorize] [Authorize]
public async Task<IActionResult> UpdateChallengeProgress([FromForm] UpdateChallengeRequest formReq) public async Task<IActionResult> UpdateChallengeProgress([FromForm] UpdateChallengeRequest formReq)
@@ -126,6 +157,7 @@ public class ProgressionController(
var req = await ReadChallengeRequestAsync(formReq); var req = await ReadChallengeRequestAsync(formReq);
var mapId = req.ChallengeMapId != 0 ? req.ChallengeMapId : CurrentChallengeMapId(); var mapId = req.ChallengeMapId != 0 ? req.ChallengeMapId : CurrentChallengeMapId();
var challengeId = req.ChallengeId != 0 ? req.ChallengeId : req.Id; var challengeId = req.ChallengeId != 0 ? req.ChallengeId : req.Id;
var weekly = await serverSettings.GetWeeklyChallengesAsync(); var weekly = await serverSettings.GetWeeklyChallengesAsync();
var challengeCount = Math.Max(1, weekly.Challenges.Count); var challengeCount = Math.Max(1, weekly.Challenges.Count);
var index = ChallengeIndex(mapId, challengeId, challengeCount); var index = ChallengeIndex(mapId, challengeId, challengeCount);
@@ -137,8 +169,7 @@ public class ProgressionController(
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key }; row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row); db.ObjectiveProgress.Add(row);
} }
var shouldComplete = req.Complete ?? true; if (!row.IsCompleted)
if (shouldComplete && !row.IsCompleted)
{ {
row.IsCompleted = true; row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow; row.ClearedAt = DateTime.UtcNow;
@@ -147,29 +178,36 @@ public class ProgressionController(
var weeklyRewarded = false; var weeklyRewarded = false;
var allKeys = weekly.Challenges var allKeys = weekly.Challenges
.Select(c => c.Index) .Select(c => ChallengeProgressKey(mapId, c.Index))
.Select(i => ChallengeProgressKey(mapId, i))
.ToArray(); .ToArray();
var completeCount = await db.ObjectiveProgress var completeCount = await db.ObjectiveProgress
.CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted); .CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted);
if (completeCount >= allKeys.Length) if (completeCount >= allKeys.Length)
{ {
var reward = weekly.Reward;
weeklyRewarded = await GrantRewardOnceAsync( weeklyRewarded = await GrantRewardOnceAsync(
pid, pid,
$"weekly-challenge:{mapId}", $"weekly-challenge:{mapId}",
xp: weekly.Reward.Xp, xp: reward.Xp,
tokens: weekly.Reward.Tokens, tokens: reward.Tokens,
reason: $"weekly_challenge:{mapId}"); 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 return Ok(new
{ {
ChallengeMapId = mapId, ChallengeMapId = mapId,
ChallengeId = challengeId, ChallengeId = challengeId,
Config = req.Config ?? string.Empty, Progress = Math.Max(req.Progress, 10),
Complete = row.IsCompleted, IsCompleted = true,
Progress = Math.Max(req.Progress, row.IsCompleted ? 10 : 0),
IsCompleted = row.IsCompleted,
ClearedAt = row.ClearedAt, ClearedAt = row.ClearedAt,
Rewarded = weeklyRewarded, Rewarded = weeklyRewarded,
}); });
@@ -282,13 +320,13 @@ public class ProgressionController(
var isRewarded = await IsRewardedAsync(pid, rewardKey); var isRewarded = await IsRewardedAsync(pid, rewardKey);
return Ok(new return Ok(new
{ {
// 2020.12 NMEKJOABIFC.PPGFHEDFBEA: HasClaimedReward (not IsRewarded).
req.Index,
req.Group, req.Group,
req.Index,
Progress = row.IsCompleted ? 1f : req.Progress, Progress = row.IsCompleted ? 1f : req.Progress,
VisualProgress = row.IsCompleted ? 1f : req.Progress, VisualProgress = row.IsCompleted ? 1f : req.Progress,
IsCompleted = row.IsCompleted, IsCompleted = row.IsCompleted,
HasClaimedReward = isRewarded, IsRewarded = isRewarded,
IsDirty = false,
}); });
} }
@@ -349,10 +387,6 @@ public class ProgressionController(
.Select(o => .Select(o =>
{ {
var (grp, idx) = ParseObjectiveKey(o.Key); var (grp, idx) = ParseObjectiveKey(o.Key);
// 2020.12 NMEKJOABIFC.PPGFHEDFBEA reads Index, Group, Progress,
// VisualProgress, IsCompleted, HasClaimedReward (NOT IsRewarded).
// Missing HasClaimedReward → KeyNotFoundException → "Couldn't
// download progress data" → BootSequence kick to LogoutScene.
return new return new
{ {
Index = idx, Index = idx,
@@ -360,7 +394,8 @@ public class ProgressionController(
Progress = o.IsCompleted ? 1f : 0f, Progress = o.IsCompleted ? 1f : 0f,
VisualProgress = o.IsCompleted ? 1f : 0f, VisualProgress = o.IsCompleted ? 1f : 0f,
IsCompleted = o.IsCompleted, IsCompleted = o.IsCompleted,
HasClaimedReward = rewardKeys.Contains(o.Key), IsRewarded = rewardKeys.Contains(o.Key),
IsDirty = false,
}; };
}); });
@@ -415,18 +450,10 @@ public class ProgressionController(
}); });
} }
/// <summary>POST <c>api/players/v1/objectives</c> + /// <summary>POST <c>api/players/v2/objectives</c> — batched
/// <c>api/players/v2/objectives</c> — batched ProgressionManager /// ProgressionManager completion records. These are separate from
/// completion records (the watch's /// ObjectiveProgress rows: they grant XP/currency for daily
/// <c>ACJLCBNBJDK.EIFPPIPOIHB(List&lt;PIHHFPDKFAG&gt;)</c>; body is a /// objective events and must be idempotent per UTC day.</summary>
/// JSON array of <c>{objectiveType: int, additionalXp: int,
/// inParty: bool}</c>). Grants XP + RecCenter tokens via
/// <see cref="RewardForObjectiveType"/> and dedupes per UTC day
/// through <see cref="GrantRewardOnceAsync"/>, so a re-submission of
/// the same objective on the same day is a no-op. Returns the
/// granted XP / currency + post-grant balance so the watch UI can
/// refresh without a follow-up GET.</summary>
[HttpPost("api/players/v1/objectives")]
[HttpPost("api/players/v2/objectives")] [HttpPost("api/players/v2/objectives")]
[Authorize] [Authorize]
public async Task<IActionResult> CompletePlayerObjectives([FromBody] List<PlayerObjectiveCompletionRecord>? records) public async Task<IActionResult> CompletePlayerObjectives([FromBody] List<PlayerObjectiveCompletionRecord>? records)
@@ -478,18 +505,6 @@ public class ProgressionController(
private static int CurrentChallengeMapId() => private static int CurrentChallengeMapId() =>
(int)((DateTime.UtcNow - new DateTime(2020, 1, 1)).Days / 7 % 52 + 1); (int)((DateTime.UtcNow - new DateTime(2020, 1, 1)).Days / 7 % 52 + 1);
private static string ChallengeProgressKey(int mapId, int index) => $"challenge:{mapId}:{index}";
private static int ChallengeId(int mapId, int index) => mapId * 10 + index;
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);
}
private async Task<UpdateChallengeRequest> ReadChallengeRequestAsync(UpdateChallengeRequest formReq) private async Task<UpdateChallengeRequest> ReadChallengeRequestAsync(UpdateChallengeRequest formReq)
{ {
if (Request.HasFormContentType || formReq.Id != 0 || formReq.ChallengeId != 0 || formReq.ChallengeMapId != 0) if (Request.HasFormContentType || formReq.Id != 0 || formReq.ChallengeId != 0 || formReq.ChallengeMapId != 0)
@@ -340,41 +340,27 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
// Watch URLs: "api/relationships/v1/favorite?id={N}" and // Watch URLs: "api/relationships/v1/favorite?id={N}" and
// "api/relationships/v1/unfavorite?id={N}". These return a // "api/relationships/v1/unfavorite?id={N}". These return a
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer // boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
// disassembly, not a Relationship. The flag is persisted on the caller's // disassembly (Core.Post … ExpectHttpStatusSuccess), not a
// directional row and surfaced back through v2/get + personaldetails // Relationship. The flag is persisted on the caller's directional row
// (see BuildMerged). // and surfaced back through v2/get + personaldetails (see BuildMerged).
//
// VERB: the 2020 watch sends these as GET (RecNet.Relationships
// FavoritePlayer/UnfavoritePlayer/etc. call Core.Get — verified in the
// readable March dump at Relationships.txt:1675/1834, and the December
// build hits the same obfuscated Core-GET wrapper). The endpoints were
// POST-only, so the watch's GET returned HTTP 405 ("Error favoriting
// player: HTTP Error 405"). Accept GET (real verb) + POST (cross-build
// safety), mirroring the friend endpoints above.
[HttpGet("api/relationships/v1/favorite")]
[HttpPost("api/relationships/v1/favorite")] [HttpPost("api/relationships/v1/favorite")]
public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true); public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true);
[HttpGet("api/relationships/v1/unfavorite")]
[HttpPost("api/relationships/v1/unfavorite")] [HttpPost("api/relationships/v1/unfavorite")]
public Task<IActionResult> Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false); public Task<IActionResult> Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false);
[HttpGet("api/relationships/v1/mute")]
[HttpPost("api/relationships/v1/mute")] [HttpPost("api/relationships/v1/mute")]
public Task<IActionResult> Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) public Task<IActionResult> Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: true); => SetPreference(id != 0 ? id : formId ?? 0, muted: true);
[HttpGet("api/relationships/v1/unmute")]
[HttpPost("api/relationships/v1/unmute")] [HttpPost("api/relationships/v1/unmute")]
public Task<IActionResult> Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) public Task<IActionResult> Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: false); => SetPreference(id != 0 ? id : formId ?? 0, muted: false);
[HttpGet("api/relationships/v1/ignore")]
[HttpPost("api/relationships/v1/ignore")] [HttpPost("api/relationships/v1/ignore")]
public Task<IActionResult> Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) public Task<IActionResult> Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: true); => SetPreference(id != 0 ? id : formId ?? 0, ignored: true);
[HttpGet("api/relationships/v1/unignore")]
[HttpPost("api/relationships/v1/unignore")] [HttpPost("api/relationships/v1/unignore")]
public Task<IActionResult> Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId) public Task<IActionResult> Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: false); => SetPreference(id != 0 ? id : formId ?? 0, ignored: false);
@@ -1,280 +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.RoomKeys;
[ApiController]
[Authorize]
public class RoomKeysController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// Boot-sequence call — the 2020.12 client hits this with roomId=-1
// before the user has authenticated. Class-level [Authorize] would
// 401 and the client treats that as fatal, kicking back to LogoutScene.
[AllowAnonymous]
[HttpGet("api/roomkeys/v1/room")]
public async Task<IActionResult> ForRoom([FromQuery] long roomId)
{
if (roomId <= 0) return Ok(Array.Empty<object>());
var rows = await db.RoomKeys
.Where(k => k.RoomId == roomId && !k.IsDeleted)
.OrderBy(k => k.Price)
.ThenBy(k => k.Name)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/roomkeys/v1/mine")]
public async Task<IActionResult> Mine()
{
var pid = Me;
var rows = await db.RoomKeys
.Where(k => k.CreatorPlayerId == pid && !k.IsDeleted)
.OrderByDescending(k => k.UpdatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/roomkeys/v1/create")]
public async Task<IActionResult> Create()
{
var req = await ReadBodyAsync<NewRoomKeyRequest>();
var status = ValidateText(req.Name, req.Description, req.Price);
if (status != RoomKeyStatus.Success)
return Ok(RoomKeyResponse(status, null));
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return Ok(RoomKeyResponse(RoomKeyStatus.RoomDoesNotExist, null));
if (!await CanManageRoomKeysAsync(room, Me))
return Ok(RoomKeyResponse(RoomKeyStatus.PermissionDenied, null));
var activeCount = await db.RoomKeys.CountAsync(k => k.RoomId == room.Id && !k.IsDeleted);
if (activeCount >= 100)
return Ok(RoomKeyResponse(RoomKeyStatus.RoomKeyLimitReached, null));
var duplicate = await db.RoomKeys.AnyAsync(k =>
k.RoomId == room.Id && !k.IsDeleted && k.Name == req.Name!.Trim());
if (duplicate) return Ok(RoomKeyResponse(RoomKeyStatus.DuplicateName, null));
var key = new RoomKeyEntity
{
RoomId = room.Id,
CreatorPlayerId = Me,
Name = req.Name!.Trim(),
Description = req.Description!.Trim(),
Price = req.Price,
ReplicationId = Guid.NewGuid().ToString("D"),
};
db.RoomKeys.Add(key);
await db.SaveChangesAsync();
return Ok(RoomKeyResponse(RoomKeyStatus.Success, key));
}
[HttpPost("api/roomkeys/v1/update")]
public async Task<IActionResult> Update()
{
var req = await ReadBodyAsync<UpdateRoomKeyRequest>();
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == req.RoomKeyId && !k.IsDeleted);
if (key is null) return Ok(RoomKeyResponse(RoomKeyStatus.DoesNotExist, null));
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == key.RoomId);
if (room is null) return Ok(RoomKeyResponse(RoomKeyStatus.RoomDoesNotExist, null));
if (!await CanManageRoomKeysAsync(room, Me))
return Ok(RoomKeyResponse(RoomKeyStatus.PermissionDenied, key));
var nextName = req.Name?.Trim() ?? key.Name;
var nextDescription = req.Description?.Trim() ?? key.Description;
var nextPrice = req.Price ?? key.Price;
var status = ValidateText(nextName, nextDescription, nextPrice);
if (status != RoomKeyStatus.Success)
return Ok(RoomKeyResponse(status, key));
var duplicate = await db.RoomKeys.AnyAsync(k =>
k.Id != key.Id && k.RoomId == key.RoomId && !k.IsDeleted && k.Name == nextName);
if (duplicate) return Ok(RoomKeyResponse(RoomKeyStatus.DuplicateName, key));
key.Name = nextName;
key.Description = nextDescription;
key.Price = nextPrice;
key.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(RoomKeyResponse(RoomKeyStatus.Success, key));
}
[HttpDelete("api/roomkeys/v1/delete/{roomKeyId:long}")]
[HttpPost("api/roomkeys/v1/delete/{roomKeyId:long}")]
public async Task<IActionResult> Delete(long roomKeyId)
{
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == roomKeyId && !k.IsDeleted);
if (key is null) return Content(((int)RoomKeyStatus.DoesNotExist).ToString(), "application/json");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == key.RoomId);
if (room is null) return Content(((int)RoomKeyStatus.RoomDoesNotExist).ToString(), "application/json");
if (!await CanManageRoomKeysAsync(room, Me))
return Content(((int)RoomKeyStatus.PermissionDenied).ToString(), "application/json");
key.IsDeleted = true;
key.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Content(((int)RoomKeyStatus.Success).ToString(), "application/json");
}
[HttpGet("api/roomkeys/v1/purchased/{roomKeyId:long}")]
public async Task<IActionResult> Purchased(long roomKeyId)
{
var pid = Me;
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
return Content(owns ? "true" : "false", "application/json");
}
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
{
Status = (int)status,
RoomKey = key is null ? null : ToWire(key),
};
public static object ToWire(RoomKeyEntity key) => new
{
RoomKeyId = key.Id,
ReplicationId = Guid.TryParse(key.ReplicationId, out var guid)
? guid
: Guid.Empty,
key.RoomId,
key.Name,
key.Description,
key.Price,
};
private static RoomKeyStatus ValidateText(string? name, string? description, int price)
{
if (string.IsNullOrWhiteSpace(name) || name.Trim().Length < 3) return RoomKeyStatus.NameTooShort;
if (name.Trim().Length > 40) return RoomKeyStatus.NameTooLong;
if (string.IsNullOrWhiteSpace(description) || description.Trim().Length < 10) return RoomKeyStatus.DescriptionTooShort;
if (description.Trim().Length > 174) return RoomKeyStatus.DescriptionTooLong;
if (price < 10) return RoomKeyStatus.PriceTooLow;
if (price > 10000) return RoomKeyStatus.PriceTooHigh;
return RoomKeyStatus.Success;
}
private async Task<bool> CanManageRoomKeysAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
var isCoOwner = await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted);
if (isCoOwner) return true;
return await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
}
private async Task<T> ReadBodyAsync<T>() where T : new()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (typeof(T) == typeof(NewRoomKeyRequest))
{
object req = new NewRoomKeyRequest
{
RoomId = long.TryParse(form["roomId"].FirstOrDefault()
?? form["RoomId"].FirstOrDefault(), out var roomId)
? roomId
: 0,
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
Price = int.TryParse(form["price"].FirstOrDefault()
?? form["Price"].FirstOrDefault(), out var price)
? price
: 0,
};
return (T)req;
}
if (typeof(T) == typeof(UpdateRoomKeyRequest))
{
object req = new UpdateRoomKeyRequest
{
RoomKeyId = long.TryParse(form["roomKeyId"].FirstOrDefault()
?? form["RoomKeyId"].FirstOrDefault(), out var roomKeyId)
? roomKeyId
: 0,
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
Price = int.TryParse(form["price"].FirstOrDefault()
?? form["Price"].FirstOrDefault(), out var price)
? price
: null,
};
return (T)req;
}
return new T();
}
try
{
return await JsonSerializer.DeserializeAsync<T>(Request.Body, JsonOptions) ?? new T();
}
catch (JsonException)
{
return new T();
}
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public sealed class NewRoomKeyRequest
{
[JsonPropertyName("roomId")] public long RoomId { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("price")] public int Price { get; set; }
}
public sealed class UpdateRoomKeyRequest
{
[JsonPropertyName("roomKeyId")] public long RoomKeyId { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("price")] public int? Price { get; set; }
}
}
public enum RoomKeyStatus
{
Success = 0,
InvalidParameters = 1,
DoesNotExist = 2,
NameTooShort = 3,
NameTooLong = 4,
DuplicateName = 5,
InappropriateName = 6,
DescriptionTooShort = 7,
DescriptionTooLong = 8,
InappropriateDescription = 9,
PriceTooLow = 10,
PriceTooHigh = 11,
PermissionDenied = 12,
PlayerHasRoomUnderModerationReview = 13,
JuniorStatusFail = 14,
PlayerIsNotCoOwner = 15,
RoomKeyLimitReached = 16,
PlayerAlreadyOwns = 17,
RoomUnderModerationReview = 18,
PurchaseFailed = 19,
RoomDoesNotExist = 20,
PaidKeyPurchasingDisabled = 21,
CreateOrModifyKeysDisabled = 22,
RoomKeyUnderModerationReview = 23,
PlayerRestrictedFromP2PSelling = 24,
PlayerNotRecRoomPlusMember = 25,
}
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Models.Notification; using DorkNet.Models.Notification;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.Match; using DorkNet.Server.Controllers.Match;
@@ -19,7 +18,7 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2;
/// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms, /// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms,
/// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent, /// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent,
/// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id}, /// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id},
/// roomserver/rooms/{id}, v1/personaldetails/{id}, v2/personaldetails/{id}. /// v1/personaldetails/{id}, v2/personaldetails/{id}.
/// ///
/// All endpoints serialize Rooms via RoomService.ToWireRoom which matches /// All endpoints serialize Rooms via RoomService.ToWireRoom which matches
/// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required /// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required
@@ -28,13 +27,11 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2;
[ApiController] [ApiController]
public class RoomsController( public class RoomsController(
RoomService rooms, RoomService rooms,
PlaylistService playlists,
DorkNetDbContext db, DorkNetDbContext db,
PlayerPresenceService presence, PlayerPresenceService presence,
OnlinePresenceService onlinePresence, OnlinePresenceService onlinePresence,
DomainConfig domain, DomainConfig domain,
NotificationService notifications, NotificationService notifications,
ServerSettingsService serverSettings,
ILogger<RoomsController> logger) : ControllerBase ILogger<RoomsController> logger) : ControllerBase
{ {
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this); private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
@@ -48,71 +45,16 @@ public class RoomsController(
/// TagsCsv column (e.g. `#community`, `#recroomoriginal`). /// TagsCsv column (e.g. `#community`, `#recroomoriginal`).
/// </summary> /// </summary>
[HttpGet("api/rooms/v1/hot")] [HttpGet("api/rooms/v1/hot")]
[HttpGet("rooms/hot")]
public async Task<IActionResult> Hot( public async Task<IActionResult> Hot(
[FromQuery] string? roomScoreType, [FromQuery] string? roomScoreType,
[FromQuery] string? tags) [FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList()); => Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// GET <c>/roomserver/rooms/recommendations</c> — the home-tab
/// "Recommended" carousel. <b>Wire shape is NOT a flat
/// <c>List&lt;Room&gt;</c></b>; the watch's
/// <c>EJDCNGBEICB.JDKLBNBIEAH</c> at <c>EJDCNGBEICB.txt:1349,1430</c>
/// returns <c>IPromise&lt;List&lt;ANNKHNFLMNP&gt;&gt;</c> and
/// <see cref="ANNKHNFLMNP.PPGFHEDFBEA"/> at
/// <c>ANNKHNFLMNP.txt:43-97</c> reads <c>SeedRoom</c> (sub-object,
/// <c>KLCOGEIGEBJ</c> i.e. full Room) and <c>Rooms</c> (list of
/// Rooms) as required keys. Returning <c>[{room}, …]</c> raised
/// <c>KeyNotFoundException: Failed to find key 'Rooms' when
/// deserializing object of type KLCOGEIGEBJ</c>.
///
/// We group the hot list into one "card" per room — that gives the
/// watch one row per featured room with that room as its own
/// SeedRoom and a single-element Rooms list. Cheap, semantically
/// correct, and avoids the deserializer-crashing empty-Rooms
/// edge case the watch hits when a group has no rooms.
/// </summary>
[HttpGet("roomserver/rooms/recommendations")]
[HttpGet("rooms/recommendations")]
public async Task<IActionResult> Recommendations(
[FromQuery(Name = "splitTestId")] int? splitTestId,
[FromQuery(Name = "splitTestValue")] int? splitTestValue)
{
var hot = await rooms.HotAsync(null, take: 12);
if (hot.Count == 0) return Ok(Array.Empty<object>());
var roomIds = hot.Select(r => r.Id).ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
var wireRooms = hot
.Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
// One ANNKHNFLMNP entry per room: SeedRoom = the room itself,
// Rooms = [that room]. The watch enumerates the outer list to
// build the row list; each row shows its SeedRoom as headline.
var result = wireRooms.Select(room => new Dictionary<string, object>(StringComparer.Ordinal)
{
["SeedRoom"] = room,
["Rooms"] = new List<object> { room },
}).ToList();
return Ok(result);
}
/// <summary> /// <summary>
/// `Rooms.SearchForRooms(query)` — free-text room search. /// `Rooms.SearchForRooms(query)` — free-text room search.
/// </summary> /// </summary>
[HttpGet("api/rooms/v1/search")] [HttpGet("api/rooms/v1/search")]
[HttpGet("api/rooms/v2/search")] [HttpGet("api/rooms/v2/search")]
[HttpGet("rooms/search")]
public async Task<IActionResult> Search( public async Task<IActionResult> Search(
[FromQuery(Name = "query")] string? query, [FromQuery(Name = "query")] string? query,
[FromQuery(Name = "value")] string? value) [FromQuery(Name = "value")] string? value)
@@ -133,7 +75,6 @@ public class RoomsController(
/// </summary> /// </summary>
[HttpGet("api/rooms/v2/baserooms")] [HttpGet("api/rooms/v2/baserooms")]
[HttpGet("api/rooms/v3/baserooms")] [HttpGet("api/rooms/v3/baserooms")]
[HttpGet("rooms/base")]
public async Task<IActionResult> BaseRooms() public async Task<IActionResult> BaseRooms()
=> Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList()); => Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList());
@@ -145,30 +86,23 @@ public class RoomsController(
// '#' at render time, so sending "#community" produces "##community" in // '#' at render time, so sending "#community" produces "##community" in
// the UI. Names stored in the DB also drop the prefix. // the UI. Names stored in the DB also drop the prefix.
[HttpGet("api/rooms/v1/filters")] [HttpGet("api/rooms/v1/filters")]
public async Task<IActionResult> Filters() public IActionResult Filters() => Ok(new
{ {
var tags = await serverSettings.GetPlayMenuTagsAsync(); PinnedFilters = new[] { "community", "recroomoriginal", "featured", "quest" },
return Ok(new PopularFilters = new[] { "paintball", "dodgeball", "soccer", "lasertag", "discgolf" },
{ });
PinnedFilters = tags.PinnedTags,
PopularFilters = tags.PopularTags,
});
}
[HttpGet("api/rooms/v1/tags")] [HttpGet("api/rooms/v1/tags")]
public async Task<IActionResult> Tags() public IActionResult Tags() => Ok(new[] {
{ "community", "recroomoriginal", "featured", "quest",
var tags = await serverSettings.GetPlayMenuTagsAsync(); "paintball", "dodgeball", "soccer", "lasertag", "discgolf",
return Ok(tags.PinnedTags.Concat(tags.PopularTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()); });
}
[HttpGet("api/rooms/v1/pinnedtags")] [HttpGet("api/rooms/v1/pinnedtags")]
public async Task<IActionResult> PinnedTags() public IActionResult PinnedTags() => Ok(new[] { "community", "recroomoriginal", "featured" });
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PinnedTags);
[HttpGet("api/rooms/v1/populartags")] [HttpGet("api/rooms/v1/populartags")]
public async Task<IActionResult> PopularTags() public IActionResult PopularTags() => Ok(new[] { "paintball", "dodgeball", "soccer" });
=> Ok((await serverSettings.GetPlayMenuTagsAsync()).PopularTags);
// ── Single room lookups ────────────────────────────────────────────── // ── Single room lookups ──────────────────────────────────────────────
@@ -191,7 +125,6 @@ public class RoomsController(
/// </summary> /// </summary>
[HttpGet("api/rooms/v2/{roomId:long}")] [HttpGet("api/rooms/v2/{roomId:long}")]
[HttpGet("api/rooms/v3/{roomId:long}")] [HttpGet("api/rooms/v3/{roomId:long}")]
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> ById(long roomId) public async Task<IActionResult> ById(long roomId)
{ {
var r = await rooms.GetByIdAsync(roomId); var r = await rooms.GetByIdAsync(roomId);
@@ -219,144 +152,50 @@ public class RoomsController(
// Rooms.LocalRoom is populated and the coroutine just needs a // Rooms.LocalRoom is populated and the coroutine just needs a
// populated Scenes[0] to get a non-null LocalRoomScene. // populated Scenes[0] to get a non-null LocalRoomScene.
/// <summary>GET <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/datahistory</c> /// <summary>GET api/rooms/v1/datahistory/{roomId} — returns the
/// — drives the watch's "Restore to old version" UI in the room- /// version history for a room as a list of saves. Drives the
/// settings tab. Per-subroom (not per-room) — the watch builds the /// watch's "Restore to old version" UI in the room-settings tab.
/// URL from the template
/// <c>String.Format("rooms/{0}/subrooms/{1}/datahistory", roomId, subRoomId)</c>
/// (verified at <c>EJDCNGBEICB.txt:1190</c>); the <c>roomserver/</c>
/// prefix is added by RecNet's URL builder for everything on the
/// rooms host (api.localhost proxies it through the same prefix as the
/// rest of the rooms API — see all the other <c>roomserver/rooms/...</c>
/// routes in this controller).
/// ///
/// <para><b>SubRoomId semantics:</b> the watch's 2020.12 build treats /// The watch sometimes calls this with <c>roomId=0</c>, which we
/// "SubRoomId" as the <see cref="RoomSceneEntity.OrderIndex"/>, NOT /// interpret as "the caller's currently-presence-tracked room"
/// rec.net's long random Guid-like SubRoomId. The heartbeat reports /// (typically their dorm). Anything else is a literal room id.
/// <c>subRoom=11</c> for the 12th scene in <c>RoomDetails.SubRooms[]</c>;
/// this endpoint must filter by OrderIndex to match. The zip-importer
/// stamps each <c>SubRooms/&lt;scene&gt;/History/</c> blob's
/// <see cref="RoomDataBlobEntity.SubRoomId"/> with the scene's
/// OrderIndex at insert time — the rec.net SubRoomId is captured in
/// the sidecar JSON for provenance but isn't what gets indexed.</para>
/// ///
/// <para>Wire shape: <c>List&lt;RoomDataHistoryDTO&gt;</c>. The /// Wire shape: <c>List&lt;RoomDataHistoryDTO&gt;</c>. Deserializer
/// watch's <c>DHOCPFIOKHD.PPGFHEDFBEA</c> deserializer reads EXACTLY /// reads EXACTLY three PascalCase keys (verified at
/// four PascalCase keys (<c>DHOCPFIOKHD.txt:136-150</c>): /// <c>Cpp2IL_ISIL/.../RecNet/Rooms_NestedType_RoomDataHistoryDTO.txt</c>):
/// <c>SubRoomId</c> (long, strict GetKey), <c>DataBlob</c> (string), /// <c>RoomDataHistoryId</c> (long), <c>DataBlobName</c> (string),
/// <c>SavedByAccountId</c> (long, strict GetKey), <c>CreatedAt</c> /// <c>CreatedAt</c> (DateTime). Extra/wrong keys aren't ignored —
/// (DateTime, GetKeyOrDefault). Missing strict keys throw /// missing required keys throw <c>KeyNotFoundException</c> in
/// <c>KeyNotFoundException</c> in LitJson and the watch shows the /// LitJson and the watch shows an empty history list. Don't add
/// "Could not retrieve save history from server :-(" toast from /// helper fields here; pipe them through a separate admin endpoint
/// <c>UIRestoreRoomDialog.txt:1970</c>. Extra keys are ignored.</para> /// if needed.</summary>
/// [HttpGet("api/rooms/v1/datahistory/{roomId:long}")]
/// <para>Pre-2026-05 rows (and any rows the live save flow inserts, [HttpGet("api/rooms/v2/datahistory/{roomId:long}")]
/// which doesn't populate SubRoomId yet) have a null SubRoomId and public async Task<IActionResult> DataHistory(long roomId)
/// surface under every sub-room's history list — back-compat.</para></summary>
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
{ {
// 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 var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId .Where(b => b.RoomId == roomId)
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
// Skip the canonical per-scene blob from the import — the watch
// is asking for HISTORY, and serving the current `room_<id>_<slug>_<scene>.room`
// back to it as an option would make `restore to current` a
// confusing no-op entry in the list.
.Where(b => !b.BlobName.StartsWith($"room_{roomId}_"))
.OrderByDescending(b => b.UploadedAt) .OrderByDescending(b => b.UploadedAt)
.Take(50) .Take(50)
.Select(b => new .Select(b => new
{ {
SubRoomId = subRoomId, RoomDataHistoryId = b.Id,
DataBlob = b.BlobName, DataBlobName = b.BlobName,
SavedByAccountId = b.UploadedByPlayerId,
CreatedAt = b.UploadedAt, CreatedAt = b.UploadedAt,
}) })
.ToListAsync(); .ToListAsync();
return Ok(blobs); return Ok(blobs);
} }
/// <summary>POST <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/restoredata</c>
/// — the watch's "Go" button in the Restore Room dialog. Body is
/// form-urlencoded with a single <c>filename</c> field carrying the
/// historical blob name from the datahistory list. <paramref name="subRoomId"/>
/// is RoomSceneEntity.OrderIndex (same convention as the GET above).
///
/// Effect: stamp the chosen scene's DataBlobName to the historical
/// blob and, when restoring the room's current entry scene, also
/// update the room-level CurrentDataBlobName so a fresh /v4/details
/// returns the restored bytes too. Then push SubscriptionUpdateRoom
/// so any clients already cached on the room reload the scene.
///
/// Returns CreateModifyRoomSceneResponse — same shape as
/// /api/rooms/v1/datahistory/restore — so the watch's
/// ProcessCreateModifyRoomSceneResponse chain can deserialise and
/// flip the Restore dialog out of its "applying" state.</summary>
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")]
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")]
[Authorize]
public async Task<IActionResult> SubRoomRestoreData(
long roomId, long subRoomId, [FromForm] string? filename)
{
if (string.IsNullOrWhiteSpace(filename))
return BadRequest(new { error = "missing_filename" });
var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
if (room.CreatorPlayerId != pid) return Forbid();
var blob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
if (blob is null) return NotFound(new { error = "blob_not_found", filename });
var sceneRows = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var sceneRow = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId);
if (sceneRow is null) return NotFound(new { error = "subroom_not_found", subRoomId });
var restoredAt = DateTime.UtcNow;
sceneRow.DataBlobName = filename;
sceneRow.DataModifiedAt = restoredAt;
// Only stamp the room-level CurrentDataBlobName when restoring the
// entry scene (OrderIndex 0). Restoring a non-entry sub-room
// shouldn't rewrite what the room loads at the front door.
if (sceneRow.OrderIndex == 0)
{
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, filename)
.SetProperty(r => r.UpdatedAt, restoredAt));
room.CurrentDataBlobName = filename;
room.UpdatedAt = restoredAt;
}
await db.SaveChangesAsync();
var pushRoom = room.IsDormRoom
? CloneWithCreator(room, pid, room.CurrentDataBlobName)
: CloneWithCreator(room, room.CreatorPlayerId, room.CurrentDataBlobName);
var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRows, sceneRow.OrderIndex, filename);
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
logger.LogInformation(
"[rooms-restore] player={PlayerId} room={RoomId} subRoom={SubRoomId} scene={Scene} blob={Blob}",
pid, roomId, subRoomId, sceneRow.Name, filename);
// Watch's response deserializer is RecNetResult<PPENFJMFPNE> —
// the same legacy success/value/error envelope used by
// roomserver/rooms/{id}/subrooms/{id}/data. `value` must be the
// flattened /roomserver/rooms/{id} DTO, not the modern
// { Room, Scenes } details payload pushed over SubscriptionUpdateRoom.
var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRows, filename);
return Ok(new { success = true, value = roomServerDetails, error = string.Empty });
}
/// <summary>POST api/rooms/v4/saveData — the watch's /// <summary>POST api/rooms/v4/saveData — the watch's
/// <c>RecNet.Rooms.UploadLocalRoomSceneData</c> save flow. /// <c>RecNet.Rooms.UploadLocalRoomSceneData</c> save flow.
/// Body shape mirrors <c>RecNet.Rooms+SaveRoomSceneRequest</c> /// Body shape mirrors <c>RecNet.Rooms+SaveRoomSceneRequest</c>
@@ -374,12 +213,6 @@ public class RoomsController(
[HttpPost("api/rooms/v4/saveData")] [HttpPost("api/rooms/v4/saveData")]
[Authorize] [Authorize]
public async Task<IActionResult> SaveData([FromBody] SaveRoomSceneRequest body) public async Task<IActionResult> SaveData([FromBody] SaveRoomSceneRequest body)
=> await SaveDataCore(body);
private async Task<IActionResult> SaveDataCore(
SaveRoomSceneRequest body,
long? routeRoomId = null,
bool wrapCreateModifyResponse = false)
{ {
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
@@ -390,13 +223,13 @@ public class RoomsController(
// on every /goto). This is the only signal we have without a // on every /goto). This is the only signal we have without a
// RoomId on the request body. // RoomId on the request body.
var current = presence.GetRoom(pid); var current = presence.GetRoom(pid);
if (current is null && routeRoomId is null) if (current is null)
{ {
// No active room — without it we can't pick a target row. // No active room — without it we can't pick a target row.
// Return a degenerate RoomScene so the deserializer doesn't // Return a degenerate RoomScene so the deserializer doesn't
// throw, but flag CanMatchmakeInto=false so the watch // throw, but flag CanMatchmakeInto=false so the watch
// doesn't think the save succeeded. // doesn't think the save succeeded.
var missingPresenceScene = new return Ok(new
{ {
RoomSceneId = body.RoomSceneId, RoomSceneId = body.RoomSceneId,
RoomId = 0L, RoomId = 0L,
@@ -407,25 +240,14 @@ public class RoomsController(
MaxPlayers = 8, MaxPlayers = 8,
CanMatchmakeInto = false, CanMatchmakeInto = false,
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"), DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
}; });
return Ok(wrapCreateModifyResponse
? new { success = true, value = missingPresenceScene, error = string.Empty }
: missingPresenceScene);
} }
var roomId = routeRoomId ?? current!.RoomId; var roomId = current.RoomId;
var room = await rooms.GetByIdAsync(roomId); var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" }); if (room is null) return NotFound(new { error = "room_not_found" });
var newBlob = body.EffectiveRoomDataFilename.Trim(); var newBlob = body.RoomDataFilename?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(newBlob))
{
logger.LogWarning(
"[rooms-save] missing blob filename player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} routeRoom={RouteRoomId}",
pid, roomId, body.RoomSceneId, routeRoomId);
return BadRequest(new { error = "missing_room_data_filename" });
}
var savedAt = DateTime.UtcNow;
// Multi-scene rooms have a RoomScenes row per scene. The 2020 // Multi-scene rooms have a RoomScenes row per scene. The 2020
// client usually sends the scene's OrderIndex, but some flows // client usually sends the scene's OrderIndex, but some flows
@@ -455,33 +277,27 @@ public class RoomsController(
db.DormStates.Add(dormState); db.DormStates.Add(dormState);
} }
dormState.CurrentDataBlobName = newBlob; dormState.CurrentDataBlobName = newBlob;
dormState.UpdatedAt = savedAt; dormState.UpdatedAt = DateTime.UtcNow;
} }
else else
{ {
var canSave = room.CreatorPlayerId == pid // Owned rooms: only the creator can save. Admin override
|| await db.Players.AnyAsync(p => p.Id == pid && p.IsAdmin) // intentionally not included — admins should use /restore
|| await db.RoomRoles.AnyAsync(r => // when they need to roll a room back.
r.RoomId == room.Id && if (room.CreatorPlayerId != pid) return Forbid();
r.PlayerId == pid &&
r.Accepted &&
r.Role == 0);
if (!canSave) return Forbid();
} }
if (sceneRow is not null) if (sceneRow is not null)
{ {
sceneRow.DataBlobName = newBlob; sceneRow.DataBlobName = newBlob;
sceneRow.DataModifiedAt = savedAt; sceneRow.DataModifiedAt = DateTime.UtcNow;
} }
await db.Rooms await db.Rooms
.Where(r => r.Id == roomId) .Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, newBlob) .SetProperty(r => r.CurrentDataBlobName, newBlob)
.SetProperty(r => r.UpdatedAt, savedAt)); .SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
await db.SaveChangesAsync(); await db.SaveChangesAsync();
room.CurrentDataBlobName = newBlob;
room.UpdatedAt = savedAt;
// The watch's MasterUploadRoomDataBlobCoroutine waits for BOTH // The watch's MasterUploadRoomDataBlobCoroutine waits for BOTH
// the /upload + /saveData responses AND a SubscriptionUpdateRoom // the /upload + /saveData responses AND a SubscriptionUpdateRoom
@@ -505,7 +321,7 @@ public class RoomsController(
if (sceneRow is not null && scene.Id == sceneRow.Id) if (sceneRow is not null && scene.Id == sceneRow.Id)
{ {
scene.DataBlobName = newBlob; scene.DataBlobName = newBlob;
scene.DataModifiedAt = savedAt; scene.DataModifiedAt = DateTime.UtcNow;
} }
} }
var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId; var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId;
@@ -513,48 +329,30 @@ public class RoomsController(
logger.LogInformation( logger.LogInformation(
"[rooms-save] saveData player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} savedScene={SavedSceneId} blob={BlobName} sceneRow={SceneRowFound} scenes={SceneCount}", "[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); pid, roomId, body.RoomSceneId, savedSceneId, newBlob, sceneRow is not null, sceneRowsForRoom.Count);
var activeInstanceId = current is not null && current.RoomId == roomId var playersInInstance = onlinePresence.OnlinePlayerIds()
? current.RoomInstanceId .Where(playerId =>
: 0L; {
var playersInInstance = activeInstanceId == 0 var playerRoom = presence.GetRoom(playerId);
? new[] { pid } return playerRoom is not null
: onlinePresence.OnlinePlayerIds() && playerRoom.RoomId == roomId
.Where(playerId => && playerRoom.RoomInstanceId == current.RoomInstanceId;
{ })
var playerRoom = presence.GetRoom(playerId); .Append(pid)
return playerRoom is not null .Distinct()
&& playerRoom.RoomId == roomId .ToArray();
&& playerRoom.RoomInstanceId == activeInstanceId;
})
.Append(pid)
.Distinct()
.ToArray();
logger.LogInformation( logger.LogInformation(
"[rooms-save] fanout room update player={PlayerId} room={RoomId} instance={InstanceId} blob={BlobName} recipients={RecipientCount}", "[rooms-save] fanout room update player={PlayerId} room={RoomId} instance={InstanceId} blob={BlobName} recipients={RecipientCount}",
pid, roomId, activeInstanceId, newBlob, playersInInstance.Length); pid, roomId, current.RoomInstanceId, newBlob, playersInInstance.Length);
if (activeInstanceId != 0)
{
var updatedPrivateInstances = await db.PrivateInstances
.Where(instance => instance.Id == activeInstanceId && instance.RoomId == roomId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(instance => instance.DataBlob, newBlob));
if (updatedPrivateInstances > 0)
{
logger.LogInformation(
"[rooms-save] updated private instance blob room={RoomId} instance={InstanceId} blob={BlobName}",
roomId, activeInstanceId, newBlob);
}
}
var updatedRoomInstances = new Dictionary<long, RoomInstanceDto>(); var updatedRoomInstances = new Dictionary<long, RoomInstanceDto>();
foreach (var playerId in playersInInstance) foreach (var playerId in playersInInstance)
{ {
var currentPresence = presence.GetRoom(playerId); var currentPresence = presence.GetRoom(playerId);
if (currentPresence is null && playerId == pid && current is not null) if (currentPresence is null && playerId == pid)
{ {
currentPresence = current; currentPresence = current;
} }
if (currentPresence is null || currentPresence.RoomId != roomId) continue; if (currentPresence is null) continue;
currentPresence.DataBlob = newBlob; currentPresence.DataBlob = newBlob;
presence.SetRoom(playerId, currentPresence); presence.SetRoom(playerId, currentPresence);
@@ -596,31 +394,25 @@ public class RoomsController(
vrMovementMode = 0, vrMovementMode = 0,
roomInstance = new roomInstance = new
{ {
roomInstanceId = currentPresence.RoomInstanceId, roomInstanceId = currentPresence.RoomInstanceId,
roomId = currentPresence.RoomId, roomId = currentPresence.RoomId,
subRoomId = currentPresence.SubRoomId, subRoomId = currentPresence.SubRoomId,
roomInstanceType = currentPresence.RoomInstanceType, location = currentPresence.Location,
location = currentPresence.Location, photonRegionId = currentPresence.PhotonRegionId,
dataBlob = currentPresence.DataBlob, photonRoomId = currentPresence.PhotonRoomId,
eventId = currentPresence.EventId, maxCapacity = currentPresence.MaxCapacity,
clubId = currentPresence.ClubId, isFull = currentPresence.IsFull,
roomCode = currentPresence.RoomCode, isPrivate = currentPresence.IsPrivate,
photonRegionId = currentPresence.PhotonRegionId, isInProgress = currentPresence.IsInProgress,
photonRoomId = currentPresence.PhotonRoomId, dataBlob = currentPresence.DataBlob,
name = currentPresence.NameWire, eventId = currentPresence.EventId,
maxCapacity = currentPresence.MaxCapacity, name = currentPresence.Name,
isFull = currentPresence.IsFull,
isPrivate = currentPresence.IsPrivate,
isInProgress = currentPresence.IsInProgress,
EncryptVoiceChat = currentPresence.EncryptVoiceChat,
}, },
isOnline = true,
appVersion = 20201210,
}; };
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload); await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload);
} }
var savedScene = new return Ok(new
{ {
RoomSceneId = savedSceneId, RoomSceneId = savedSceneId,
RoomId = roomId, RoomId = roomId,
@@ -630,31 +422,17 @@ public class RoomsController(
DataBlobName = newBlob, DataBlobName = newBlob,
MaxPlayers = sceneRow?.MaxPlayers ?? 8, MaxPlayers = sceneRow?.MaxPlayers ?? 8,
CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true, CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true,
DataModifiedAt = savedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
}; });
if (wrapCreateModifyResponse)
{
var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob);
return Ok(new { success = true, value = roomServerDetails, error = string.Empty });
}
return Ok(savedScene);
} }
public class SaveRoomSceneRequest public class SaveRoomSceneRequest
{ {
public long RoomSceneId { get; set; } public long RoomSceneId { get; set; }
public string? RoomDataFilename { get; set; } public string? RoomDataFilename { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("filename")]
public string? Filename { get; set; }
public List<long>? InventionUsages { get; set; } public List<long>? InventionUsages { get; set; }
public string? InventionUsageBase64 { get; set; }
public CreatorActionContextDto? CreatorActionContext { get; set; } public CreatorActionContextDto? CreatorActionContext { get; set; }
public long RequestPlayerId { get; set; } public long RequestPlayerId { get; set; }
public int SaveRequestPlayerId { get; set; }
public string EffectiveRoomDataFilename =>
RoomDataFilename ?? Filename ?? string.Empty;
} }
public class CreatorActionContextDto public class CreatorActionContextDto
@@ -838,53 +616,8 @@ public class RoomsController(
return Ok(new { IsCheering = cheering, IsBookmarked = bookmarked }); return Ok(new { IsCheering = cheering, IsBookmarked = bookmarked });
} }
/// <summary>
/// GET <c>/roomserver/rooms/{id}/interactionby/me</c> — sibling of
/// personaldetails but DIFFERENT wire shape. Per
/// <c>EJDCNGBEICB.txt:8219,8266</c> the deserialiser is
/// <c>CJODCLDGFCF</c>, which reads <c>Cheered</c> + <c>Favorited</c>
/// (PascalCase, both bool, both required — verified at
/// <c>CJODCLDGFCF.txt:118,123</c>). Sharing the
/// <c>{IsCheering, IsBookmarked}</c> payload with personaldetails
/// throws <c>KeyNotFoundException</c> on the watch and stalls the
/// room-tile UI.
/// </summary>
[HttpGet("roomserver/rooms/{roomId:long}/interactionby/me")]
[HttpGet("rooms/{roomId:long}/interactionby/me")]
public async Task<IActionResult> RoomInteractionByMe(long roomId)
{
var pid = CurrentPlayerId ?? 0;
bool cheered = false, favorited = false;
if (pid > 0)
{
cheered = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid);
favorited = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid);
}
return Ok(new { Cheered = cheered, Favorited = favorited });
}
/// <summary>
/// GET <c>/roomserver/playlists/{id}/interactionby/me</c> — same
/// <c>CJODCLDGFCF</c> wire shape as the room variant. Per
/// <c>EJDCNGBEICB.txt:8600</c> the URL is
/// <c>"playlists/{0}/interactionby/me"</c>. Reads from
/// <see cref="PlaylistInteractionEntity"/>; an anonymous caller
/// (no JWT) reads as (false, false) so the watch's pre-login
/// browse renders cleanly.
/// </summary>
[HttpGet("roomserver/playlists/{playlistId:long}/interactionby/me")]
[HttpGet("playlists/{playlistId:long}/interactionby/me")]
public async Task<IActionResult> PlaylistInteractionByMe(long playlistId)
{
var pid = CurrentPlayerId ?? 0;
if (pid <= 0) return Ok(new { Cheered = false, Favorited = false });
var (cheered, favorited) = await playlists.InteractionForAsync(playlistId, pid);
return Ok(new { Cheered = cheered, Favorited = favorited });
}
[HttpGet("api/rooms/v4/details/{roomId:long}")] [HttpGet("api/rooms/v4/details/{roomId:long}")]
[HttpGet("api/rooms/v3/details/{roomId:long}")] [HttpGet("api/rooms/v3/details/{roomId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}")]
public async Task<IActionResult> Details(long roomId) public async Task<IActionResult> Details(long roomId)
{ {
var r = await rooms.GetByIdAsync(roomId); var r = await rooms.GetByIdAsync(roomId);
@@ -897,7 +630,12 @@ public class RoomsController(
// owner's DormStateEntity blob so they load the same room state. // owner's DormStateEntity blob so they load the same room state.
if (room.IsDormRoom) if (room.IsDormRoom)
{ {
room = await ShapeDormRoomAsync(room); 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 var scenes = await db.RoomScenes
@@ -907,125 +645,9 @@ public class RoomsController(
var roomRoles = await db.RoomRoles var roomRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id) .Where(r => r.RoomId == room.Id)
.ToListAsync(); .ToListAsync();
if (Request.Path.StartsWithSegments("/roomserver", StringComparison.OrdinalIgnoreCase))
return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles));
return Ok(BuildRoomDetails(room, scenes, roles: roomRoles)); return Ok(BuildRoomDetails(room, scenes, roles: roomRoles));
} }
[HttpGet("roomserver/rooms")]
[HttpGet("rooms")]
public async Task<IActionResult> RoomServerDetailsByName([FromQuery(Name = "name")] string? roomName)
{
if (string.IsNullOrWhiteSpace(roomName))
return Ok(Array.Empty<object>());
var pid = CurrentPlayerId ?? 0;
RoomEntity? room;
if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0)
{
room = await rooms.EnsurePersonalDormAsync(pid);
}
else
{
room = await rooms.GetByNameAsync(roomName);
}
room ??= Synthetic(roomName);
if (room.IsDormRoom)
{
// Stamp the wire Name with the requested name so the watch's
// name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) can find it.
room = await ShapeDormRoomAsync(room, DormNameOverride(roomName));
}
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(BuildRoomServerDetails(room, scenes, roles: roomRoles));
}
/// <summary>GET <c>/roomserver/rooms/bulk?name=X&amp;name=Y</c> or
/// <c>?id=1&amp;id=2</c> — bulk room cache lookup. Decomp-verified
/// in EJDCNGBEICB: the Int64 overload writes query key <c>id</c>
/// and the string overload writes <c>name</c>. Returns the same
/// per-room object that <c>/roomserver/rooms/{id}</c> emits;
/// unresolved entries are silently skipped (watch handles a shorter
/// list fine, but a 404 wedges the room browser).</summary>
[HttpGet("roomserver/rooms/bulk")]
[HttpGet("rooms/bulk")]
public async Task<IActionResult> RoomServerBulkByName()
{
var names = Request.Query["name"].Where(n => !string.IsNullOrWhiteSpace(n)).ToArray();
var ids = Request.Query["id"]
.Where(v => !string.IsNullOrWhiteSpace(v))
.SelectMany(v => v!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.ToArray();
if (names.Length == 0 && ids.Length == 0) return Ok(Array.Empty<object>());
var pid = CurrentPlayerId ?? 0;
var results = new List<object>(names.Length + ids.Length);
var emitted = new HashSet<long>();
foreach (var raw in names)
{
var name = raw!.Trim();
RoomEntity? room;
if (name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0)
room = await rooms.EnsurePersonalDormAsync(pid);
else
room = await rooms.GetByNameAsync(name);
room ??= Synthetic(name);
if (room.IsDormRoom)
{
// Stamp the wire Name with the requested name so the watch's
// name-keyed room cache (OJMCBOKJFOF.NHBPIIGDAJP) finds it.
room = await ShapeDormRoomAsync(room, DormNameOverride(name));
}
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();
results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles));
emitted.Add(room.Id);
}
foreach (var id in ids)
{
if (!emitted.Add(id)) continue;
var room = await rooms.GetByIdAsync(id);
if (room is null) continue;
if (room.IsDormRoom)
{
room = await ShapeDormRoomAsync(room);
}
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();
results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles));
}
return Ok(results);
}
/// <summary> /// <summary>
/// Shallow clone of a RoomEntity with a different CreatorPlayerId /// Shallow clone of a RoomEntity with a different CreatorPlayerId
/// and (optionally) a different CurrentDataBlobName. Used to give /// and (optionally) a different CurrentDataBlobName. Used to give
@@ -1033,40 +655,11 @@ public class RoomsController(
/// caller and pointing at the caller's own most recent save. /// caller and pointing at the caller's own most recent save.
/// Doesn't touch the DB — purely a response-shaping utility. /// Doesn't touch the DB — purely a response-shaping utility.
/// </summary> /// </summary>
/// <summary>When a dorm was resolved via the magic name "DormRoom",
/// returns that requested name (verbatim, so casing matches what the
/// watch will look the cache up by) to stamp onto the wire room.
/// Returns null for any other request name so the dorm keeps its real
/// "Dorm_{playerId}" name in by-id / direct-name responses.</summary>
private static string? DormNameOverride(string requestedName) =>
requestedName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase)
? requestedName
: null;
private async Task<RoomEntity> ShapeDormRoomAsync(RoomEntity room, string? overrideName = null)
{
var dormBlobName = await rooms.ResolveDormDataBlobNameAsync(room.CreatorPlayerId, room.Id);
var dormName = overrideName ?? await rooms.BuildPersonalDormDisplayNameAsync(room.CreatorPlayerId);
return CloneWithCreator(room, room.CreatorPlayerId, dormBlobName, dormName);
}
public static RoomEntity CloneWithCreator( public static RoomEntity CloneWithCreator(
RoomEntity src, long creatorId, string? overrideBlobName = null, RoomEntity src, long creatorId, string? overrideBlobName = null) => new()
string? overrideName = null) => new()
{ {
Id = src.Id, Id = src.Id,
// overrideName lets the by-name resolvers stamp the wire Name with Name = src.Name,
// the name the client actually requested. The personal-dorm entity
// is named "Dorm_{playerId}", but the watch resolves it via the
// magic name "DormRoom" (HomeScreenFlow.Button_DormRoom →
// RunJoinRoom("DormRoom") → OJMCBOKJFOF.NHBPIIGDAJP("DormRoom")).
// That call caches the response in a Dictionary<string,Room> and
// looks it back up by the REQUESTED string ("DormRoom"); if the
// wire Name is "Dorm_{id}" the lookup misses → the promise rejects
// with "No such room" → the "contact recroom.happyfox.com" toast.
// Stamping Name="DormRoom" here makes the lookup hit, the same way
// RecCenter (whose entity Name IS "RecCenter") already works.
Name = overrideName ?? src.Name,
Description = src.Description, Description = src.Description,
CreatorPlayerId = creatorId, CreatorPlayerId = creatorId,
ImageName = src.ImageName, ImageName = src.ImageName,
@@ -1150,13 +743,11 @@ public class RoomsController(
// no DeserializeRoomDataBlobCoroutine, the master init // no DeserializeRoomDataBlobCoroutine, the master init
// proceeds with default-zero permissions. // proceeds with default-zero permissions.
// //
// • Saved dorm / customisable rooms: DataBlobName is the // • Dorm / customisable rooms: DataBlobName is either the
// latest uploaded blob name (RoomEntity.CurrentDataBlobName, // latest uploaded blob name (RoomEntity.CurrentDataBlobName,
// written by StorageController on /upload). Unsaved dorms // written by StorageController on /upload) or, before any
// deliberately use "" so the baked DormRoom scene loads // save, "room_<id>_v1.dat" — which the catch-all serves
// without deserializing the synthetic all-perms blob. The // from RoomDataBlobService (the all-perms default blob).
// role-only default blob made December wait forever on
// StandaloneShapeContainer big-data during first-dorm entry.
// • AG-Original rooms: DataBlobName="" → completed-promise // • AG-Original rooms: DataBlobName="" → completed-promise
// short-circuit → no download → no leaked stale-blob // short-circuit → no download → no leaked stale-blob
// behaviour → master flow proceeds → spawn fires. Maker // behaviour → master flow proceeds → spawn fires. Maker
@@ -1169,12 +760,9 @@ public class RoomsController(
// the all-perms default blob until their first save replaces // the all-perms default blob until their first save replaces
// it via StorageController; the rec center / paintball / etc. // it via StorageController; the rec center / paintball / etc.
// serve "" so the persistence flow short-circuits. // serve "" so the persistence flow short-circuits.
var roleList = roles ?? Array.Empty<RoomRoleEntity>(); var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName) var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName ? room.CurrentDataBlobName
: room.IsDormRoom ? ""
: customisable ? $"room_{room.Id}_v1.dat" : ""; : customisable ? $"room_{room.Id}_v1.dat" : "";
// Multi-scene rooms (imported via tools/import-room.ps1) carry one // Multi-scene rooms (imported via tools/import-room.ps1) carry one
@@ -1197,7 +785,6 @@ public class RoomsController(
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) && DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1) (overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
? overrideDataBlobName ? overrideDataBlobName
: room.IsDormRoom ? dataBlobName
: s.DataBlobName, : s.DataBlobName,
MaxPlayers = s.MaxPlayers, MaxPlayers = s.MaxPlayers,
CanMatchmakeInto = s.CanMatchmakeInto, CanMatchmakeInto = s.CanMatchmakeInto,
@@ -1218,7 +805,7 @@ public class RoomsController(
Name = "Home", Name = "Home",
IsSandbox = false, IsSandbox = false,
DataBlobName = dataBlobName, DataBlobName = dataBlobName,
MaxPlayers = room.MaxCapacity, MaxPlayers = 8,
CanMatchmakeInto = true, CanMatchmakeInto = true,
// Use the room's actual UpdatedAt so the watch's // Use the room's actual UpdatedAt so the watch's
// "is my local cache stale?" check sees an accurate // "is my local cache stale?" check sees an accurate
@@ -1247,6 +834,7 @@ public class RoomsController(
// Additional grants come from RoomRoleEntity rows when supplied; // Additional grants come from RoomRoleEntity rows when supplied;
// callers without role data (clone/myrooms/etc.) ship just the // callers without role data (clone/myrooms/etc.) ship just the
// implicit creator. // implicit creator.
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
int[] PlayersIn(int role, bool accepted) => roleList int[] PlayersIn(int role, bool accepted) => roleList
.Where(r => r.Role == role && r.Accepted == accepted) .Where(r => r.Role == role && r.Accepted == accepted)
.Select(r => (int)r.PlayerId) .Select(r => (int)r.PlayerId)
@@ -1276,132 +864,6 @@ public class RoomsController(
}; };
} }
/// <summary>
/// 2020.12 <c>/roomserver/rooms/{id}?include=301</c> shape. The client
/// deserializes this directly as PPENFJMFPNE, not as the older
/// { Room, Scenes, ... } wrapper.
/// </summary>
public static object BuildRoomServerDetails(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
string? overrideDataBlobName = null,
IReadOnlyList<RoomRoleEntity>? roles = null)
{
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: room.IsDormRoom ? ""
: customisable ? $"room_{room.Id}_v1.dat" : "";
var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ");
object[] subRooms = sceneRows is { Count: > 0 }
? sceneRows.Select(s => (object)new
{
SubRoomId = (long)s.OrderIndex,
RoomId = room.Id,
Name = s.Name,
DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
? overrideDataBlobName
: room.IsDormRoom ? dataBlobName
: s.DataBlobName,
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
IsSandbox = s.IsSandbox,
MaxPlayers = s.MaxPlayers,
Accessibility = room.Accessibility,
UnitySceneId = s.RoomSceneLocationId,
}).ToArray()
: new object[]
{
new
{
SubRoomId = 0L,
RoomId = room.Id,
Name = "Home",
DataBlob = dataBlobName,
DataSavedAt = updatedAt,
IsSandbox = false,
MaxPlayers = 8,
Accessibility = room.Accessibility,
UnitySceneId = room.LocationReplicationId,
},
};
static int WireRole(int role) => role switch
{
0 => 30, // CoOwner
1 => 20, // Moderator
2 => 10, // Host
_ => 0,
};
var wireRoles = new List<object>
{
new { AccountId = room.CreatorPlayerId, Role = 255, InvitedRole = 0 },
};
wireRoles.AddRange(roleList.Select(r =>
{
var role = WireRole(r.Role);
return (object)new
{
AccountId = r.PlayerId,
Role = r.Accepted ? role : 0,
InvitedRole = r.Accepted ? 0 : role,
};
}));
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();
return new
{
RoomId = room.Id,
Name = room.Name,
Description = room.Description,
ImageName = room.ImageName,
WarningMask = room.RoomWarningMask,
CustomWarning = room.CustomRoomWarning,
CreatorAccountId = room.CreatorPlayerId,
State = room.State,
Accessibility = room.Accessibility,
SupportsLevelVoting = room.SupportsLevelVoting,
IsRRO = room.IsAGRoom,
SupportsScreens = room.SupportsScreens,
SupportsWalkVR = room.SupportsWalkVR,
SupportsTeleportVR = room.SupportsTeleportVR,
SupportsVRLow = room.SupportsVRLow,
SupportsQuest2 = true,
SupportsMobile = room.SupportsMobile,
SupportsJuniors = room.AllowsJuniors,
CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
Stats = new
{
CheerCount = room.CheerCount,
FavoriteCount = room.FavoriteCount,
VisitorCount = room.VisitorCount,
VisitCount = room.VisitCount,
},
IsDorm = room.IsDormRoom,
CloningAllowed = room.CloningAllowed,
DisableMicAutoMute = room.DisableMicAutoMute,
DisableRoomComments = false,
EncryptVoiceChat = false,
SubRooms = subRooms,
Roles = wireRoles,
LoadScreens = Array.Empty<object>(),
PromoImages = Array.Empty<string>(),
PromoExternalContent = Array.Empty<object>(),
Tags = tags,
};
}
// ── Clone / create ─────────────────────────────────────────────────── // ── Clone / create ───────────────────────────────────────────────────
@@ -1441,19 +903,7 @@ public class RoomsController(
// ── My-Rooms tabs ──────────────────────────────────────────────────── // ── My-Rooms tabs ────────────────────────────────────────────────────
// 2020.12 OJMCBOKJFOF populates its local rooms cache from these
// URLs at boot (verified via metadata strings: "rooms/createdby/me",
// "rooms/moderatedby/me", "rooms/visitedby/me"). The 2020.03 client
// used /api/rooms/v2/myrooms, while 2020.12 moved the calls under
// /roomserver/rooms/*. Keep both route families on the same handlers.
[HttpGet("api/rooms/v2/myrooms")] [HttpGet("api/rooms/v2/myrooms")]
[HttpGet("api/rooms/v1/createdby/me")]
[HttpGet("api/rooms/v2/createdby/me")]
[HttpGet("api/rooms/v3/createdby/me")]
[HttpGet("roomserver/rooms/createdby/me")]
[HttpGet("roomserver/rooms/ownedby/me")]
[HttpGet("rooms/createdby/me")]
[HttpGet("rooms/ownedby/me")]
[Authorize] [Authorize]
public async Task<IActionResult> MyRooms() public async Task<IActionResult> MyRooms()
{ {
@@ -1462,46 +912,6 @@ public class RoomsController(
return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList()); return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
} }
[HttpGet("api/rooms/v1/createdby/{otherPlayerId:long}")]
[HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")]
[HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")]
[HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")]
[HttpGet("rooms/createdby/{otherPlayerId:long}")]
public async Task<IActionResult> CreatedByOther(long otherPlayerId)
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
[HttpGet("api/rooms/v1/visitedby/me")]
[HttpGet("api/rooms/v2/visitedby/me")]
[HttpGet("api/rooms/v3/visitedby/me")]
[HttpGet("roomserver/rooms/visitedby/me")]
[HttpGet("rooms/visitedby/me")]
[Authorize]
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/moderatedby/me")]
[HttpGet("api/rooms/v2/moderatedby/me")]
[HttpGet("api/rooms/v3/moderatedby/me")]
[HttpGet("roomserver/rooms/moderatedby/me")]
[HttpGet("rooms/moderatedby/me")]
[Authorize]
public IActionResult ModeratedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/favoritedby/me")]
[HttpGet("api/rooms/v2/favoritedby/me")]
[HttpGet("api/rooms/v3/favoritedby/me")]
[HttpGet("roomserver/rooms/favoritedby/me")]
[HttpGet("rooms/favoritedby/me")]
[Authorize]
public IActionResult FavoritedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/cheeredby/me")]
[HttpGet("api/rooms/v2/cheeredby/me")]
[HttpGet("api/rooms/v3/cheeredby/me")]
[HttpGet("roomserver/rooms/cheeredby/me")]
[HttpGet("rooms/cheeredby/me")]
[Authorize]
public IActionResult CheeredByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v2/mybookmarks")] [HttpGet("api/rooms/v2/mybookmarks")]
[HttpGet("api/rooms/v2/mybookmarkedrooms")] [HttpGet("api/rooms/v2/mybookmarkedrooms")]
[Authorize] [Authorize]
@@ -1548,7 +958,6 @@ public class RoomsController(
/// seeded AG / RR-Original room id. Used by the watch to bias the /// seeded AG / RR-Original room id. Used by the watch to bias the
/// room browser toward official rooms first.</summary> /// room browser toward official rooms first.</summary>
[HttpGet("api/rooms/v1/agRoomIds")] [HttpGet("api/rooms/v1/agRoomIds")]
[HttpGet("rooms/rro_ids")]
public async Task<IActionResult> AgRoomIds() => public async Task<IActionResult> AgRoomIds() =>
Ok(await rooms.AgRoomIdsAsync()); Ok(await rooms.AgRoomIdsAsync());
@@ -1563,28 +972,6 @@ public class RoomsController(
FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12), FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12),
}); });
/// <summary>GET <c>featuredrooms/current</c> — the watch's pointer
/// to the current "Featured" room group. Caller:
/// <c>EJDCNGBEICB.CFKDADKHAGB</c>. Wire shape is <c>NMPFCIJPODA</c>
/// with required keys <c>FeaturedRoomGroupId</c> (long) and
/// <c>Name</c> (string) — a pointer record, NOT the room list
/// itself (the watch fetches the list separately via
/// <c>api/rooms/v3/featured</c>). Returning the
/// <see cref="Featured"/> shape here throws KeyNotFoundException on
/// the watch's group-id lookup.</summary>
[HttpGet("featuredrooms/current")]
public async Task<IActionResult> FeaturedRoomsCurrent() => Ok(new
{
FeaturedRoomGroupId = 1L,
Name = "Featured",
// NMPFCIJPODA.PPGFHEDFBEA (NMPFCIJPODA.txt:100-125) reads
// 3 strict keys, not 2 — FeaturedRoomGroupId, Name, AND
// Rooms (List<PPKJFAAAGDO>). Returning without the third
// key throws KeyNotFoundException on the watch and the
// Featured carousel stays empty.
Rooms = await rooms.FeaturedAgRoomIdsAsync(12),
});
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark /// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c> /// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
/// flag (default = add).</summary> /// flag (default = add).</summary>
@@ -1601,148 +988,6 @@ public class RoomsController(
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
} }
/// <summary>POST <c>rooms/{roomId}/name</c> — rename a room.
/// Watch path: <c>EJDCNGBEICB.DNOGGKKHMHI(long roomId, string newName)</c>
/// (analytics tag "change room name"). Body shape: form-urlencoded
/// <c>Name=...</c> or a bare string in the body. Returns the
/// updated Room DTO (<c>PPENFJMFPNE : KLCOGEIGEBJ</c>).
///
/// <para>Caller must be the room creator OR a co-owner. Other
/// validations: name length 1..40, no leading/trailing whitespace,
/// uniqueness against other Rooms.Name. Failures return
/// the watch's <c>CreateModifyRoomStatus</c>-style payload with
/// a non-zero Result so the rename toast surfaces a real reason.</para>
///
/// <para>We accept both POST and PUT because the watch's ISIL
/// doesn't expose the explicit HTTPMethod literal at this call
/// site — covering both is cheap and means we can't get bitten
/// by the wrong one.</para></summary>
public sealed class RenameBody { public string? Name { get; set; } }
[HttpPost("rooms/{roomId:long}/name")]
[HttpPut("rooms/{roomId:long}/name")]
[Authorize]
public async Task<IActionResult> RenameRoom(
long roomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
if (newName.Length is < 1 or > 40)
return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length });
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
if (room.CreatorPlayerId != me)
{
// RoomRoles co-owner check — same authorisation as the
// existing modify endpoint, lets co-owners rename too.
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
}
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
var collision = await db.Rooms.AnyAsync(r => r.Id != roomId && r.Name == newName);
if (collision) return Conflict(new { Result = 5, error = "name_taken" });
room.Name = newName;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName);
// Return the full updated Room DTO. The watch's
// <c>PPENFJMFPNE</c> deserialiser inherits from
// <c>KLCOGEIGEBJ</c> (the standard Room shape) so we can
// forward to the same v3-details payload builder by re-reading.
// BuildRoomDetailsV3Async is too heavy here — just return the
// minimal mutated subset; the watch UI re-fetches details
// shortly after for the room-info screen.
return Ok(new
{
Result = 0,
RoomId = room.Id,
Name = room.Name,
Description = room.Description,
ImageName = room.ImageName,
CreatorPlayerId = room.CreatorPlayerId,
CreatorAccountId = room.CreatorPlayerId,
State = room.State,
Accessibility = room.Accessibility,
IsAGRoom = room.IsAGRoom,
IsRRO = false,
IsDormRoom = room.IsDormRoom,
});
}
/// <summary>POST <c>rooms/{roomId}/subrooms/{subRoomId}/name</c> —
/// rename a scene/sub-room within a multi-scene room. Watch path:
/// <c>EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName)</c>.
/// Updates the matching <see cref="RoomSceneEntity.Name"/> after
/// verifying ownership and intra-room uniqueness.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[Authorize]
public async Task<IActionResult> RenameSubroom(
long roomId,
long subRoomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
if (newName.Length is < 1 or > 40)
return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length });
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
if (room.CreatorPlayerId != me)
{
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
}
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound(new { Result = 4, error = "subroom_not_found", subRoomId });
// Uniqueness within this room — same scene name twice would
// make /goto/room/X/Y ambiguous against the RoomScenes index
// on (RoomId, Name) at DorkNetDbContext.cs:153.
var collision = await db.RoomScenes.AnyAsync(s =>
s.RoomId == roomId && s.OrderIndex != subRoomId && s.Name == newName);
if (collision) return Conflict(new { Result = 5, error = "subroom_name_taken" });
scene.Name = newName;
scene.DataModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName);
return Ok(new
{
Result = 0,
RoomId = roomId,
RoomSceneId = scene.OrderIndex,
SubRoomId = scene.OrderIndex,
Name = scene.Name,
scene.DataBlobName,
RoomSceneLocationId = scene.RoomSceneLocationId,
scene.IsSandbox,
scene.MaxPlayers,
scene.CanMatchmakeInto,
});
}
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body /// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
/// form). Idempotent per (caller, room) — duplicate taps don't /// form). Idempotent per (caller, room) — duplicate taps don't
/// inflate CheerCount.</summary> /// inflate CheerCount.</summary>
@@ -1786,965 +1031,6 @@ public class RoomsController(
return Ok(new { RoomId = roomId, CheerCount = count, ICheered = iCheered }); return Ok(new { RoomId = roomId, CheerCount = count, ICheered = iCheered });
} }
// ── Bare-path interactionby/me toggles ─────────────────────────────
[HttpPost("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPut("rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareCheer(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.CheerRoomAsync(me, roomId, 0);
return Ok(new { success = true });
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareUncheer(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.UncheerRoomAsync(me, roomId, 0);
return Ok(new { success = true });
}
[HttpPost("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPut("rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareFavorite(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.BookmarkAsync(me, roomId);
return Ok(new { success = true });
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareUnfavorite(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.UnbookmarkAsync(me, roomId);
return Ok(new { success = true });
}
// ── Rooms-and-Playlists union (2020.12 watch) ────────────────────────
/// <summary>
/// GET <c>/roomserver/roomsandplaylists/hot</c> — the 2020.12 watch's
/// merged "Trending" / "New" tab. Two URL variants exist with
/// DIFFERENT wire shapes:
///
/// 1. <c>/roomserver/roomsandplaylists/hot</c> (this endpoint)
/// Watch caller: <c>EJDCNGBEICB.EBBHGHEGBKG</c>
/// Signature: <c>IPromise&lt;HJKAOMOICJG&gt;</c>
/// Wire shape: <c>{ TotalResults, Results: [union] }</c>
/// (verified at <c>EJDCNGBEICB.txt:2571,2645</c> +
/// <c>HJKAOMOICJG.txt:39-50</c>)
/// 2. <c>/roomserver/hot_roomsandplaylists/{tags-joined}</c>
/// Watch caller: <c>OJMCBOKJFOF.EBBHGHEGBKG</c>
/// Signature: <c>IPromise&lt;List&lt;MKAMHOIHOJK&gt;&gt;</c>
/// Wire shape: bare list, see <see cref="HotRoomsAndPlaylistsPath"/>
/// (verified at <c>OJMCBOKJFOF.txt:4607,4743</c>)
///
/// Returning a bare list here throws
/// <c>InvalidCastException: Unable to cast object of type 'List`1'
/// to type 'Dictionary`2'</c> on the watch — the wrapped
/// HJKAOMOICJG deserializer reads <c>TotalResults</c> as a top-level
/// dict key, and a JSON array body fails that cast.
///
/// Inside <c>Results</c>: union entries discriminated by lowercase
/// <c>roomId</c> key per the <c>MKAMHOIHOJK</c> factory at
/// <c>MKAMHOIHOJK.txt:621</c> — see <see cref="BuildRoomUnionEntry"/>
/// + <see cref="BuildPlaylistUnionEntry"/>.
///
/// <para><b>Why rooms-only despite the "and playlists" name:</b> the
/// Hot tab of the watch's Play page mounts this endpoint and used to
/// interleave playlist tiles next to room tiles, but design intent is
/// that playlists appear only under the Moods/Playlists section (their
/// own endpoints — <c>/api/curatedroomplaylists</c> +
/// <c>/roomserver/playlists/{id}</c>). The watch's union deserializer
/// happily accepts a Results list that's 100% rooms, so we keep the
/// wrapper shape but drop the playlist half.</para>
/// </summary>
[HttpGet("roomserver/roomsandplaylists/hot")]
[HttpGet("roomsandplaylists/hot")]
public async Task<IActionResult> RoomsAndPlaylistsHot([FromQuery] string? tag)
{
var (entries, total) = await BuildHotRoomsAsync(tag);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{
["TotalResults"] = (long)total,
["Results"] = entries,
});
}
/// <summary>
/// GET <c>/roomserver/hot_roomsandplaylists/{tagsJoined}</c> —
/// path-style sibling of <see cref="RoomsAndPlaylistsHot"/> with a
/// BARE <c>List&lt;MKAMHOIHOJK&gt;</c> shape (no TotalResults
/// wrapper). Tags are comma-joined into the URL path per
/// <c>OJMCBOKJFOF.txt:4739,4743</c> (<c>String.Join(",", tags)</c>
/// followed by <c>String.Concat("hot_roomsandplaylists/", joined)</c>).
/// Empty path is legal — equivalent to "no tag filter".
/// </summary>
[HttpGet("roomserver/hot_roomsandplaylists/{tagsJoined?}")]
[HttpGet("hot_roomsandplaylists/{tagsJoined?}")]
public async Task<IActionResult> HotRoomsAndPlaylistsPath(string? tagsJoined)
{
// Watch joins multiple tags with "," — use the first one as a
// substring filter since RoomService.HotAsync takes a single
// tag arg. Empty / null = unfiltered.
//
// Returns rooms-only for the same reason as the wrapped
// RoomsAndPlaylistsHot endpoint above: playlists belong in
// Moods, not in the Hot tab. See that endpoint's doc-comment.
var firstTag = string.IsNullOrWhiteSpace(tagsJoined)
? null
: tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
var (entries, _) = await BuildHotRoomsAsync(firstTag);
return Ok(entries);
}
/// <summary>GET <c>hot_rooms/{tagsJoined?}</c> — rooms-only variant
/// of <see cref="HotRoomsAndPlaylistsPath"/>. Caller:
/// <c>OJMCBOKJFOF.DLPLPKCNLNA</c>. Same <c>MKAMHOIHOJK</c> union
/// entry shape as <c>hot_roomsandplaylists</c>, just skips playlist
/// rows so the watch's rooms-only browse tab doesn't render
/// playlist tiles that have nowhere to go.</summary>
[HttpGet("hot_rooms/{tagsJoined?}")]
public async Task<IActionResult> HotRoomsOnlyPath(string? tagsJoined)
{
var firstTag = string.IsNullOrWhiteSpace(tagsJoined)
? null
: tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
var roomList = await rooms.HotAsync(firstTag, take: 12);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = roomIds.Count == 0
? new List<RoomSceneEntity>()
: await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex).ToListAsync();
var scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
return Ok(roomList
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList());
}
/// <summary>
/// Shared body for the two hot variants — pulls hot rooms only,
/// fans out sub-room rows in one query to avoid N+1, and returns
/// the union-shape entries the watch's factory expects (each entry
/// happens to be a room dict; the factory accepts that). Returns
/// (entries, totalCount) so the wrapped variant can fill in
/// TotalResults.
///
/// <para>Take limit raised to 100 (was 12) — the Hot tab used to
/// surface only the top dozen rooms which is far less than the
/// catalog the watch's browse UI can actually render. The watch
/// paginates client-side if it needs to chunk further.</para>
/// </summary>
private async Task<(List<IDictionary<string, object>> Entries, int Total)> BuildHotRoomsAsync(string? tag)
{
var roomList = await rooms.HotAsync(tag, take: 100);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = roomIds.Count == 0
? new List<RoomSceneEntity>()
: await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
var entries = roomList
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
return (entries, entries.Count);
}
/// <summary>
/// GET <c>/api/curatedroomplaylists</c> — the editorial "Curated"
/// row on the watch's playlists tab.
///
/// Wire shape: <c>List&lt;Int64&gt;</c> — JUST the playlist IDs, not
/// the full playlist objects. Verified against
/// <c>FPNPBJBCMKB.EPPPMHMMGME</c> (signature
/// <c>IPromise&lt;List&lt;long&gt;&gt;</c>) and the call site at
/// <c>BrowseRoomsScreen.txt:2806</c> which constructs an
/// <c>Action&lt;List&lt;Int64&gt;&gt;</c> callback. Returning rich
/// objects here throws
/// <c>InvalidCastException: Unable to cast object of type 'List`1'
/// to type 'Dictionary`2'</c> on the watch ("Failed to get curated
/// room playlists: Malformed Response"). The watch fetches each
/// playlist's details separately via the playlist-detail endpoints.
/// </summary>
[HttpGet("api/curatedroomplaylists")]
public async Task<IActionResult> CuratedRoomPlaylists()
{
var curated = await playlists.CuratedAsync();
return Ok(curated.Select(p => p.Id).ToList());
}
/// <summary>
/// GET <c>/roomserver/playlists/{id}</c> — single-playlist details
/// with members. The watch deserializes this as <b>BMFAGMFKODA</b>
/// (verified at <c>BMFAGMFKODA.txt:182-191</c>) which:
/// 1. Calls <c>KMKPEOGJDFK.PPGFHEDFBEA</c> to read the base
/// Playlist union fields (<c>PlaylistId</c> + all
/// <c>MKAMHOIHOJK</c> base keys).
/// 2. Reads required key <c>Rooms</c> — <c>List&lt;KLCOGEIGEBJ&gt;</c>
/// (the playlist's member rooms, full Room dicts).
/// 3. Reads required key <c>Tags</c> — <c>List&lt;DPHPFLGAICI&gt;</c>
/// (tag rows with <c>Type</c> + <c>Tag</c>).
/// Returning just the <c>KMKPEOGJDFK</c> shape (no Rooms/Tags) throws
/// <c>KeyNotFoundException: Failed to find key 'Rooms' when
/// deserializing object of type KLCOGEIGEBJ</c> on the watch.
/// </summary>
[HttpGet("roomserver/playlists/{id:long}")]
[HttpGet("playlists/{id:long}")]
public async Task<IActionResult> PlaylistDetails(long id)
{
var p = await db.Playlists.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
// Pull member rooms in playlist order, then fan out room +
// scene rows so each entry can render as a full KLCOGEIGEBJ.
var roomIds = await db.PlaylistRooms
.Where(pr => pr.PlaylistId == id)
.OrderBy(pr => pr.OrderIndex)
.Select(pr => pr.RoomId)
.ToListAsync();
List<RoomEntity> memberRooms;
Dictionary<long, IReadOnlyList<RoomSceneEntity>> scenesByRoom;
if (roomIds.Count == 0)
{
memberRooms = new List<RoomEntity>();
scenesByRoom = new Dictionary<long, IReadOnlyList<RoomSceneEntity>>();
}
else
{
var roomRows = await db.Rooms
.Where(r => roomIds.Contains(r.Id))
.ToListAsync();
// Preserve playlist order — db.Rooms.Where doesn't guarantee
// it matches the OrderBy on PlaylistRooms.OrderIndex.
var roomById = roomRows.ToDictionary(r => r.Id);
memberRooms = roomIds
.Select(rid => roomById.TryGetValue(rid, out var r) ? r : null)
.Where(r => r is not null)
.Select(r => r!)
.ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
}
var roomsWire = memberRooms
.Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
// Tags wire shape: [{Type:int, Tag:string}] — same shape used in
// BuildRoomServerDetails for Room.Tags. Split TagsCsv if present.
var tagsWire = string.IsNullOrWhiteSpace(p.TagsCsv)
? new List<object>()
: p.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.ToList();
// Start from BuildPlaylistUnionEntry for base + PlaylistId, then
// splice in the Rooms + Tags lists. Dictionary lets us add the
// extra keys after the base build.
var result = BuildPlaylistUnionEntry(p);
result["Rooms"] = roomsWire;
result["Tags"] = tagsWire;
return Ok(result);
}
/// <summary>
/// GET <c>/roomserver/roomsandplaylists/search?query=X</c> — search
/// surface used by the watch's friend-mention / room-search affordance.
/// Verified at <c>EJDCNGBEICB.txt:2486,2560</c> — signature
/// <c>IPromise&lt;HJKAOMOICJG&gt;</c>. Per
/// <see cref="HJKAOMOICJG.PPGFHEDFBEA"/> at <c>HJKAOMOICJG.txt:39-50</c>
/// the response object reads <c>TotalResults</c> (Int64) and
/// <c>Results</c> (List of <c>MKAMHOIHOJK</c> union entries — same
/// Room/Playlist factory used by <c>/hot</c>).
///
/// Distinct from <c>/roomserver/search_roomsandplaylists/{query}</c>
/// (path-param shape from <c>OJMCBOKJFOF.txt:4567</c>); both URLs
/// reach the same data but differ in shape — the path-param sibling
/// returns a bare <c>List&lt;MKAMHOIHOJK&gt;</c> without the
/// TotalResults wrapper.
/// </summary>
[HttpGet("roomserver/roomsandplaylists/search")]
[HttpGet("roomsandplaylists/search")]
public async Task<IActionResult> RoomsAndPlaylistsSearch([FromQuery] string? query)
{
var (merged, total) = await BuildSearchUnionAsync(query);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{
["TotalResults"] = (long)total,
["Results"] = merged,
});
}
/// <summary>
/// GET <c>/roomserver/search_roomsandplaylists/{query}</c> — free-text
/// search across both rooms and playlists, BARE-LIST variant. Per
/// <c>OJMCBOKJFOF.txt:4439,4567</c> the signature is
/// <c>IPromise&lt;List&lt;MKAMHOIHOJK&gt;&gt;</c> — no TotalResults
/// wrapper. URL is <c>search_roomsandplaylists/{query}</c>
/// (query in path, not query-string).
/// </summary>
[HttpGet("roomserver/search_roomsandplaylists/{query}")]
[HttpGet("search_roomsandplaylists/{query}")]
public async Task<IActionResult> SearchRoomsAndPlaylists(string query)
{
var (merged, _) = await BuildSearchUnionAsync(query);
return Ok(merged);
}
/// <summary>GET <c>search_rooms/{query}</c> — rooms-only search
/// variant. Same <c>MKAMHOIHOJK</c> union entry shape as
/// <c>search_roomsandplaylists/{query}</c>, just skips playlist
/// rows.</summary>
[HttpGet("search_rooms/{query}")]
public async Task<IActionResult> SearchRoomsOnly(string query)
{
if (string.IsNullOrWhiteSpace(query))
return Ok(Array.Empty<object>());
var roomList = await rooms.SearchAsync(query);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = roomIds.Count == 0
? new List<RoomSceneEntity>()
: await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex).ToListAsync();
var scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
return Ok(roomList
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList());
}
/// <summary>
/// Shared body for the two search variants. Empty/whitespace query
/// returns an empty result without round-tripping the DB.
/// </summary>
private async Task<(List<IDictionary<string, object>> Entries, int Total)> BuildSearchUnionAsync(string? query)
{
if (string.IsNullOrWhiteSpace(query))
return (new List<IDictionary<string, object>>(), 0);
// Sequential — shared scoped DbContext isn't thread-safe.
var roomList = await rooms.SearchAsync(query);
var playlistList = await playlists.SearchAsync(query);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
var merged = InterleaveUnion(
roomList.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))),
playlistList.Select(BuildPlaylistUnionEntry));
return (merged, roomList.Count + playlistList.Count);
}
/// <summary>Round-robin merge of two enumerables so the watch's
/// hot row alternates between rooms and playlists rather than
/// stacking one type on top. Once one side runs out the remainder
/// of the other side spills out in order.</summary>
private static List<IDictionary<string, object>> InterleaveUnion(
IEnumerable<IDictionary<string, object>> a,
IEnumerable<IDictionary<string, object>> b)
{
var listA = a.ToList();
var listB = b.ToList();
var output = new List<IDictionary<string, object>>(listA.Count + listB.Count);
int i = 0, j = 0;
while (i < listA.Count || j < listB.Count)
{
if (i < listA.Count) output.Add(listA[i++]);
if (j < listB.Count) output.Add(listB[j++]);
}
return output;
}
/// <summary>
/// Build a union-shape Room entry — same payload as
/// <see cref="BuildRoomServerDetails"/> but with the additional
/// lowercase <c>roomId</c> key that the 2020.12
/// <c>MKAMHOIHOJK</c> factory uses as its Room-vs-Playlist
/// discriminator (factory at <c>MKAMHOIHOJK.txt:621</c> calls
/// <c>Dictionary.ContainsKey("roomId")</c>; the actual Room
/// deserializer at <c>KLCOGEIGEBJ.txt:208-234</c> reads
/// PascalCase <c>RoomId</c>). Must include BOTH keys.
/// </summary>
private static IDictionary<string, object> BuildRoomUnionEntry(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows)
{
// Start from the canonical RoomServerDetails shape so the union
// entry carries every field the 2020.12 client expects on a
// Room subclass (SubRooms, Roles, Stats, etc.). Then add the
// lowercase 'roomId' discriminator key on top.
var details = BuildRoomServerDetails(room, sceneRows);
// Convert anonymous record into a mutable dictionary so we can
// add the lowercase 'roomId' key that anonymous types can't
// express (you can't have two properties differing only in
// letter casing on a single anonymous type).
var dict = new Dictionary<string, object>(StringComparer.Ordinal);
foreach (var prop in details.GetType().GetProperties())
{
var value = prop.GetValue(details);
if (value is not null) dict[prop.Name] = value;
}
// Discriminator key — lowercase 'roomId'. Without this the
// MKAMHOIHOJK factory falls through to the Playlist branch
// and PlaylistId.PPGFHEDFBEA throws on the missing key.
dict["roomId"] = room.Id;
return dict;
}
/// <summary>
/// Build a union-shape Playlist entry — the
/// <see cref="KMKPEOGJDFK"/> branch of MKAMHOIHOJK. Keys read at
/// <c>MKAMHOIHOJK.txt:516-612</c> (PascalCase base keys) and
/// <c>KMKPEOGJDFK.txt:68</c> (PascalCase <c>PlaylistId</c>).
/// Omits the lowercase <c>roomId</c> key so the factory's
/// Room-vs-Playlist branch falls through to the Playlist
/// constructor.
/// </summary>
public static IDictionary<string, object> BuildPlaylistUnionEntry(PlaylistEntity p)
{
return new Dictionary<string, object>(StringComparer.Ordinal)
{
["PlaylistId"] = p.Id,
["Name"] = p.Name,
["Description"] = p.Description,
["ImageName"] = p.ImageName,
["CreatorAccountId"] = p.CreatorPlayerId,
["State"] = 0,
["Accessibility"] = 1,
["WarningMask"] = 0,
["CustomWarning"] = string.Empty,
["SupportsLevelVoting"] = false,
// Playlists are watchable from every device the
// member rooms support; advertise the broadest set so
// the watch never filters them out by capability.
["IsRRO"] = p.IsCurated,
["SupportsScreens"] = true,
["SupportsWalkVR"] = true,
["SupportsTeleportVR"] = true,
["SupportsVRLow"] = true,
["SupportsQuest2"] = true,
["SupportsMobile"] = true,
["SupportsJuniors"] = true,
["CreatedAt"] = (p.CreatedAt == default ? DateTime.UtcNow : p.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
["Stats"] = new
{
CheerCount = p.CheerCount,
FavoriteCount = p.FavoriteCount,
VisitorCount = p.VisitorCount,
VisitCount = p.VisitCount,
},
};
}
// ── Subroom mutations (bare path) ──────────────────────────────────
public sealed class CreateSubRoomRequest
{
public string? Name { get; set; }
public string? RoomSceneLocationId { get; set; }
public int? MaxPlayers { get; set; }
public bool? IsSandbox { get; set; }
}
/// <summary>POST <c>rooms/{id}/subrooms</c> — append a new scene.
/// Owner-gated. OrderIndex = max+1, Name unique within room.</summary>
[HttpPost("rooms/{roomId:long}/subrooms")]
[Authorize]
public async Task<IActionResult> AddSubRoom(long roomId, [FromBody] CreateSubRoomRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var name = (req.Name ?? string.Empty).Trim();
if (name.Length == 0) name = "NewScene";
var collision = await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == name);
if (collision) return Conflict(new { error = "name_taken" });
var nextOrder = (await db.RoomScenes
.Where(s => s.RoomId == roomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
var scene = new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = nextOrder,
Name = name,
RoomSceneLocationId = req.RoomSceneLocationId
?? "a75f7547-79eb-47c6-8986-6767abcb4f92",
MaxPlayers = req.MaxPlayers ?? 8,
IsSandbox = req.IsSandbox ?? false,
};
db.RoomScenes.Add(scene);
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
}
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" });
db.RoomScenes.Remove(scene);
await db.SaveChangesAsync();
return Ok(new { Result = 0 });
}
public sealed class SubRoomIntRequest { public int? Value { get; set; } }
public sealed class SubRoomBoolRequest { public bool? Value { get; set; } }
public sealed class SubRoomModifyRequest
{
public string? Name { get; set; }
public int? MaxPlayers { get; set; }
public bool? IsSandbox { get; set; }
public bool? CanMatchmakeInto { get; set; }
public string? RoomSceneLocationId { get; set; }
}
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize]
public Task<IActionResult> SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); });
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize]
public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/clone</c> — duplicate
/// a scene (DataBlobName + location pointer). New scene goes at the
/// end with " Copy" suffix; per-room name uniqueness preserved.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[Authorize]
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var src = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (src is null) return NotFound();
var baseName = $"{src.Name} Copy";
var newName = baseName;
int suffix = 1;
while (await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == newName))
{
suffix += 1;
newName = $"{baseName} {suffix}";
}
var nextOrder = (await db.RoomScenes.Where(s => s.RoomId == roomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
var clone = new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = nextOrder,
Name = newName,
RoomSceneLocationId = src.RoomSceneLocationId,
DataBlobName = src.DataBlobName,
MaxPlayers = src.MaxPlayers,
IsSandbox = src.IsSandbox,
CanMatchmakeInto = src.CanMatchmakeInto,
};
db.RoomScenes.Add(clone);
await db.SaveChangesAsync();
return Ok(SceneWire(clone));
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
public Task<IActionResult> ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) =>
MutateScene(roomId, subRoomId, s =>
{
if (!string.IsNullOrWhiteSpace(req.Name)) s.Name = req.Name.Trim();
if (req.MaxPlayers is int mp) s.MaxPlayers = Math.Max(1, mp);
if (req.IsSandbox is bool sb) s.IsSandbox = sb;
if (req.CanMatchmakeInto is bool cmi) s.CanMatchmakeInto = cmi;
if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId;
});
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c> — reorder a
/// scene. Body NewIndex is the target OrderIndex; the in-between
/// rows shift to fill the gap.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize]
public async Task<IActionResult> MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scenes = await db.RoomScenes
.Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync();
var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId);
if (moving is null) return NotFound();
var newIndex = Math.Clamp(req.NewIndex ?? 0, 0, scenes.Count - 1);
scenes.Remove(moving);
scenes.Insert(newIndex, moving);
// Recompute OrderIndex on the entire row set. The unique
// (RoomId, OrderIndex) index would clash if we wrote rows in
// place — flush a sentinel offset first, then settle to
// final indices.
const int sentinelOffset = 100_000;
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i + sentinelOffset;
await db.SaveChangesAsync();
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i;
await db.SaveChangesAsync();
return Ok(SceneWire(moving));
}
/// <summary>POST <c>roomserver/rooms/{id}/subrooms/{sub}/data</c> —
/// commit the blob returned by <c>storage/upload</c> as the current
/// scene save. The 2020 watch posts this as form-urlencoded
/// (<c>filename</c>, <c>inventionUsage</c>, <c>savedByAccountId</c>),
/// while some tooling uses JSON. Both variants delegate to the
/// existing saveData path so room, scene, dorm-state, presence, and
/// subscription fanout stay consistent.</summary>
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[Authorize]
public async Task<IActionResult> SubRoomData(long roomId, long subRoomId)
{
var body = await ReadSaveRoomSceneRequestAsync();
body.RoomSceneId = subRoomId;
return await SaveDataCore(body, roomId, wrapCreateModifyResponse: true);
}
private async Task<SaveRoomSceneRequest> ReadSaveRoomSceneRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var request = new SaveRoomSceneRequest
{
Filename = FirstFormValue(form, "filename", "Filename"),
RoomDataFilename = FirstFormValue(form, "roomDataFilename", "RoomDataFilename", "roomDataFileName", "RoomDataFileName"),
InventionUsageBase64 = FirstFormValue(form, "inventionUsage", "InventionUsage", "inventionUsageBase64", "InventionUsageBase64"),
};
if (long.TryParse(FirstFormValue(form, "requestPlayerId", "RequestPlayerId", "savedByAccountId", "SavedByAccountId"), out var requestPlayerId))
request.RequestPlayerId = requestPlayerId;
if (int.TryParse(FirstFormValue(form, "saveRequestPlayerId", "SaveRequestPlayerId"), out var saveRequestPlayerId))
request.SaveRequestPlayerId = saveRequestPlayerId;
return request;
}
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
return await JsonSerializer.DeserializeAsync<SaveRoomSceneRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new SaveRoomSceneRequest();
}
catch (JsonException)
{
return new SaveRoomSceneRequest();
}
}
return new SaveRoomSceneRequest();
}
private static string? FirstFormValue(IFormCollection form, params string[] keys)
{
foreach (var key in keys)
{
var value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
return null;
}
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
mutator(scene);
scene.DataModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
}
private static object SceneWire(RoomSceneEntity s) => new
{
Result = 0,
RoomId = s.RoomId,
RoomSceneId = s.OrderIndex,
SubRoomId = s.OrderIndex,
Name = s.Name,
s.DataBlobName,
RoomSceneLocationId = s.RoomSceneLocationId,
s.IsSandbox,
s.MaxPlayers,
s.CanMatchmakeInto,
};
// ── Bare-path roles ────────────────────────────────────────────────
/// <summary>GET <c>rooms/{id}/roles</c> — accepted role grants on
/// the room. Owner is implicit (CreatorPlayerId) and surfaces under
/// Role=30; co-owner/mod/host rows come from RoomRoles.</summary>
[HttpGet("rooms/{roomId:long}/roles")]
public async Task<IActionResult> RolesList(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var list = new List<object>(rows.Count + 1)
{
new { RoomId = roomId, PlayerId = (int)room.CreatorPlayerId, Role = 30 /*Owner*/, Accepted = true },
};
list.AddRange(rows.Select(r => (object)new
{
RoomId = roomId,
PlayerId = (int)r.PlayerId,
Role = r.Role,
Accepted = r.Accepted,
}));
return Ok(list);
}
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId == playerId)
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = 30, Accepted = true });
var row = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
if (row is null) return NotFound();
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = row.Role, Accepted = row.Accepted });
}
public sealed class GrantRoleRequest { public int? Role { get; set; } }
/// <summary>POST <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[Authorize]
public async Task<IActionResult> GrantRole(long roomId, long playerId)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
var role = await ReadClientRoomRoleAsync();
if (role is null) return BadRequest(new { error = "missing_role" });
if (role.Value == 0)
return await ClearRoomAccountRoleAsync(room, playerId);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
return BadRequest(new { error = "invalid_role", role });
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
if (existing is null)
{
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = true, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = true;
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
/// as grant but Accepted=false; the target's accept-invite flow
/// flips the flag (separate endpoint, not yet exposed). For now
/// invited rows surface in RoomDetails.InvitedCoOwners etc.</summary>
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[Authorize]
public async Task<IActionResult> InviteRole(long roomId, long playerId)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (!await CanManageRoomRolesAsync(room, me)) return Forbid();
var role = await ReadClientRoomRoleAsync();
if (role is null) return BadRequest(new { error = "missing_role" });
if (role.Value == 0)
return await ClearRoomAccountRoleAsync(room, playerId);
if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole))
return BadRequest(new { error = "invalid_role", role });
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole);
if (existing is null)
{
await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole);
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = roomRole,
Accepted = false, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = false;
}
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
}
private async Task<IActionResult> ClearRoomAccountRoleAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId)
return BadRequest(new { error = "cannot_change_creator_role" });
var rows = await db.RoomRoles
.Where(r => r.RoomId == room.Id && r.PlayerId == playerId)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
await db.SaveChangesAsync();
return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id));
}
private async Task<bool> CanManageRoomRolesAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
return await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id
&& r.PlayerId == playerId
&& r.Accepted
&& r.Role == 0);
}
private async Task<int?> ReadClientRoomRoleAsync()
{
int parsed;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed;
if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed;
}
if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed;
if (int.TryParse(Request.Query["Role"].FirstOrDefault(), out parsed)) return parsed;
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
var body = await JsonSerializer.DeserializeAsync<GrantRoleRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (body?.Role is int bodyRole) return bodyRole;
}
catch (JsonException)
{
return null;
}
}
return null;
}
private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole)
{
var rows = await db.RoomRoles
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole)
.ToListAsync();
if (rows.Count > 0)
db.RoomRoles.RemoveRange(rows);
}
private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole)
{
roomRole = clientRole switch
{
30 => 0, // CoOwner
20 => 1, // Moderator
10 => 2, // Host
_ => -1,
};
return roomRole >= 0;
}
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
{
var room = await db.Rooms.FirstAsync(r => r.Id == roomId);
var scenes = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roles = await db.RoomRoles
.Where(r => r.RoomId == roomId)
.ToListAsync();
return BuildRoomServerDetails(room, scenes, roles: roles);
}
/// <summary> /// <summary>
/// Synthesise a placeholder Room when the client asks for one we don't /// Synthesise a placeholder Room when the client asks for one we don't
/// have in the DB. Keeps the deserializer happy — every required key /// have in the DB. Keeps the deserializer happy — every required key
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
@@ -23,20 +22,13 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2;
[Authorize] [Authorize]
public class RoomsModerationController(DorkNetDbContext db) : ControllerBase public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
{ {
private const int PublicAccessibility = 1;
private const long CanUseShareCamPermission = 1L << 18;
private long Me => this.RequireCurrentPlayerId(); private long Me => this.RequireCurrentPlayerId();
private async Task<RoomEntity?> RequireOwnedRoomAsync(long roomId) private async Task<RoomEntity?> RequireOwnedRoomAsync(long roomId)
{ {
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return null; if (room is null) return null;
if (room.CreatorPlayerId == Me) return room; return room.CreatorPlayerId == Me ? room : null;
var coOwner = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == Me && r.Accepted && r.Role == 0);
return coOwner ? room : null;
} }
// ── /modify (general fields) ───────────────────────────────────────── // ── /modify (general fields) ─────────────────────────────────────────
@@ -306,29 +298,14 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
var acceptedRoles = await db.RoomRoles int role = (room?.CreatorPlayerId == playerId) ? 30 : 0;
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Accepted)
.Select(r => r.Role)
.ToListAsync();
int role = (room?.CreatorPlayerId == playerId)
? 30
: acceptedRoles.Select(RoomRoleRank).DefaultIfEmpty(0).Max();
long permissions = role switch long permissions = role switch
{ {
30 => -1L, 30 => -1L,
20 => 0x0FFFFFFFL,
10 => 0x0FFFFFFFL, 10 => 0x0FFFFFFFL,
5 => 0x000000FFL, 5 => 0x000000FFL,
_ => 0x0000000FL, _ => 0x0000000FL,
}; };
// RoomRole.BJICCBAKLAF.CAN_USE_SHARE_CAM = 18 in the
// 2020.12 decompile. Public rooms should allow desktop/share
// screens without requiring host/mod/co-owner, while private
// rooms keep their explicit role gate.
if (room?.Accessibility == PublicAccessibility)
{
permissions |= CanUseShareCamPermission;
}
return Ok(new return Ok(new
{ {
success = true, success = true,
@@ -339,366 +316,4 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
Permissions = permissions, Permissions = permissions,
}); });
} }
/// <summary>POST <c>api/rooms/v1/verifyRole</c> — status-only
/// gate used by 2020.12 before privileged room actions. Body is
/// form or JSON with roomId, role, and optional context.</summary>
[HttpPost("api/rooms/v1/verifyRole")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> VerifyRole()
{
var req = await ReadVerifyRoleRequestAsync();
if (req.RoomId <= 0) return BadRequest();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return NotFound();
var me = Me;
var required = RoleRank(req.Role);
if (required <= 0) return Ok();
var isAdmin = await db.Players
.Where(p => p.Id == me)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
if (isAdmin || room.CreatorPlayerId == me) return Ok();
var acceptedRoles = await db.RoomRoles
.Where(r => r.RoomId == req.RoomId && r.PlayerId == me && r.Accepted)
.Select(r => r.Role)
.ToListAsync();
var actual = acceptedRoles.Select(RoomRoleRank).DefaultIfEmpty(0).Max();
return actual >= required ? Ok() : Forbid();
}
private sealed class VerifyRoleRequest
{
public long RoomId { get; set; }
public string Role { get; set; } = string.Empty;
public string Context { get; set; } = string.Empty;
}
private async Task<VerifyRoleRequest> ReadVerifyRoleRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new VerifyRoleRequest
{
RoomId = long.TryParse(form["roomId"].FirstOrDefault()
?? form["RoomId"].FirstOrDefault(), out var roomId)
? roomId
: 0,
Role = form["role"].FirstOrDefault()
?? form["Role"].FirstOrDefault()
?? string.Empty,
Context = form["context"].FirstOrDefault()
?? form["Context"].FirstOrDefault()
?? string.Empty,
};
}
try
{
var req = await JsonSerializer.DeserializeAsync<VerifyRoleRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return req ?? new VerifyRoleRequest();
}
catch (JsonException)
{
return new VerifyRoleRequest();
}
}
private static int RoomRoleRank(int role) => role switch
{
0 => 30, // CoOwner
1 => 10, // Moderator
2 => 20, // Host
_ => 0,
};
private static int RoleRank(string role)
{
if (int.TryParse(role, out var n)) return n;
return role.Trim().ToLowerInvariant() switch
{
"owner" or "creator" or "coowner" or "co-owner" => 30,
"host" => 20,
"moderator" or "mod" => 10,
"member" => 5,
"visitor" or "" => 0,
_ => 0,
};
}
// ── Bare-path per-field mutations (2020.12 watch URLs) ─────────────
//
// The 2020.12 client emits per-field mutation URLs like
// POST rooms/{id}/description rather than POST api/rooms/v2/modify
// with one field set. Each handler below shares the same
// RequireOwnedRoomAsync gate as /modify and returns the same
// ToWireRoom shape so the watch's CreateModifyRoomResponse
// deserializer reads cleanly.
public sealed class BareDescriptionRequest { public string? Description { get; set; } }
public sealed class BareImageRequest { public string? ImageName { get; set; } }
public sealed class BareTagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
public sealed class BareIntRequest { public int? Value { get; set; } }
public sealed class BareBoolRequest { public bool? Value { get; set; } }
public sealed class BareWarningRequest { public int? RoomWarningMask { get; set; } public string? CustomRoomWarning { get; set; } }
// Each per-field route registers BOTH POST and PUT — the 2020.12
// watch's request-builder wraps the HTTP method inside opaque
// BPHGKAEDBPE helpers; the existing /name handler at
// RoomsController.cs:1455-1456 set the precedent of "register both
// because the ISIL is opaque". Same pattern here so the bind never
// 405s.
[HttpPost("rooms/{roomId:long}/description")]
[HttpPut("rooms/{roomId:long}/description")]
public async Task<IActionResult> BareDescription(long roomId,
[FromBody] BareDescriptionRequest? body,
[FromForm(Name = "Description")] string? form) =>
await ApplyAndReturn(roomId, r => r.Description = body?.Description ?? form ?? r.Description);
[HttpPost("rooms/{roomId:long}/image")]
[HttpPut("rooms/{roomId:long}/image")]
public async Task<IActionResult> BareImage(long roomId,
[FromBody] BareImageRequest? body,
[FromForm(Name = "ImageName")] string? form) =>
await ApplyAndReturn(roomId, r => r.ImageName = body?.ImageName ?? form ?? r.ImageName);
[HttpPost("rooms/{roomId:long}/tags")]
[HttpPut("rooms/{roomId:long}/tags")]
public async Task<IActionResult> BareTags(long roomId,
[FromBody] BareTagsRequest? body,
[FromForm(Name = "Tags")] string? form)
{
var csv = body?.Tags
?? (body?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
?? form;
return await ApplyAndReturn(roomId, r => r.TagsCsv = csv ?? r.TagsCsv);
}
[HttpPost("rooms/{roomId:long}/accessibility")]
[HttpPut("rooms/{roomId:long}/accessibility")]
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> BareAccessibility(long roomId)
{
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
return await ApplyAndReturn(roomId, r =>
{
if (value is int vv) r.Accessibility = Math.Clamp(vv, 0, 2);
});
}
private async Task<int?> ReadBareIntAsync(params string[] keys)
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in keys)
if (int.TryParse(form[key].FirstOrDefault(), out var value)) return value;
return null;
}
foreach (var key in keys)
if (int.TryParse(Request.Query[key].FirstOrDefault(), out var value)) return value;
if ((Request.ContentLength ?? 0) <= 0) return null;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var key in keys)
{
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n;
if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out n)) return n;
}
}
catch (JsonException)
{
return null;
}
return null;
}
[HttpPost("rooms/{roomId:long}/cloning")]
[HttpPut("rooms/{roomId:long}/cloning")]
public async Task<IActionResult> BareCloning(long roomId, [FromBody] BareBoolRequest? body,
[FromForm(Name = "CloningAllowed")] bool? form) =>
await ApplyAndReturn(roomId, r =>
{
var v = body?.Value ?? form;
if (v is bool vv) r.CloningAllowed = vv;
});
[HttpPost("rooms/{roomId:long}/automute")]
[HttpPut("rooms/{roomId:long}/automute")]
public async Task<IActionResult> BareAutomute(long roomId, [FromBody] BareBoolRequest? body,
[FromForm(Name = "DisableMicAutoMute")] bool? form) =>
await ApplyAndReturn(roomId, r =>
{
var v = body?.Value ?? form;
if (v is bool vv) r.DisableMicAutoMute = vv;
});
/// <summary><c>rooms/{id}/restrictions</c> — junior / age-related
/// restriction toggle. Body bool maps to AllowsJuniors (true = no
/// restriction, false = juniors restricted).</summary>
[HttpPost("rooms/{roomId:long}/restrictions")]
[HttpPut("rooms/{roomId:long}/restrictions")]
public async Task<IActionResult> BareRestrictions(long roomId, [FromBody] BareBoolRequest? body,
[FromForm(Name = "AllowsJuniors")] bool? form) =>
await ApplyAndReturn(roomId, r =>
{
var v = body?.Value ?? form;
if (v is bool vv) r.AllowsJuniors = vv;
});
[HttpPost("rooms/{roomId:long}/warning")]
[HttpPut("rooms/{roomId:long}/warning")]
public async Task<IActionResult> BareWarning(long roomId, [FromBody] BareWarningRequest? body) =>
await ApplyAndReturn(roomId, r =>
{
if (body?.RoomWarningMask is int m) r.RoomWarningMask = m;
if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning;
});
/// <summary><c>rooms/{id}/comments</c> + <c>voice_chat_encryption</c>
/// — fields not persisted in the 2020 RoomEntity. Owner-gated
/// acknowledgement so the watch's settings panel doesn't surface
/// a "save failed" error; populated when the columns land.</summary>
[HttpPost("rooms/{roomId:long}/comments")]
[HttpPut("rooms/{roomId:long}/comments")]
[HttpPost("rooms/{roomId:long}/voice_chat_encryption")]
[HttpPut("rooms/{roomId:long}/voice_chat_encryption")]
public async Task<IActionResult> BareAck(long roomId) =>
await ApplyAndReturn(roomId, _ => { /* no-op: not modelled yet */ });
/// <summary><c>rooms/{id}/modify</c> — bare-path equivalent
/// of <c>api/rooms/v2/modify</c>. Reuses <see cref="Modify"/> by
/// forwarding the body with RoomId pre-populated from the URL.</summary>
[HttpPost("rooms/{roomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/modify")]
public async Task<IActionResult> BareModify(long roomId, [FromBody] ModifyRoomRequest req)
{
req.RoomId = roomId;
return await Modify(req);
}
/// <summary>POST <c>rooms/{id}/clone</c> — bare-path alias of
/// <c>api/rooms/v1/clone</c>. The actual clone logic lives on
/// <see cref="DorkNet.Server.Controllers.API.Rooms.V2.RoomsController"/>;
/// this handler proxies via the service so we don't duplicate the
/// substantial clone-rooms logic.</summary>
[HttpPost("rooms/{roomId:long}/clone")]
public async Task<IActionResult> BareClone(long roomId,
[FromBody] CloneBareRequest? body,
[FromForm(Name = "Name")] string? formName)
{
var newName = body?.Name ?? formName ?? string.Empty;
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (source is null) return NotFound();
if (!source.CloningAllowed && source.CreatorPlayerId != Me)
return Forbid();
if (string.IsNullOrWhiteSpace(newName))
newName = $"{source.Name}Copy";
else
newName = newName.Trim();
// Uniqueness — append a numeric suffix until free.
var baseName = newName;
int suffix = 1;
while (await db.Rooms.AnyAsync(r => r.Name == newName))
{
suffix += 1;
newName = $"{baseName}{suffix}";
}
var clone = new RoomEntity
{
Name = newName,
Description = source.Description,
CreatorPlayerId = Me,
ImageName = source.ImageName,
State = 0,
Accessibility = source.Accessibility,
SupportsLevelVoting = source.SupportsLevelVoting,
IsAGRoom = false,
IsDormRoom = false,
CloningAllowed = source.CloningAllowed,
SupportsVRLow = source.SupportsVRLow,
SupportsMobile = source.SupportsMobile,
SupportsScreens = source.SupportsScreens,
SupportsWalkVR = source.SupportsWalkVR,
SupportsTeleportVR = source.SupportsTeleportVR,
AllowsJuniors = source.AllowsJuniors,
RoomWarningMask = source.RoomWarningMask,
CustomRoomWarning = source.CustomRoomWarning,
DisableMicAutoMute = source.DisableMicAutoMute,
LocationReplicationId = source.LocationReplicationId,
TagsCsv = source.TagsCsv,
CurrentDataBlobName = source.CurrentDataBlobName,
};
db.Rooms.Add(clone);
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(clone));
}
public sealed class CloneBareRequest { public string? Name { get; set; } }
// ── Bare-path bans aliases ─────────────────────────────────────────
public sealed class BareBanRequest { public long PlayerId { get; set; } public int BanType { get; set; } public DateTime? Until { get; set; } public string? Reason { get; set; } }
[HttpGet("rooms/{roomId:long}/bans")]
public Task<IActionResult> BareBansList(long roomId) => RoomBansList(roomId);
[HttpPost("rooms/{roomId:long}/bans")]
public Task<IActionResult> BareBan(long roomId, [FromBody] BareBanRequest req) =>
BanFromRoom(new BanFromRoomRequest
{
RoomId = roomId,
PlayerId = req.PlayerId,
BanType = req.BanType,
Until = req.Until,
Reason = req.Reason,
});
[HttpDelete("rooms/{roomId:long}/bans/{playerId:long}")]
public async Task<IActionResult> BareUnban(long roomId, long playerId)
{
var room = await RequireOwnedRoomAsync(roomId);
if (room is null) return NotFound();
var rows = await db.RoomBans
.Where(b => b.RoomId == roomId && b.BannedPlayerId == playerId)
.ToListAsync();
if (rows.Count == 0) return Ok(new { Removed = 0 });
db.RoomBans.RemoveRange(rows);
await db.SaveChangesAsync();
return Ok(new { Removed = rows.Count });
}
[HttpPost("rooms/{roomId:long}/bans/import")]
public Task<IActionResult> BareBansImport(long roomId, [FromBody] ImportRoomBansRequest req)
{
req.RoomId = roomId;
return ImportRoomBans(req);
}
private async Task<IActionResult> ApplyAndReturn(long roomId, Action<RoomEntity> mutator)
{
var room = await RequireOwnedRoomAsync(roomId);
if (room is null) return NotFound();
mutator(room);
room.UpdatedAt = DateTime.UtcNow;
try { await db.SaveChangesAsync(); }
catch (DbUpdateException) { return Conflict(new { Result = 2 }); }
return Ok(Services.RoomService.ToWireRoom(room));
}
} }
@@ -115,18 +115,4 @@ public class SanitizeController(ILogger<SanitizeController> logger) : Controller
?? textForm ?? textQuery ?? string.Empty; ?? textForm ?? textQuery ?? string.Empty;
return Ok(ProfanityFilter.IsClean(input)); return Ok(ProfanityFilter.IsClean(input));
} }
/// <summary>POST <c>api/sanitize/isPure</c> — December 2020 route
/// used by room/settings forms. Its importer reads an <c>IsPure</c>
/// object field, unlike the newer bare-bool route above.</summary>
[HttpPost("api/sanitize/isPure")]
public IActionResult IsPure(
[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(new { IsPure = ProfanityFilter.IsClean(input) });
}
} }
@@ -113,66 +113,23 @@ public class PurchaseController(StoreService store, ILogger<PurchaseController>
public IActionResult CleanupPending() => public IActionResult CleanupPending() =>
Ok(new { success = true, error = "" }); Ok(new { success = true, error = "" });
/// <summary>POST <c>subscription/v1/*</c> — Rec Room+. The private /// <summary>POST <c>subscription/v1/cancel</c> — Rec Room+
/// server has no real billing integration, so buying RR+ is a yes/ack /// subscription cancel button. Verified URL at
/// operation and cancel is also acknowledged. Returning an active /// Commerce.txt:2188. The private server has no real billing
/// subscription object keeps the client out of platform IAP UI.</summary> /// 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")]
[HttpPost("/subscription/v1/purchase")]
[HttpPost("/subscription/v1/buy")]
[HttpPost("/subscription/v1/cancel")] [HttpPost("/subscription/v1/cancel")]
[HttpPost("/api/subscription/v1")] [HttpPost("/api/subscription/v1")]
[HttpPost("/api/subscription/v1/purchase")]
[HttpPost("/api/subscription/v1/buy")]
[HttpPost("/api/subscription/v1/cancel")] [HttpPost("/api/subscription/v1/cancel")]
public IActionResult SubscriptionAck() public IActionResult CancelSubscription()
{ {
var pid = this.CurrentPlayerId() ?? 0; logger.LogInformation("[subscription] cancel requested by player {Pid}",
logger.LogInformation("[subscription] ack requested by player {Pid}", pid); this.CurrentPlayerId() ?? 0);
return Ok(SubscriptionResponse(pid)); return Ok(new { success = true, error = "" });
}
[HttpGet("/subscription/v1")]
[HttpGet("/subscription/v1/current")]
[HttpGet("/subscription/v1/mine")]
[HttpGet("/api/subscription/v1")]
[HttpGet("/api/subscription/v1/current")]
[HttpGet("/api/subscription/v1/mine")]
public IActionResult SubscriptionStatus() =>
Ok(SubscriptionResponse(this.CurrentPlayerId() ?? 0));
private static Dictionary<string, object?> SubscriptionResponse(long playerId)
{
var now = DateTime.UtcNow;
var pascal = new Dictionary<string, object?>
{
["SubscriptionId"] = $"dorknet-rrplus-{playerId}",
["RecNetPlayerId"] = playerId,
["IsActive"] = true,
["StartedAt"] = now,
["CurrentPeriodEnd"] = now.AddYears(10),
["RenewalDate"] = now.AddYears(10),
};
var camel = new Dictionary<string, object?>
{
["subscriptionId"] = $"dorknet-rrplus-{playerId}",
["recNetPlayerId"] = playerId,
["isActive"] = true,
["startedAt"] = now,
["currentPeriodEnd"] = now.AddYears(10),
["renewalDate"] = now.AddYears(10),
};
return new Dictionary<string, object?>
{
["success"] = true,
["error"] = "",
["IsSubscribed"] = true,
["isSubscribed"] = true,
["Active"] = true,
["active"] = true,
["Subscription"] = pascal,
["subscription"] = camel,
};
} }
private async Task<Data.Entities.StoreItemEntity?> ResolveItemAsync(PurchaseRequest body) private async Task<Data.Entities.StoreItemEntity?> ResolveItemAsync(PurchaseRequest body)
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification; using DorkNet.Models.Notification;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.API.RoomKeys;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities; using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services; using DorkNet.Server.Services;
@@ -103,7 +102,6 @@ public class StorefrontsBuyController(
StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out avatarItemDesc); StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out avatarItemDesc);
var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc); var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc);
StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc); StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc);
StoreService.TryGetEquipmentPayload(item.Slug, out var equipmentPrefabName, out var equipmentModificationGuid);
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid)) if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{ {
avatarItemType = 1; avatarItemType = 1;
@@ -117,8 +115,6 @@ public class StorefrontsBuyController(
AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType, AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType,
AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc, AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc,
ConsumableItemDesc = consumableItemDesc, ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = equipmentPrefabName,
EquipmentModificationGuid = equipmentModificationGuid,
CurrencyType = req.CurrencyType, CurrencyType = req.CurrencyType,
Currency = 0, Currency = 0,
Xp = 0, Xp = 0,
@@ -161,8 +157,6 @@ public class StorefrontsBuyController(
AvatarItemType = gift.AvatarItemType, AvatarItemType = gift.AvatarItemType,
AvatarItemDesc = avatarItemDesc, AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty, ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
EquipmentPrefabName = gift.EquipmentPrefabName,
EquipmentModificationGuid = gift.EquipmentModificationGuid,
Message = gift.Message ?? string.Empty, Message = gift.Message ?? string.Empty,
}); });
} }
@@ -201,47 +195,6 @@ public class StorefrontsBuyController(
return Ok(BalanceUpdateResponse(balance, req.CurrencyType)); return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
} }
/// <summary>GET <c>api/storefronts/v1/buyRoomKey</c> — purchase a
/// persisted room key. The 2020.12 client sends roomKeyId and the
/// price it displayed so we reject stale-price purchases.</summary>
[HttpGet("api/storefronts/v1/buyRoomKey")]
public async Task<IActionResult> BuyRoomKey(
[FromQuery] long roomKeyId,
[FromQuery] int requestedPrice)
{
var pid = Me;
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == roomKeyId && !k.IsDeleted);
if (key is null)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.DoesNotExist, null, await level.GetBalanceAsync(pid, 2)));
if (key.CreatorPlayerId == pid)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PlayerAlreadyOwns, key, await level.GetBalanceAsync(pid, 2)));
if (requestedPrice != key.Price)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.InvalidParameters, key, await level.GetBalanceAsync(pid, 2)));
var existing = await db.RoomKeyPurchases
.FirstOrDefaultAsync(p => p.RoomKeyId == key.Id && p.PlayerId == pid);
if (existing is not null)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PlayerAlreadyOwns, key, await level.GetBalanceAsync(pid, 2)));
var balance = await level.GetBalanceAsync(pid, 2);
if (balance < key.Price)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PurchaseFailed, key, balance));
var newBalance = await level.GrantCurrencyAsync(pid, 2, -key.Price, $"buyRoomKey:{key.Id}");
db.RoomKeyPurchases.Add(new RoomKeyPurchaseEntity
{
RoomKeyId = key.Id,
PlayerId = pid,
PaidPrice = key.Price,
});
await db.SaveChangesAsync();
if (key.CreatorPlayerId != pid && key.Price > 0)
await level.GrantCurrencyAsync(key.CreatorPlayerId, 2, key.Price, $"sellRoomKey:{key.Id}");
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
}
// ── Helpers ────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────
private async Task GrantWardrobeAsync(long pid, string guid) private async Task GrantWardrobeAsync(long pid, string guid)
@@ -346,13 +299,10 @@ public class StorefrontsBuyController(
var avatarItemType = 0; var avatarItemType = 0;
var avatarItemDesc = string.Empty; var avatarItemDesc = string.Empty;
var consumableItemDesc = string.Empty; var consumableItemDesc = string.Empty;
var equipmentPrefabName = string.Empty;
var equipmentModificationGuid = string.Empty;
if (grantedSlug is not null) if (grantedSlug is not null)
{ {
StoreService.TryGetAvatarItemPayload(grantedSlug, out avatarItemType, out avatarItemDesc); StoreService.TryGetAvatarItemPayload(grantedSlug, out avatarItemType, out avatarItemDesc);
StoreService.TryGetConsumableItemDesc(grantedSlug, out consumableItemDesc); StoreService.TryGetConsumableItemDesc(grantedSlug, out consumableItemDesc);
StoreService.TryGetEquipmentPayload(grantedSlug, out equipmentPrefabName, out equipmentModificationGuid);
if (StoreService.TryGetHairDyePayload(grantedSlug, out var hairDyeConsumableDesc, out var hairDyeColorGuid)) if (StoreService.TryGetHairDyePayload(grantedSlug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{ {
avatarItemType = 1; avatarItemType = 1;
@@ -364,18 +314,8 @@ public class StorefrontsBuyController(
var giftPackage = new var giftPackage = new
{ {
// REQUIRED via Util.GetKey — KeyNotFoundException on miss. // REQUIRED via Util.GetKey — KeyNotFoundException on miss.
// Verified keys at NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946:
// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc,
// ConsumableItemDesc, EquipmentPrefabName,
// EquipmentModificationGuid, Platform, PlatformsToSpawnOn,
// BalanceType, CurrencyType, Currency, Xp, GiftRarity,
// GiftContext. Missing any → "Failed to download gifts:
// Malformed Response" (and on a purchase path, the buy
// times out because the response never deserializes).
Id = giftId, Id = giftId,
Platform = 0, Platform = 0,
PlatformsToSpawnOn = -1,
BalanceType = 0,
Xp = 0, Xp = 0,
Level = 1, Level = 1,
GiftRarity = 0, GiftRarity = 0,
@@ -386,8 +326,8 @@ public class StorefrontsBuyController(
AvatarItemDesc = avatarItemDesc, AvatarItemDesc = avatarItemDesc,
AvatarItemDescOrHairDyeDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc), AvatarItemDescOrHairDyeDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc),
ConsumableItemDesc = consumableItemDesc, ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = equipmentPrefabName, EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = equipmentModificationGuid, EquipmentModificationGuid = string.Empty,
CurrencyType = currencyType, CurrencyType = currencyType,
Currency = 0, Currency = 0,
Message = string.Empty, Message = string.Empty,
@@ -415,16 +355,4 @@ public class StorefrontsBuyController(
}, },
}; };
} }
private static object RoomKeyPurchaseResponse(RoomKeyStatus status, RoomKeyEntity? key, long balance) => new
{
RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key),
BalanceUpdateResponse = new
{
Balance = balance,
CurrencyType = 2,
BalanceType = 0,
BalanceUpdates = Array.Empty<object>(),
},
};
} }
@@ -50,23 +50,6 @@ public class StorefrontsController(
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray()); return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
} }
/// <summary>GET <c>api/storefronts/v1/p2p/betaEnabled</c> —
/// raw Boolean gate for player-to-player sales. Returning false
/// disables the beta affordance without lying about catalog state.</summary>
[HttpGet("api/storefronts/v1/p2p/betaEnabled")]
[AllowAnonymous]
public IActionResult P2PBetaEnabled() => Content("false", "application/json");
/// <summary>GET <c>api/storefronts/v1/trialInvention/duration</c>.
/// The 2020.12 client's public promise projects the Duration
/// property from this object, so the wire body is not a bare int.</summary>
[HttpGet("api/storefronts/v1/trialInvention/duration")]
[AllowAnonymous]
public IActionResult TrialInventionDuration() => Ok(new
{
Duration = Math.Max(0, config.GetValue("Store:TrialInventionDurationSeconds", 300)),
});
/// <summary>GET api/storefronts/v3/season — items currently in the /// <summary>GET api/storefronts/v3/season — items currently in the
/// season-pass storefront. Empty for now since we don't run /// season-pass storefront. Empty for now since we don't run
/// seasons; returning an empty list rather than the catch-all so /// seasons; returning an empty list rather than the catch-all so
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Version;
[ApiController]
public class VersionController : ControllerBase
{
// VersionStatus enum: ValidForPlay=0, ValidForMenu=1, UpdateRequired=2
private static readonly object ValidForPlay = new { VersionStatus = 0 };
[HttpGet("api/version/v1")]
[HttpGet("api/version")]
public ActionResult<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);
}
@@ -393,10 +393,19 @@ public class AccountsController(
logger.LogInformation( logger.LogInformation(
"[accounts] profile image set: player={PlayerId} → {Name}", "[accounts] profile image set: player={PlayerId} → {Name}",
player.Id, player.ProfileImageName ?? "<cleared>"); player.Id, player.ProfileImageName ?? "<cleared>");
await notifications.AccountChanged(player); await NotifyAccountImageChangedAsync(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty }); 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) private static int AgeYears(DateTime birthday)
{ {
var today = DateTime.UtcNow.Date; var today = DateTime.UtcNow.Date;
@@ -431,7 +440,6 @@ public class AccountsController(
if (player is null) return Unauthorized(); if (player is null) return Unauthorized();
await playerService.UpdateUsernameAsync(player.Id, username.Trim()); await playerService.UpdateUsernameAsync(player.Id, username.Trim());
var updated = await playerService.GetByIdAsync(player.Id); var updated = await playerService.GetByIdAsync(player.Id);
if (updated is not null) await notifications.AccountChanged(updated);
return Ok(new return Ok(new
{ {
success = true, success = true,
@@ -460,8 +468,6 @@ public class AccountsController(
var player = await GetCurrentPlayerAsync(); var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized(); if (player is null) return Unauthorized();
await playerService.UpdateDisplayNameAsync(player.Id, displayName.Trim()); await playerService.UpdateDisplayNameAsync(player.Id, displayName.Trim());
var updated = await playerService.GetByIdAsync(player.Id);
if (updated is not null) await notifications.AccountChanged(updated);
return Ok(new { success = true, error = "" }); return Ok(new { success = true, error = "" });
} }
@@ -75,6 +75,7 @@ public class AdminController(
q = q.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query)); q = q.Where(p => p.Username.Contains(query) || p.DisplayName.Contains(query));
var page = await q.Skip(skip).Take(take).ToListAsync(); var page = await q.Skip(skip).Take(take).ToListAsync();
var onlineSet = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(page.Select(p => new return Ok(page.Select(p => new
{ {
p.Id, p.Id,
@@ -93,7 +94,7 @@ public class AdminController(
p.Level, p.Level,
p.XP, p.XP,
p.ProfileImageName, p.ProfileImageName,
Online = playerPresence.IsRecentlyActive(p.Id), Online = onlineSet.Contains(p.Id),
})); }));
} }
@@ -132,7 +133,7 @@ public class AdminController(
p.Level, p.Level,
p.XP, p.XP,
p.ProfileImageName, p.ProfileImageName,
Online = playerPresence.IsRecentlyActive(p.Id), Online = onlinePresence.OnlinePlayerIds().Contains(p.Id),
Balances = balances, Balances = balances,
Avatar = avatar, Avatar = avatar,
}); });
@@ -158,8 +159,8 @@ public class AdminController(
await LogAsync("ban_player", "player", id, body.Reason ?? ""); await LogAsync("ban_player", "player", id, body.Reason ?? "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.KickPlayerAsync(id, body.Reason ?? "You have been banned."); await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
playerPresence.Clear(id); new { Reason = body.Reason ?? "", Until = player.BannedUntil });
return Ok(new { player.Id, player.BannedUntil }); return Ok(new { player.Id, player.BannedUntil });
} }
@@ -203,56 +204,6 @@ public class AdminController(
return Ok(new { player.Id, player.IsAdmin }); return Ok(new { player.Id, player.IsAdmin });
} }
public sealed record DeletePlayerRequest(string ConfirmUsername, string ConfirmPhrase, string? Reason);
/// <summary>Hard-remove a non-admin player account. The destructive
/// endpoint requires two exact confirmations: the current username and
/// the phrase <c>DELETE {id}</c>. Personal rows are deleted, authored
/// durable content is reassigned to the system account, and live sessions
/// are kicked so stale bearer tokens stop being useful immediately.</summary>
[HttpDelete("players/{id:long}")]
public async Task<ActionResult> DeletePlayer(long id, [FromBody] DeletePlayerRequest body)
{
if (id == 1)
return BadRequest(new { Error = "Cannot delete the system account." });
if (id == CurrentAdminId)
return BadRequest(new { Error = "Cannot delete your own admin account." });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == id);
if (player is null) return NotFound();
if (player.IsAdmin)
return BadRequest(new { Error = "Cannot delete an admin account. Demote it first." });
var expectedPhrase = $"DELETE {player.Id}";
if (!string.Equals(body.ConfirmUsername?.Trim(), player.Username, StringComparison.Ordinal)
|| !string.Equals(body.ConfirmPhrase?.Trim(), expectedPhrase, StringComparison.Ordinal))
return BadRequest(new { Error = $"Confirmation failed. Type @{player.Username} and {expectedPhrase} exactly." });
var reason = string.IsNullOrWhiteSpace(body.Reason)
? $"removed @{player.Username}"
: body.Reason.Trim();
var systemPlayerId = await db.Players.AnyAsync(p => p.Id == 1) ? 1L : CurrentAdminId;
await using var tx = await db.Database.BeginTransactionAsync();
var summary = await DeletePlayerDataAsync(id, systemPlayerId);
db.Players.Remove(player);
await LogAsync("delete_player", "player", id, $"{reason}; {summary}");
await db.SaveChangesAsync();
await tx.CommitAsync();
await notifications.KickPlayerAsync(id, "Your account has been removed by an admin.");
playerPresence.Clear(id);
return Ok(new
{
deleted = id,
username = player.Username,
reassignedToPlayerId = systemPlayerId,
summary,
});
}
// ── Rooms ──────────────────────────────────────────────────────────── // ── Rooms ────────────────────────────────────────────────────────────
public sealed record DeleteRoomRequest(string? Reason); public sealed record DeleteRoomRequest(string? Reason);
@@ -400,7 +351,6 @@ public class AdminController(
{ {
r.Id, r.Id,
r.Name, r.Name,
r.ImageName,
r.IsAGRoom, r.IsAGRoom,
r.IsDormRoom, r.IsDormRoom,
r.CreatorPlayerId, r.CreatorPlayerId,
@@ -531,191 +481,6 @@ public class AdminController(
}); });
} }
public sealed record WipeImportTargetsRequest(
long[]? InventionIds,
string[]? RoomNames);
/// <summary>POST <c>api/admin/v1/import/wipe-targets</c> — clear the
/// DB rows that would conflict with a zip re-import. Built for the
/// zip importer's "Wipe duplicates first" affordance — when the
/// admin uploads a refreshed zip after a recovery pass added more
/// .inv bytes, the second import would otherwise: (a) reject every
/// room with <c>duplicate_name</c>, and (b) silently auto-id every
/// invention whose original id is already in the DB, leaving the
/// room blob's placement refs pointing at orphans (= empty/missing
/// inventions in-game).
///
/// Wipes:
/// * <see cref="InventionEntity"/> rows for the listed ids + their
/// associated <see cref="RoomDataBlobEntity"/> rows (.inv bytes
/// + image thumbnails)
/// * <see cref="RoomEntity"/> rows matching any of the listed
/// names exactly + their <see cref="RoomSceneEntity"/> rows +
/// <see cref="RoomDataBlobEntity"/> rows (scene blobs + room
/// image)
///
/// Skips dorms and seed-owned RR-Originals (same safety net as
/// <see cref="HardDeleteRoom"/>) — only user-content survives the
/// wipe path.
///
/// Returns a per-room and per-invention rollup so the UI can show
/// what landed before kicking off the actual upload.</summary>
[HttpPost("import/wipe-targets")]
public async Task<ActionResult> WipeImportTargets([FromBody] WipeImportTargetsRequest body)
{
var inventionIds = body?.InventionIds ?? Array.Empty<long>();
var roomNames = body?.RoomNames ?? Array.Empty<string>();
if (inventionIds.Length == 0 && roomNames.Length == 0)
return BadRequest(new { error = "nothing_to_wipe" });
// ── Inventions ────────────────────────────────────────────────
var inventions = inventionIds.Length == 0
? new List<InventionEntity>()
: await db.Inventions.Where(i => inventionIds.Contains(i.Id)).ToListAsync();
var invBlobNames = inventions
.SelectMany(i => new[] { i.CurrentBlobName, i.ImageName })
.Where(n => !string.IsNullOrEmpty(n))
.Cast<string>()
.Distinct()
.ToList();
var invBlobs = invBlobNames.Count == 0
? new List<RoomDataBlobEntity>()
: await db.RoomDataBlobs
.Where(b => b.RoomId == 0 && invBlobNames.Contains(b.BlobName))
.ToListAsync();
// ── Rooms ─────────────────────────────────────────────────────
// Exact-name match — same key the importer uses for its
// duplicate-name rejection. Skip dorms + seed-owned RR-Originals
// so a fat-finger on the wipe list can't nuke canonical content.
var roomMatches = roomNames.Length == 0
? new List<RoomEntity>()
: await db.Rooms.Where(r => roomNames.Contains(r.Name)).ToListAsync();
var roomsToDelete = roomMatches
.Where(r => !r.IsDormRoom
&& !(r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId))
.ToList();
var roomIds = roomsToDelete.Select(r => r.Id).ToList();
var roomScenes = roomIds.Count == 0
? new List<RoomSceneEntity>()
: await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId)).ToListAsync();
var roomBlobs = roomIds.Count == 0
? new List<RoomDataBlobEntity>()
: await db.RoomDataBlobs.Where(b => roomIds.Contains(b.RoomId)).ToListAsync();
var roomImageNames = roomsToDelete
.Select(r => r.ImageName)
.Where(n => !string.IsNullOrEmpty(n))
.Cast<string>()
.Distinct()
.ToList();
var roomImages = roomImageNames.Count == 0
? new List<RoomDataBlobEntity>()
: await db.RoomDataBlobs
.Where(b => b.RoomId == 0 && roomImageNames.Contains(b.BlobName))
.ToListAsync();
db.RoomDataBlobs.RemoveRange(invBlobs);
db.Inventions.RemoveRange(inventions);
db.RoomScenes.RemoveRange(roomScenes);
db.RoomDataBlobs.RemoveRange(roomBlobs);
db.RoomDataBlobs.RemoveRange(roomImages);
db.Rooms.RemoveRange(roomsToDelete);
await LogAsync("wipe_import_targets", "import", 0,
$"inventions={inventions.Count}/{inventionIds.Length} " +
$"rooms={roomsToDelete.Count}/{roomNames.Length} " +
$"roomBlobs={roomBlobs.Count} roomScenes={roomScenes.Count} " +
$"invBlobs={invBlobs.Count} roomImages={roomImages.Count}");
await db.SaveChangesAsync();
var skippedRooms = roomMatches
.Where(r => r.IsDormRoom
|| (r.IsAGRoom && r.CreatorPlayerId == PlayerService.SystemAccountId))
.Select(r => new { id = r.Id, name = r.Name, reason = r.IsDormRoom ? "dorm" : "seeded_rr_original" })
.ToList();
return Ok(new
{
inventionsDeleted = inventions.Count,
inventionsRequested = inventionIds.Length,
inventionsNotFound = inventionIds.Length - inventions.Count,
inventionBlobsRemoved = invBlobs.Count,
roomsDeleted = roomsToDelete.Count,
roomsRequested = roomNames.Length,
roomsNotFound = roomNames.Length - roomMatches.Count,
roomScenesRemoved = roomScenes.Count,
roomBlobsRemoved = roomBlobs.Count + roomImages.Count,
skippedRooms,
});
}
public sealed record PurgeInventionsRequest(
long[]? InventionIds,
// The caller must echo back the count of ids it expects to delete.
// Mismatch = client and server disagree about scope → refuse.
// Same pattern as `git rm` requiring -f when files would be lost.
int? ExpectedCount);
/// <summary>POST <c>api/admin/v1/inventions/purge</c> — hard-delete a
/// batch of inventions by id. Used by the zip-recovery workflow to
/// wipe a room's referenced inventions before re-importing a refreshed
/// zip — avoids the duplicate-id problem where the zip importer
/// auto-generates new ids when the original is already in the DB,
/// leaving the room blob's invention references pointing at orphans.
///
/// Deletes:
/// * <see cref="InventionEntity"/> rows for the given ids
/// * Associated <see cref="RoomDataBlobEntity"/> rows for both the
/// invention's <c>CurrentBlobName</c> (the .inv bytes) and
/// <c>ImageName</c> (the thumbnail). RoomId on those rows is 0
/// for invention-owned blobs.
///
/// Does NOT touch S3 — the importer will overwrite on re-import, and
/// orphan S3 keys are harmless (read path lookups them by BlobName
/// from the DB).
///
/// Logs each delete to AdminActions for audit replay.
/// </summary>
[HttpPost("inventions/purge")]
public async Task<ActionResult> PurgeInventions([FromBody] PurgeInventionsRequest body)
{
var ids = body?.InventionIds;
if (ids is null || ids.Length == 0)
return BadRequest(new { error = "missing_ids" });
if (body!.ExpectedCount is not int ec || ec != ids.Length)
return BadRequest(new { error = "expected_count_mismatch", expected = body.ExpectedCount, actual = ids.Length });
var inventions = await db.Inventions
.Where(i => ids.Contains(i.Id))
.ToListAsync();
var blobNames = inventions
.SelectMany(i => new[] { i.CurrentBlobName, i.ImageName })
.Where(n => !string.IsNullOrEmpty(n))
.Cast<string>()
.Distinct()
.ToList();
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == 0 && blobNames.Contains(b.BlobName))
.ToListAsync();
db.RoomDataBlobs.RemoveRange(blobs);
db.Inventions.RemoveRange(inventions);
await LogAsync("purge_inventions", "invention", 0,
$"count={inventions.Count} blobs={blobs.Count} ids=[{string.Join(",", inventions.Select(i => i.Id).Take(20))}{(inventions.Count > 20 ? ",..." : "")}]");
await db.SaveChangesAsync();
return Ok(new
{
deleted = inventions.Count,
requested = ids.Length,
notFound = ids.Length - inventions.Count,
blobsRemoved = blobs.Count,
});
}
// ── Leaderboards ───────────────────────────────────────────────────── // ── Leaderboards ─────────────────────────────────────────────────────
/// <summary>Verified stat-channel metadata. The 2020 watch's /// <summary>Verified stat-channel metadata. The 2020 watch's
@@ -749,31 +514,28 @@ public class AdminController(
public async Task<ActionResult> ListChannels() public async Task<ActionResult> ListChannels()
{ {
var rows = await db.LeaderboardStats var rows = await db.LeaderboardStats
.GroupBy(s => new { s.RoomId, s.StatChannel }) .GroupBy(s => s.StatChannel)
.Select(g => new .Select(g => new
{ {
g.Key.RoomId, Channel = g.Key,
Channel = g.Key.StatChannel,
EntryCount = g.Count(), EntryCount = g.Count(),
MinValue = g.Min(x => x.Value), MinValue = g.Min(x => x.Value),
MaxValue = g.Max(x => x.Value), MaxValue = g.Max(x => x.Value),
}) })
.OrderBy(r => r.RoomId) .OrderBy(r => r.Channel)
.ThenBy(r => r.Channel)
.ToListAsync(); .ToListAsync();
// DB-defined meta beats hardcoded KnownStatChannels so admins // DB-defined meta beats hardcoded KnownStatChannels so admins
// can rename / reassign channels live without a code change. // can rename / reassign channels live without a code change.
var dbMeta = await db.LeaderboardChannelMeta var dbMeta = await db.LeaderboardChannelMeta.ToDictionaryAsync(c => c.Channel);
.ToDictionaryAsync(c => new { c.RoomId, c.Channel });
var labelled = rows.Select(r => var labelled = rows.Select(r =>
{ {
dbMeta.TryGetValue(new { r.RoomId, r.Channel }, out var live); dbMeta.TryGetValue(r.Channel, out var live);
KnownStatChannels.TryGetValue(r.Channel, out var fallback); KnownStatChannels.TryGetValue(r.Channel, out var fallback);
return new return new
{ {
r.Channel, r.Channel,
Name = live?.Name is { Length: > 0 } n ? n : (fallback?.Name ?? $"Channel {r.Channel}"), Name = live?.Name is { Length: > 0 } n ? n : (fallback?.Name ?? $"Channel {r.Channel}"),
RoomId = r.RoomId, RoomId = live?.RoomId ?? 0,
LowerIsBetter = live?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false, LowerIsBetter = live?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false,
ValueFormat = live?.ValueFormat ?? fallback?.ValueFormat ?? "count", ValueFormat = live?.ValueFormat ?? fallback?.ValueFormat ?? "count",
Verified = live is not null || fallback is not null, Verified = live is not null || fallback is not null,
@@ -803,16 +565,16 @@ public class AdminController(
/// the channel's natural direction (lower-is-better channels /// the channel's natural direction (lower-is-better channels
/// sorted ascending, everything else descending).</summary> /// sorted ascending, everything else descending).</summary>
[HttpGet("leaderboards/{channel:int}")] [HttpGet("leaderboards/{channel:int}")]
public async Task<ActionResult> ChannelDetail(int channel, [FromQuery] long roomId = 0, [FromQuery] int take = 100) public async Task<ActionResult> ChannelDetail(int channel, [FromQuery] int take = 100)
{ {
take = Math.Clamp(take, 1, 1000); take = Math.Clamp(take, 1, 1000);
var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == channel); var liveMeta = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
KnownStatChannels.TryGetValue(channel, out var fallback); KnownStatChannels.TryGetValue(channel, out var fallback);
var name = liveMeta?.Name is { Length: > 0 } ln ? ln : (fallback?.Name ?? $"Channel {channel}"); var name = liveMeta?.Name is { Length: > 0 } ln ? ln : (fallback?.Name ?? $"Channel {channel}");
var lowerIsBetter = liveMeta?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false; var lowerIsBetter = liveMeta?.LowerIsBetter ?? fallback?.LowerIsBetter ?? false;
var valueFormat = liveMeta?.ValueFormat ?? fallback?.ValueFormat ?? "count"; var valueFormat = liveMeta?.ValueFormat ?? fallback?.ValueFormat ?? "count";
var query = db.LeaderboardStats var query = db.LeaderboardStats
.Where(s => s.RoomId == roomId && s.StatChannel == channel); .Where(s => s.StatChannel == channel);
query = lowerIsBetter query = lowerIsBetter
? query.OrderBy(s => s.Value) ? query.OrderBy(s => s.Value)
: query.OrderByDescending(s => s.Value); : query.OrderByDescending(s => s.Value);
@@ -833,7 +595,7 @@ public class AdminController(
{ {
Channel = channel, Channel = channel,
Name = name, Name = name,
RoomId = roomId, RoomId = liveMeta?.RoomId ?? 0,
LowerIsBetter = lowerIsBetter, LowerIsBetter = lowerIsBetter,
ValueFormat = valueFormat, ValueFormat = valueFormat,
Verified = liveMeta is not null || fallback is not null, Verified = liveMeta is not null || fallback is not null,
@@ -850,7 +612,7 @@ public class AdminController(
}); });
} }
public sealed record SetLeaderboardScoreRequest(long PlayerId, int Value, long RoomId = 0); public sealed record SetLeaderboardScoreRequest(long PlayerId, int Value);
/// <summary>POST <c>api/admin/v1/leaderboards/{channel}/score</c> /// <summary>POST <c>api/admin/v1/leaderboards/{channel}/score</c>
/// — upsert a single player's score in a channel.</summary> /// — upsert a single player's score in a channel.</summary>
@@ -858,13 +620,12 @@ public class AdminController(
public async Task<ActionResult> SetScore(int channel, [FromBody] SetLeaderboardScoreRequest body) public async Task<ActionResult> SetScore(int channel, [FromBody] SetLeaderboardScoreRequest body)
{ {
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == body.RoomId && s.PlayerId == body.PlayerId && s.StatChannel == channel); s.PlayerId == body.PlayerId && s.StatChannel == channel);
if (row is null) if (row is null)
{ {
row = new LeaderboardStatEntity row = new LeaderboardStatEntity
{ {
PlayerId = body.PlayerId, PlayerId = body.PlayerId,
RoomId = body.RoomId,
StatChannel = channel, StatChannel = channel,
Value = body.Value, Value = body.Value,
}; };
@@ -875,37 +636,35 @@ public class AdminController(
row.Value = body.Value; row.Value = body.Value;
row.UpdatedAt = DateTime.UtcNow; row.UpdatedAt = DateTime.UtcNow;
} }
await LogAsync("set_leaderboard_score", "player", body.PlayerId, $"room={body.RoomId} channel={channel} value={body.Value}"); await LogAsync("set_leaderboard_score", "player", body.PlayerId, $"channel={channel} value={body.Value}");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { row.PlayerId, row.RoomId, row.StatChannel, row.Value }); return Ok(new { row.PlayerId, row.StatChannel, row.Value });
} }
[HttpDelete("leaderboards/{channel:int}/score/{playerId:long}")] [HttpDelete("leaderboards/{channel:int}/score/{playerId:long}")]
public async Task<ActionResult> DeleteScore(int channel, long playerId, [FromQuery] long roomId = 0) public async Task<ActionResult> DeleteScore(int channel, long playerId)
{ {
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.PlayerId == playerId && s.StatChannel == channel); s.PlayerId == playerId && s.StatChannel == channel);
if (row is null) return NotFound(); if (row is null) return NotFound();
db.LeaderboardStats.Remove(row); db.LeaderboardStats.Remove(row);
await LogAsync("delete_leaderboard_score", "player", playerId, $"room={roomId} channel={channel}"); await LogAsync("delete_leaderboard_score", "player", playerId, $"channel={channel}");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { deleted = playerId, roomId, channel }); return Ok(new { deleted = playerId, channel });
} }
/// <summary>DELETE <c>api/admin/v1/leaderboards/{channel}</c> — /// <summary>DELETE <c>api/admin/v1/leaderboards/{channel}</c> —
/// wipe every entry in a stat channel. Used for season resets or /// wipe every entry in a stat channel. Used for season resets or
/// to clear test data.</summary> /// to clear test data.</summary>
[HttpDelete("leaderboards/{channel:int}")] [HttpDelete("leaderboards/{channel:int}")]
public async Task<ActionResult> WipeChannel(int channel, [FromQuery] long roomId = 0) public async Task<ActionResult> WipeChannel(int channel)
{ {
var rows = await db.LeaderboardStats var rows = await db.LeaderboardStats.Where(s => s.StatChannel == channel).ToListAsync();
.Where(s => s.RoomId == roomId && s.StatChannel == channel) if (rows.Count == 0) return Ok(new { wiped = 0, channel });
.ToListAsync();
if (rows.Count == 0) return Ok(new { wiped = 0, roomId, channel });
db.LeaderboardStats.RemoveRange(rows); db.LeaderboardStats.RemoveRange(rows);
await LogAsync("wipe_leaderboard_channel", "system", channel, $"room={roomId} entries={rows.Count}"); await LogAsync("wipe_leaderboard_channel", "system", channel, $"entries={rows.Count}");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { wiped = rows.Count, roomId, channel }); return Ok(new { wiped = rows.Count, channel });
} }
// ── Rec Room Originals (RROs) ──────────────────────────────────────── // ── Rec Room Originals (RROs) ────────────────────────────────────────
@@ -953,8 +712,7 @@ public class AdminController(
string? TagsCsv, string? TagsCsv,
double? HotScore, double? HotScore,
int? State, int? State,
string? ImageName, string? ImageName);
int? MaxCapacity);
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial /// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
/// update of room properties. Only the non-null fields in the /// update of room properties. Only the non-null fields in the
@@ -977,13 +735,6 @@ public class AdminController(
if (body.HotScore is double h) { room.HotScore = h; changes.Add($"hot={h}"); } 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.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 (body.ImageName is string img) { room.ImageName = img; changes.Add($"image={img}"); }
if (body.MaxCapacity is int cap)
{
// Clamp to a sane range. Note: advertised cap only on the 2020.12
// client today (no Photon hard-enforce) — see RoomEntity.MaxCapacity.
room.MaxCapacity = Math.Clamp(cap, 1, 80);
changes.Add($"maxCapacity={room.MaxCapacity}");
}
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true }); if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
@@ -1047,7 +798,6 @@ public class AdminController(
room.SupportsWalkVR, room.SupportsWalkVR,
room.SupportsTeleportVR, room.SupportsTeleportVR,
room.AllowsJuniors, room.AllowsJuniors,
room.MaxCapacity,
room.DisableMicAutoMute, room.DisableMicAutoMute,
room.RoomWarningMask, room.RoomWarningMask,
room.CustomRoomWarning, room.CustomRoomWarning,
@@ -1092,12 +842,6 @@ public class AdminController(
if (body.PlayerId <= 0) return BadRequest("missing player"); if (body.PlayerId <= 0) return BadRequest("missing player");
if (body.Role is < 0 or > 2) return BadRequest("invalid role"); if (body.Role is < 0 or > 2) return BadRequest("invalid role");
var oldRoles = await db.RoomRoles
.Where(r => r.RoomId == id && r.PlayerId == body.PlayerId && r.Role != body.Role)
.ToListAsync();
if (oldRoles.Count > 0)
db.RoomRoles.RemoveRange(oldRoles);
var existing = await db.RoomRoles.FirstOrDefaultAsync(r => var existing = await db.RoomRoles.FirstOrDefaultAsync(r =>
r.RoomId == id && r.PlayerId == body.PlayerId && r.Role == body.Role); r.RoomId == id && r.PlayerId == body.PlayerId && r.Role == body.Role);
if (existing is null) if (existing is null)
@@ -1117,7 +861,6 @@ public class AdminController(
} }
await LogAsync("room_role_add", "room", id, $"player={body.PlayerId} role={body.Role}"); await LogAsync("room_role_add", "room", id, $"player={body.PlayerId} role={body.Role}");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyRoomRolesChangedAsync(id, body.PlayerId, "AdminRoleGrant");
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
@@ -1132,7 +875,6 @@ public class AdminController(
db.RoomRoles.Remove(row); db.RoomRoles.Remove(row);
await LogAsync("room_role_remove", "room", id, $"player={playerId} role={role}"); await LogAsync("room_role_remove", "room", id, $"player={playerId} role={role}");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyRoomRolesChangedAsync(id, playerId, "AdminRoleRevoke");
return Ok(new { ok = true, deleted = 1 }); return Ok(new { ok = true, deleted = 1 });
} }
@@ -1172,44 +914,9 @@ public class AdminController(
await LogAsync("room_transfer_owner", "room", id, $"from={previousOwner} to={body.NewCreatorPlayerId}"); await LogAsync("room_transfer_owner", "room", id, $"from={previousOwner} to={body.NewCreatorPlayerId}");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyRoomRolesChangedAsync(id, previousOwner, "AdminOwnerTransfer");
await NotifyRoomRolesChangedAsync(id, body.NewCreatorPlayerId, "AdminOwnerTransfer");
return Ok(new { ok = true }); return Ok(new { ok = true });
} }
/// <summary>Push fresh RoomDetails after role/owner mutations. The
/// 2020 watch caches RoomDetails, including CoOwners/Moderators/Hosts,
/// and role-gated UI reads that cache until Rooms.OnSubscriptionUpdateRoom
/// stores a replacement.</summary>
private async Task NotifyRoomRolesChangedAsync(long roomId, long changedPlayerId, string reason)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return;
var scenes = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roles = await db.RoomRoles
.Where(r => r.RoomId == roomId)
.ToListAsync();
var payload = DorkNet.Server.Controllers.API.Rooms.V2.RoomsController
.BuildRoomDetails(room, scenes, roles: roles);
var recipients = onlinePresence.OnlinePlayerIds()
.Where(pid => playerPresence.GetRoom(pid)?.RoomId == roomId)
.Append(changedPlayerId)
.Distinct()
.ToList();
adminLogger.LogInformation(
"[admin-room-role] pushing room details room={RoomId} reason={Reason} changedPlayer={PlayerId} recipients={RecipientCount}",
roomId, reason, changedPlayerId, recipients.Count);
foreach (var pid in recipients)
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, payload);
}
/// <summary>GET <c>api/admin/v1/rooms/{id}/instances</c> — active /// <summary>GET <c>api/admin/v1/rooms/{id}/instances</c> — active
/// Photon instances for one specific room. Subset of the global /// Photon instances for one specific room. Subset of the global
/// <c>/instances</c> endpoint pre-filtered to one room; the admin /// <c>/instances</c> endpoint pre-filtered to one room; the admin
@@ -1234,7 +941,7 @@ public class AdminController(
.Select(r => new { r.Id, r.CreatorPlayerId, r.IsDormRoom }) .Select(r => new { r.Id, r.CreatorPlayerId, r.IsDormRoom })
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
var onlinePids = playerPresence.RecentlyActivePlayerIds().ToList(); 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)>(); 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) foreach (var pid in onlinePids)
{ {
@@ -1300,7 +1007,7 @@ public class AdminController(
// route the pulled player into. If nobody's online in the // route the pulled player into. If nobody's online in the
// instance anymore the instance has effectively died; reject. // instance anymore the instance has effectively died; reject.
DorkNet.Server.Controllers.Match.RoomInstanceDto? sample = null; DorkNet.Server.Controllers.Match.RoomInstanceDto? sample = null;
foreach (var pid in playerPresence.RecentlyActivePlayerIds()) foreach (var pid in onlinePresence.OnlinePlayerIds())
{ {
var r = playerPresence.GetRoom(pid); var r = playerPresence.GetRoom(pid);
if (r is not null && r.RoomId == id && r.RoomInstanceId == instanceId) if (r is not null && r.RoomId == id && r.RoomInstanceId == instanceId)
@@ -1336,11 +1043,11 @@ public class AdminController(
{ {
var reason = body?.Reason ?? "Instance closed by admin"; var reason = body?.Reason ?? "Instance closed by admin";
var kicked = new List<long>(); var kicked = new List<long>();
foreach (var pid in playerPresence.RecentlyActivePlayerIds().ToList()) foreach (var pid in onlinePresence.OnlinePlayerIds().ToList())
{ {
var r = playerPresence.GetRoom(pid); var r = playerPresence.GetRoom(pid);
if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue; if (r is null || r.RoomId != id || r.RoomInstanceId != instanceId) continue;
await notifications.KickPlayerAsync(pid, reason); await notifications.NotifyAsync(pid, PushNotificationId.ModerationKick, new { Reason = reason });
playerPresence.Clear(pid); playerPresence.Clear(pid);
kicked.Add(pid); kicked.Add(pid);
} }
@@ -1369,7 +1076,7 @@ public class AdminController(
var channels = metas.Select(m => m.Channel).ToList(); var channels = metas.Select(m => m.Channel).ToList();
var counts = await db.LeaderboardStats var counts = await db.LeaderboardStats
.Where(s => s.RoomId == id && channels.Contains(s.StatChannel)) .Where(s => channels.Contains(s.StatChannel))
.GroupBy(s => 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) }) .Select(g => new { Channel = g.Key, Count = g.Count(), Best = g.Max(x => x.Value), Worst = g.Min(x => x.Value) })
.ToListAsync(); .ToListAsync();
@@ -1400,11 +1107,10 @@ public class AdminController(
public async Task<ActionResult> UpsertChannelMeta([FromBody] UpsertChannelMetaRequest body) public async Task<ActionResult> UpsertChannelMeta([FromBody] UpsertChannelMetaRequest body)
{ {
if (body is null) return BadRequest("missing body"); if (body is null) return BadRequest("missing body");
var row = await db.LeaderboardChannelMeta var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == body.Channel);
.FirstOrDefaultAsync(c => c.RoomId == body.RoomId && c.Channel == body.Channel);
if (row is null) if (row is null)
{ {
row = new LeaderboardChannelMetaEntity { RoomId = body.RoomId, Channel = body.Channel }; row = new LeaderboardChannelMetaEntity { Channel = body.Channel };
db.LeaderboardChannelMeta.Add(row); db.LeaderboardChannelMeta.Add(row);
} }
row.RoomId = body.RoomId; row.RoomId = body.RoomId;
@@ -1421,13 +1127,12 @@ public class AdminController(
/// remove the metadata row. The channel goes back to showing as /// remove the metadata row. The channel goes back to showing as
/// "Channel N" / unscoped. Existing score rows are kept.</summary> /// "Channel N" / unscoped. Existing score rows are kept.</summary>
[HttpDelete("leaderboards/meta/{channel:int}")] [HttpDelete("leaderboards/meta/{channel:int}")]
public async Task<ActionResult> DeleteChannelMeta(int channel, [FromQuery] long roomId = 0) public async Task<ActionResult> DeleteChannelMeta(int channel)
{ {
var row = await db.LeaderboardChannelMeta var row = await db.LeaderboardChannelMeta.FirstOrDefaultAsync(c => c.Channel == channel);
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == channel);
if (row is null) return Ok(new { ok = true, deleted = 0 }); if (row is null) return Ok(new { ok = true, deleted = 0 });
db.LeaderboardChannelMeta.Remove(row); db.LeaderboardChannelMeta.Remove(row);
await LogAsync("leaderboard_meta_remove", "channel", channel, $"room={roomId}"); await LogAsync("leaderboard_meta_remove", "channel", channel, "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { ok = true, deleted = 1 }); return Ok(new { ok = true, deleted = 1 });
} }
@@ -1440,21 +1145,15 @@ public class AdminController(
/// actually use: load a course, drive it, then orphans surfaces /// actually use: load a course, drive it, then orphans surfaces
/// the channel id the game just reported.</summary> /// the channel id the game just reported.</summary>
[HttpGet("leaderboards/orphans")] [HttpGet("leaderboards/orphans")]
public async Task<ActionResult> LeaderboardOrphans([FromQuery] long? roomId = null) public async Task<ActionResult> LeaderboardOrphans()
{ {
var mapped = await db.LeaderboardChannelMeta var mapped = await db.LeaderboardChannelMeta.Select(c => c.Channel).ToListAsync();
.Select(c => new { c.RoomId, c.Channel }) var rows = await db.LeaderboardStats
.ToListAsync(); .Where(s => !mapped.Contains(s.StatChannel))
var query = db.LeaderboardStats.AsQueryable(); .GroupBy(s => s.StatChannel)
if (roomId is long rid) query = query.Where(s => s.RoomId == rid); .Select(g => new { Channel = g.Key, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) })
var rows = await query
.GroupBy(s => new { s.RoomId, s.StatChannel })
.Select(g => new { g.Key.RoomId, Channel = g.Key.StatChannel, EntryCount = g.Count(), LastSeen = g.Max(s => s.UpdatedAt) })
.OrderByDescending(r => r.LastSeen) .OrderByDescending(r => r.LastSeen)
.ToListAsync(); .ToListAsync();
rows = rows
.Where(r => !mapped.Any(m => m.RoomId == r.RoomId && m.Channel == r.Channel))
.ToList();
return Ok(rows); return Ok(rows);
} }
@@ -1526,6 +1225,11 @@ public class AdminController(
}); });
} }
// ── 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")] [HttpGet("settings/weekly-challenges")]
public async Task<ActionResult> GetWeeklyChallenges() public async Task<ActionResult> GetWeeklyChallenges()
{ {
@@ -1533,6 +1237,16 @@ public class AdminController(
return Ok(weekly); 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")] [HttpGet("settings/weekly-challenges/reward-options")]
public async Task<ActionResult> GetWeeklyChallengeRewardOptions() public async Task<ActionResult> GetWeeklyChallengeRewardOptions()
{ {
@@ -1545,17 +1259,14 @@ public class AdminController(
string kind, string kind,
StoreItemEntity item, StoreItemEntity item,
string avatarItemDesc = "", string avatarItemDesc = "",
string consumableItemDesc = "", string consumableItemDesc = "") => new
string equipmentPrefabName = "",
string equipmentModificationGuid = "") => new
{ {
Kind = kind, Kind = kind,
Label = $"{item.DisplayName} ({item.Slug})", Label = $"{item.DisplayName} ({item.Slug})",
Slug = item.Slug,
GiftDropId = (int)(item.Id & 0x7fffffff), GiftDropId = (int)(item.Id & 0x7fffffff),
AvatarItemDesc = avatarItemDesc, AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = consumableItemDesc, ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = equipmentPrefabName,
EquipmentModificationGuid = equipmentModificationGuid,
}; };
var avatarItems = rows var avatarItems = rows
@@ -1572,18 +1283,10 @@ public class AdminController(
.Where(o => o is not null) .Where(o => o is not null)
.ToArray(); .ToArray();
var equipment = rows
.Select(i => StoreService.TryGetEquipmentPayload(i.Slug, out var prefab, out var mod)
? Option("equipment", i, equipmentPrefabName: prefab, equipmentModificationGuid: mod)
: null)
.Where(o => o is not null)
.ToArray();
return Ok(new return Ok(new
{ {
AvatarItems = avatarItems, AvatarItems = avatarItems,
Consumables = consumables, Consumables = consumables,
Equipment = equipment,
}); });
} }
@@ -1592,6 +1295,10 @@ public class AdminController(
List<WeeklyChallengeTemplate>? Challenges, List<WeeklyChallengeTemplate>? Challenges,
WeeklyChallengeReward? Reward); 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")] [HttpPost("settings/weekly-challenges")]
public async Task<ActionResult> SetWeeklyChallenges([FromBody] WeeklyChallengeSettingsRequest body) public async Task<ActionResult> SetWeeklyChallenges([FromBody] WeeklyChallengeSettingsRequest body)
{ {
@@ -1603,56 +1310,6 @@ public class AdminController(
return Ok(weekly); return Ok(weekly);
} }
[HttpGet("settings/play-menu-tags")]
public async Task<ActionResult> GetPlayMenuTags()
=> Ok(await serverSettings.GetPlayMenuTagsAsync());
public sealed record PlayMenuTagsRequest(
List<string>? PinnedTags,
List<string>? PopularTags);
[HttpPost("settings/play-menu-tags")]
public async Task<ActionResult> SetPlayMenuTags([FromBody] PlayMenuTagsRequest body)
{
var tags = await serverSettings.SetPlayMenuTagsAsync(
body.PinnedTags ?? [],
body.PopularTags ?? []);
await LogAsync("play_menu_tags_updated", "system", 0,
$"pinned={string.Join(",", tags.PinnedTags)} popular={string.Join(",", tags.PopularTags)}");
await db.SaveChangesAsync();
return Ok(tags);
}
[HttpGet("settings/rec-center-doors")]
public async Task<ActionResult> GetRecCenterDoors()
=> Ok(await serverSettings.GetRecCenterDoorsAsync());
public sealed record RecCenterDoorsRequest(
List<RecCenterDoorConfig>? Doors);
[HttpPost("settings/rec-center-doors")]
public async Task<ActionResult> SetRecCenterDoors([FromBody] RecCenterDoorsRequest body)
{
var doors = await serverSettings.SetRecCenterDoorsAsync(body.Doors ?? []);
await LogAsync("rec_center_doors_updated", "system", 0,
string.Join(";", doors.Doors.Select(d => $"{d.Key}:{d.Query}")));
await db.SaveChangesAsync();
return Ok(doors);
}
[HttpGet("settings/gameconfigs")]
public async Task<ActionResult> GetDiscoveredGameConfigs()
=> Ok(await serverSettings.GetDiscoveredGameConfigsAsync());
[HttpPost("settings/gameconfigs")]
public async Task<ActionResult> SetDiscoveredGameConfigs([FromBody] DiscoveredGameConfigSettings body)
{
var settings = await serverSettings.SetDiscoveredGameConfigsAsync(body);
await LogAsync("gameconfigs_updated", "system", 0, "discovered");
await db.SaveChangesAsync();
return Ok(settings);
}
// ── Signup codes ───────────────────────────────────────────────────── // ── Signup codes ─────────────────────────────────────────────────────
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code /// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
@@ -1775,11 +1432,8 @@ public class AdminController(
[HttpPost("players/{id:long}/kick")] [HttpPost("players/{id:long}/kick")]
public async Task<ActionResult> KickPlayer(long id, [FromBody] KickRequest? body) public async Task<ActionResult> KickPlayer(long id, [FromBody] KickRequest? body)
{ {
var reason = string.IsNullOrWhiteSpace(body?.Reason) await notifications.NotifyAsync(id, PushNotificationId.ModerationKick,
? "Kicked by an admin." new { Reason = body?.Reason ?? "" });
: body!.Reason!.Trim();
await notifications.KickPlayerAsync(id, reason);
playerPresence.Clear(id);
await LogAsync("kick_player", "player", id, body?.Reason ?? ""); await LogAsync("kick_player", "player", id, body?.Reason ?? "");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new { kicked = id }); return Ok(new { kicked = id });
@@ -1842,83 +1496,16 @@ public class AdminController(
[HttpGet("stats")] [HttpGet("stats")]
public async Task<ActionResult> Stats() public async Task<ActionResult> Stats()
{ {
var now = DateTime.UtcNow;
var totalPlayers = await db.Players.CountAsync(); var totalPlayers = await db.Players.CountAsync();
var totalRooms = await db.Rooms.CountAsync(); var totalRooms = await db.Rooms.CountAsync();
var totalInventions = await db.Inventions.CountAsync(); var totalInventions = await db.Inventions.CountAsync();
var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null); var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null);
var bannedNow = await db.Players.CountAsync(p => var bannedNow = await db.Players.CountAsync(p =>
p.BannedUntil != null && p.BannedUntil > now); p.BannedUntil != null && p.BannedUntil > DateTime.UtcNow);
var activeIpBans = await db.IpBans.CountAsync(b => var activeIpBans = await db.IpBans.CountAsync(b =>
b.Until == null || b.Until > now); b.Until == null || b.Until > DateTime.UtcNow);
var online = playerPresence.RecentlyActivePlayerIds().ToArray(); var online = onlinePresence.OnlinePlayerIds().ToArray();
var onlineSet = online.ToHashSet();
var currentRoomsByPlayer = online
.Select(id => new { PlayerId = id, Room = playerPresence.GetRoom(id) })
.Where(x => x.Room is not null)
.ToDictionary(x => x.PlayerId, x => x.Room!);
var roomIds = currentRoomsByPlayer.Values.Select(r => r.RoomId).Distinct().ToList();
var roomNames = roomIds.Count == 0
? new Dictionary<long, string>()
: await db.Rooms
.Where(r => roomIds.Contains(r.Id))
.Select(r => new { r.Id, r.Name })
.ToDictionaryAsync(r => r.Id, r => r.Name);
var onlinePlayers = await db.Players
.Where(p => onlineSet.Contains(p.Id))
.OrderBy(p => p.DisplayName)
.ThenBy(p => p.Username)
.Select(p => new
{
p.Id,
p.Username,
p.DisplayName,
p.Level,
p.ProfileImageName,
p.IsAdmin,
p.IsDeveloper,
p.IsCommunityTeam,
p.IsJunior,
p.IsVerified,
p.BannedUntil,
p.LastSeenAt,
})
.ToListAsync();
var onlinePlayerCards = onlinePlayers.Select(p =>
{
currentRoomsByPlayer.TryGetValue(p.Id, out var room);
roomNames.TryGetValue(room?.RoomId ?? 0, out var roomName);
return new
{
p.Id,
p.Username,
p.DisplayName,
p.Level,
p.ProfileImageName,
p.IsAdmin,
p.IsDeveloper,
p.IsCommunityTeam,
p.IsJunior,
p.IsVerified,
p.BannedUntil,
p.LastSeenAt,
CurrentRoom = room is null ? null : new
{
room.RoomId,
Name = roomName ?? room.Name,
room.SubRoomId,
room.RoomInstanceId,
room.PhotonRoomId,
room.PhotonRegionId,
room.IsPrivate,
room.MaxCapacity,
},
};
}).ToList();
var topRooms = await db.Rooms var topRooms = await db.Rooms
.OrderByDescending(r => r.VisitCount) .OrderByDescending(r => r.VisitCount)
@@ -1926,31 +1513,6 @@ public class AdminController(
.Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount }) .Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount })
.ToListAsync(); .ToListAsync();
var activeSessions = await db.GameSessions
.Where(s => s.PlayerCount > 0)
.OrderByDescending(s => s.PlayerCount)
.ThenByDescending(s => s.CreatedAt)
.Take(8)
.Select(s => new
{
s.Id,
s.RoomId,
s.ActivityLevelId,
s.Region,
s.PhotonRoomName,
s.PlayerCount,
s.MaxCapacity,
s.CreatedAt,
})
.ToListAsync();
var inGamePlayerCount = await db.GameSessions.SumAsync(s => (int?)s.PlayerCount) ?? 0;
var activeSessionCount = await db.GameSessions.CountAsync(s => s.PlayerCount > 0);
var photosToday = await db.Photos.CountAsync(p =>
p.CreatedAt >= now.AddDays(-1) && p.DeletedAt == null);
var newPlayersToday = await db.Players.CountAsync(p => p.CreatedAt >= now.AddDays(-1));
var totalVisitCount = await db.Rooms.SumAsync(r => (long)r.VisitCount);
var totalCheerCount = await db.Rooms.SumAsync(r => (long)r.CheerCount);
var recentJoins = await db.Players var recentJoins = await db.Players
.OrderByDescending(p => p.CreatedAt) .OrderByDescending(p => p.CreatedAt)
.Take(10) .Take(10)
@@ -1964,28 +1526,16 @@ public class AdminController(
Total = totalPlayers, Total = totalPlayers,
OnlineNow = online.Length, OnlineNow = online.Length,
BannedNow = bannedNow, BannedNow = bannedNow,
NewToday = newPlayersToday,
Online = onlinePlayerCards,
},
Rooms = new
{
Total = totalRooms,
TotalVisits = totalVisitCount,
TotalCheers = totalCheerCount,
TopByVisits = topRooms,
ActiveSessions = activeSessions,
ActiveSessionCount = activeSessionCount,
InGamePlayerCount = inGamePlayerCount,
}, },
Rooms = new { Total = totalRooms, TopByVisits = topRooms },
Inventions = totalInventions, Inventions = totalInventions,
Photos = new { Today = photosToday },
Moderation = new Moderation = new
{ {
OpenReports = openReports, OpenReports = openReports,
ActiveIpBans = activeIpBans, ActiveIpBans = activeIpBans,
}, },
RecentJoins = recentJoins, RecentJoins = recentJoins,
ServerTime = now, ServerTime = DateTime.UtcNow,
}); });
} }
@@ -2126,106 +1676,6 @@ public class AdminController(
return Ok(new { granted = body.ItemId, quantity = qty }); return Ok(new { granted = body.ItemId, quantity = qty });
} }
/// <summary>POST <c>api/admin/v1/players/{id}/inventory/unlock-all</c>
/// — grant the player the ENTIRE catalog: every avatar cosmetic GUID
/// (merged into <see cref="AvatarEntity.InventoryJson"/>), every
/// permanent hair dye, and every store equipment/consumable slug (as
/// <see cref="PlayerInventoryEntity"/> rows). Idempotent — only adds
/// what's missing. Pushes SubscriptionUpdateProfile so the watch
/// re-fetches without a relog.
///
/// Cosmetics use the canonical InventoryJson shape — a plain JSON array
/// of GUID strings (what the buy/gift flow writes and
/// AvatarItemsController.ParseInventoryEntries reads). Only BARE GUIDs
/// from the authoritative safe-list catalog are added, so every
/// unlocked item is one this build actually ships (no missing-bundle
/// crashes). NOTE: the 2020 watch's wardrobe only renders up to
/// Avatar:MaxUnlockedItems (hard cap 120) owned cosmetics at once, so
/// the player won't see thousands of tiles even though all are granted.</summary>
[HttpPost("players/{id:long}/inventory/unlock-all")]
public async Task<ActionResult> UnlockAllItems(long id)
{
var player = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (player is null) return NotFound();
// 1) Avatar cosmetics → InventoryJson (JSON array of GUID strings).
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == id);
if (avatar is null) { avatar = new AvatarEntity { PlayerId = id }; db.Avatars.Add(avatar); }
var owned = ParseOwnedDescs(avatar.InventoryJson);
var ownedSet = new HashSet<string>(owned, StringComparer.OrdinalIgnoreCase);
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
var addedCosmetics = 0;
foreach (var guid in catalog.Keys)
if (ownedSet.Add(guid)) { owned.Add(guid); addedCosmetics++; }
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(owned);
avatar.UpdatedAt = DateTime.UtcNow;
// 2) Hair dyes + store equipment/consumables → PlayerInventory (by slug).
var existingSlugs = await db.PlayerInventory
.Where(pi => pi.PlayerId == id)
.Select(pi => pi.ItemSlug)
.ToListAsync();
var slugSet = new HashSet<string>(existingSlugs, StringComparer.OrdinalIgnoreCase);
var allSlugs = StoreService.AllHairDyeSlugs
.Concat(await db.StoreItems.Select(s => s.Slug).ToListAsync());
var addedSlugs = 0;
foreach (var slug in allSlugs)
{
if (string.IsNullOrWhiteSpace(slug) || !slugSet.Add(slug)) continue;
db.PlayerInventory.Add(new PlayerInventoryEntity { PlayerId = id, ItemSlug = slug, Quantity = 1 });
addedSlugs++;
}
await LogAsync("unlock_all_items", "player", id,
$"cosmetics+{addedCosmetics} (catalog={catalog.Count}) slugs+{addedSlugs}");
await db.SaveChangesAsync();
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminUnlockAll" });
return Ok(new
{
id,
addedCosmetics,
totalCosmetics = catalog.Count,
addedSlugs,
ownedCosmetics = owned.Count,
});
}
// Tolerant parse of InventoryJson into a flat list of owned item descs.
// Canonical shape is a JSON string array; also accepts the legacy
// [{itemId,...}] object shape so existing data is never clobbered.
private static List<string> ParseOwnedDescs(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
try
{
var strings = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
if (strings is not null && strings.Count > 0) return strings;
}
catch { }
var result = new List<string>();
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
foreach (var el in doc.RootElement.EnumerateArray())
{
if (el.ValueKind == System.Text.Json.JsonValueKind.String)
result.Add(el.GetString() ?? string.Empty);
else if (el.ValueKind == System.Text.Json.JsonValueKind.Object &&
el.TryGetProperty("itemId", out var iid) &&
iid.ValueKind == System.Text.Json.JsonValueKind.String)
result.Add(iid.GetString() ?? string.Empty);
}
}
catch { }
return result;
}
public sealed record CurrencyRequest(int CurrencyType, long Amount, string? Reason); public sealed record CurrencyRequest(int CurrencyType, long Amount, string? Reason);
/// <summary>Adjust a player's currency wallet. Positive amounts /// <summary>Adjust a player's currency wallet. Positive amounts
@@ -2257,83 +1707,6 @@ public class AdminController(
return Ok(new { id, level = lvl, xp }); return Ok(new { id, level = lvl, xp });
} }
public sealed record SetLevelRequest(int Level);
/// <summary>Set a player's level directly from the admin console.
/// XP is moved to the minimum value for that level so the next normal
/// XP grant does not snap the row back to the curve-derived level.</summary>
[HttpPost("players/{id:long}/level")]
public async Task<ActionResult> SetLevel(long id, [FromBody] SetLevelRequest body)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
var newLevel = Math.Clamp(body.Level, 1, 100);
var previous = p.Level;
p.Level = newLevel;
p.XP = MinimumXpForLevel(newLevel);
await LogAsync("set_level", "player", id, $"{previous} -> {newLevel}");
await db.SaveChangesAsync();
await notifications.AccountChanged(p);
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminLevelSet", Level = p.Level, XP = p.XP, PreviousLevel = previous });
return Ok(new { p.Id, p.Level, p.XP });
}
/// <summary>Insert one <see cref="CheerEntity"/> row per cheer Type
/// (0=General, 1=Helpful, 2=GreatHost, 3=Sportsman, 4=Creative,
/// 5=Credit) so the player has at least one received cheer in every
/// category. The watch's pinned-cheer picker (see
/// <see cref="DorkNet.Server.Controllers.API.PlayerCheer.PlayerCheerController.SetSelectedCheer"/>)
/// only lets a player pin a category they've actually received — this
/// unlocks every badge for them without having to wait on real
/// peer cheers.
///
/// Inserts come from system account id=1 (the seed admin). Idempotent
/// on (FromPlayerId, TargetPlayerId, Type) — re-running just fills in
/// whichever categories aren't there yet. Does NOT award XP (these
/// are admin-injected, not real peer interactions), so the player's
/// level isn't inflated.</summary>
[HttpPost("players/{id:long}/cheers/unlock")]
public async Task<ActionResult> UnlockCheers(long id)
{
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
// RoomieCheerType enum mirror — see CheerEntity.Type docblock.
var allTypes = new[] { 0, 1, 2, 3, 4, 5 };
var existingTypes = await db.Cheers
.Where(c => c.TargetPlayerId == id && c.TargetRoomId == 0
&& c.TargetPhotoId == 0 && c.TargetInventionId == 0)
.Select(c => c.Type)
.Distinct()
.ToListAsync();
var missing = allTypes.Except(existingTypes).ToList();
foreach (var t in missing)
{
db.Cheers.Add(new CheerEntity
{
FromPlayerId = 1, // system / seed-admin account
TargetPlayerId = id,
Type = t,
});
}
await LogAsync("unlock_cheers", "player", id,
$"inserted={missing.Count} types=[{string.Join(",", missing)}]");
await db.SaveChangesAsync();
// Nudge the watch to re-fetch its cheer list so the pinned-cheer
// picker shows the newly-unlocked badges without a relog.
await notifications.NotifyAsync(id,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "AdminCheerUnlock", Inserted = missing.Count });
return Ok(new { id, inserted = missing.Count, types = missing });
}
public sealed record ProfileFlagsRequest( public sealed record ProfileFlagsRequest(
bool? IsVerified, bool? IsVerified,
bool? IsDeveloper, bool? IsDeveloper,
@@ -2379,7 +1752,6 @@ public class AdminController(
p.DisplayName = body.DisplayName.Trim(); p.DisplayName = body.DisplayName.Trim();
await LogAsync("set_display_name", "player", id, p.DisplayName); await LogAsync("set_display_name", "player", id, p.DisplayName);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.AccountChanged(p);
return Ok(new { p.Id, p.DisplayName }); return Ok(new { p.Id, p.DisplayName });
} }
@@ -2409,16 +1781,9 @@ public class AdminController(
p.Username = name; p.Username = name;
await LogAsync("set_username", "player", id, $"{previous} -> {name}"); await LogAsync("set_username", "player", id, $"{previous} -> {name}");
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await notifications.AccountChanged(p);
return Ok(new { p.Id, p.Username }); return Ok(new { p.Id, p.Username });
} }
private static int MinimumXpForLevel(int level)
{
var normalized = Math.Max(1, level);
return 100 * (normalized - 1) * (normalized - 1);
}
private sealed class InventoryEntry private sealed class InventoryEntry
{ {
[System.Text.Json.Serialization.JsonPropertyName("itemId")] [System.Text.Json.Serialization.JsonPropertyName("itemId")]
@@ -2607,7 +1972,31 @@ public class AdminController(
// one that ends up invoking the GiftManager popup path. // one that ends up invoking the GiftManager popup path.
await notifications.NotifyAsync(id, await notifications.NotifyAsync(id,
PushNotificationId.GiftPackageReceivedImmediate, PushNotificationId.GiftPackageReceivedImmediate,
DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift)); 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 }); return Ok(new { gift.Id, gift.RecipientPlayerId });
} }
@@ -2734,7 +2123,7 @@ public class AdminController(
[HttpGet("instances")] [HttpGet("instances")]
public async Task<ActionResult> ListInstances() public async Task<ActionResult> ListInstances()
{ {
var onlineIds = playerPresence.RecentlyActivePlayerIds().ToHashSet(); var onlineIds = onlinePresence.OnlinePlayerIds().ToHashSet();
if (onlineIds.Count == 0) return Ok(Array.Empty<object>()); if (onlineIds.Count == 0) return Ok(Array.Empty<object>());
// Bulk-pull names so we can render usernames per participant. // Bulk-pull names so we can render usernames per participant.
@@ -2951,13 +2340,7 @@ public class AdminController(
[FromQuery] string? storefront, [FromQuery] string? category, [FromQuery] string? storefront, [FromQuery] string? category,
[FromQuery] int take = 200, [FromQuery] int skip = 0) [FromQuery] int take = 200, [FromQuery] int skip = 0)
{ {
// Ceiling raised from 500: the wardrobe + color-variant seed pushes take = Math.Clamp(take, 1, 500);
// the live catalog past 800 rows, all under storefront "main". A
// 500-row window (ordered Storefront→Category→Price) silently hid
// every item that sorted past it — e.g. torso SKUs like the Sea
// Captain Contest shirt — so the admin UI couldn't find them even
// by search (it filters the fetched page client-side).
take = Math.Clamp(take, 1, 5000);
skip = Math.Max(0, skip); skip = Math.Max(0, skip);
IQueryable<StoreItemEntity> q = db.StoreItems; IQueryable<StoreItemEntity> q = db.StoreItems;
if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront); if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront);
@@ -2973,59 +2356,6 @@ public class AdminController(
long? Price, bool? IsActive, bool? IsLimitedTime, long? Price, bool? IsActive, bool? IsLimitedTime,
DateTime? AvailableUntil, string? Storefront); DateTime? AvailableUntil, string? Storefront);
/// <summary>POST <c>api/admin/v1/storeitems/image/upload</c> —
/// attachable catalog artwork. Stored as a global image blob so the
/// admin UI and the watch can resolve the same ImageName through
/// img.* / cdn.* without admins hand-copying filenames.</summary>
[HttpPost("storeitems/image/upload")]
[DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 25_000_000)]
public async Task<ActionResult> UploadStoreItemImage([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 = $"store_{hash[..24]}{ext}";
var existing = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == imageName);
if (existing is null)
{
var (bucket, key) = BlobRouter.Route(imageName);
var contentType = ext switch
{
".png" => "image/png",
".webp" => "image/webp",
_ => "image/jpeg",
};
await storage.PutAsync(bucket, key, bytes, contentType);
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = imageName,
UploadedByPlayerId = CurrentAdminId,
UploadedAt = DateTime.UtcNow,
});
await LogAsync("store_item_image_upload", "storeitem", 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,
});
}
/// <summary>Create a new catalog item.</summary> /// <summary>Create a new catalog item.</summary>
[HttpPost("storeitems")] [HttpPost("storeitems")]
public async Task<ActionResult> CreateStoreItem([FromBody] StoreItemUpsertRequest body) public async Task<ActionResult> CreateStoreItem([FromBody] StoreItemUpsertRequest body)
@@ -3094,133 +2424,6 @@ public class AdminController(
// ── Helpers ────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────
private async Task<string> DeletePlayerDataAsync(long playerId, long systemPlayerId)
{
var now = DateTime.UtcNow;
var deleted = 0;
var reassigned = 0;
deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync();
deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.ChatThreads
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Clubs
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.ClubAnnouncements
.Where(x => x.AuthorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId));
deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.RoomBans
.Where(x => x.BannedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId));
reassigned += await db.RoomRoles
.Where(x => x.GrantedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId));
reassigned += await db.Rooms
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.RoomDataBlobs
.Where(x => x.UploadedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId));
reassigned += await db.RoomKeys
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Playlists
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.PlayerEventResponses
.Where(x => x.PlayerId == playerId
|| db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId))
.ExecuteDeleteAsync();
deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
var privateInstanceIds = await db.PrivateInstances
.Where(x => x.OwnerPlayerId == playerId)
.Select(x => x.Id)
.ToListAsync();
if (privateInstanceIds.Count > 0)
{
deleted += await db.PrivateInstanceInvitees
.Where(x => privateInstanceIds.Contains(x.PrivateInstanceId))
.ExecuteDeleteAsync();
deleted += await db.PrivateInstances
.Where(x => privateInstanceIds.Contains(x.Id))
.ExecuteDeleteAsync();
}
deleted += await db.GiftPackages
.Where(x => x.RecipientPlayerId == playerId)
.ExecuteDeleteAsync();
if (playerId <= int.MaxValue)
{
reassigned += await db.GiftPackages
.Where(x => x.FromPlayerId == (int)playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null));
}
reassigned += await db.Inventions
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.Photos
.Where(x => x.UploaderPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.UploaderPlayerId, systemPlayerId)
.SetProperty(x => x.DeletedAt, now)
.SetProperty(x => x.IsPublic, false));
reassigned += await db.SignupCodes
.Where(x => x.RedeemedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null));
return $"deleted_rows={deleted} reassigned_rows={reassigned}";
}
private Task LogAsync(string action, string targetType, long targetId, string reason) private Task LogAsync(string action, string targetType, long targetId, string reason)
{ {
db.AdminActions.Add(new AdminActionEntity db.AdminActions.Add(new AdminActionEntity
@@ -55,28 +55,23 @@ public class RoomZipImportController(
DorkNetDbContext db, DorkNetDbContext db,
RoomBlobNormalizerService roomBlobNormalizer, RoomBlobNormalizerService roomBlobNormalizer,
IObjectStorage storage, IObjectStorage storage,
IServiceScopeFactory scopeFactory,
ILogger<RoomZipImportController> logger) : ControllerBase ILogger<RoomZipImportController> logger) : ControllerBase
{ {
private long CurrentAdminId => this.RequireCurrentPlayerId(); private long CurrentAdminId => this.RequireCurrentPlayerId();
// ── Room-blob normaliser toggle ───────────────────────────────────── // ── Room-blob normaliser toggle ─────────────────────────────────────
// The normaliser does two things now (see RoomBlobNormalizerService): // The normaliser re-encodes .room bytes through the 2020 schema to
// 1. Project modern Rec Room's TransformData.quaternion_rotation // strip non-canonical encodings. It's been tripping the watch into
// (field 6) onto the legacy Euler rotation Vector3 (field 2) // "Error attempting to parse room data stream" — the generated 2020
// the 2020.12 watch reads. Without this, every shape in a // protobuf bindings appear to be missing fields or to emit them in
// modern-authored room renders at rotation=(0,0,0) — the // a wire-order the watch's parser disagrees with.
// "Studio-imported shapes are misplaced" bug.
// 2. Re-encode through the 2020 PersistedRoomData schema to drop
// any non-canonical wire encodings the watch's stricter parser
// doesn't accept.
// //
// Per-import flag (`normalizeBlobs` in the form / finalize body) // Per-import flag (`normalizeBlobs` in the form / finalize body)
// defaults ON now that the projection pass exists. The SPA still // defaults OFF. The SPA's importer surfaces it as a checkbox so an
// surfaces it as a checkbox so an admin can opt out for an // admin can opt back in once we're confident a particular zip's
// already-Euler-encoded blob if a particular import misbehaves. // contents survive the re-encode. The diagnostic parse still runs
// The diagnostic parse always runs (its parse-OK / parse-FAIL log // either way (its parse-OK / parse-FAIL log line is useful), but
// line is useful telemetry). // when the flag is false the original bytes are what reach S3.
/// <summary>Write a blob to S3 at its canonical (bucket, key) AND /// <summary>Write a blob to S3 at its canonical (bucket, key) AND
/// record the metadata row in <see cref="RoomDataBlobEntity"/>. /// record the metadata row in <see cref="RoomDataBlobEntity"/>.
@@ -90,8 +85,7 @@ public class RoomZipImportController(
/// S3 the same way.</summary> /// S3 the same way.</summary>
private async Task<RoomDataBlobEntity> WriteBlobAsync( private async Task<RoomDataBlobEntity> WriteBlobAsync(
long roomId, string blobName, byte[] bytes, long uploaderPlayerId, long roomId, string blobName, byte[] bytes, long uploaderPlayerId,
string? referencesCsv = null, string? contentTypeOverride = null, string? referencesCsv = null, string? contentTypeOverride = null)
DateTime? uploadedAt = null, long? subRoomId = null)
{ {
var (bucket, key) = BlobRouter.Route(blobName); var (bucket, key) = BlobRouter.Route(blobName);
var contentType = contentTypeOverride ?? MimeFromName(blobName); var contentType = contentTypeOverride ?? MimeFromName(blobName);
@@ -111,9 +105,8 @@ public class RoomZipImportController(
RoomId = roomId, RoomId = roomId,
BlobName = blobName, BlobName = blobName,
UploadedByPlayerId = uploaderPlayerId, UploadedByPlayerId = uploaderPlayerId,
UploadedAt = uploadedAt ?? DateTime.UtcNow, UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = referencesCsv ?? string.Empty, ReferencedFilenamesCsv = referencesCsv ?? string.Empty,
SubRoomId = subRoomId,
}; };
db.RoomDataBlobs.Add(entity); db.RoomDataBlobs.Add(entity);
return entity; return entity;
@@ -206,26 +199,6 @@ public class RoomZipImportController(
public string? DataBlob { get; set; } public string? DataBlob { get; set; }
} }
/// <summary>Sidecar JSON next to a History/&lt;blob&gt;.room entry. All
/// fields optional — the importer reads them to populate
/// RoomDataBlobEntity.UploadedAt + audit trail when present, and falls
/// back to <see cref="DateTime.UtcNow"/> for the timestamp otherwise.</summary>
public sealed class HistorySidecarDto
{
public long? SubRoomDataSaveId { get; set; }
/// <summary>Parent sub-room id. The Restore UI fetches
/// <c>rooms/{roomId}/subrooms/{subRoomId}/datahistory</c>; without
/// a SubRoomId on each row we'd have to return the whole room's
/// blob mix under every sub-room's history list. Optional: when
/// missing, the importer derives it from the SubRooms/&lt;scene&gt;
/// folder name suffix (<c>SubRoomName__&lt;subRoomId&gt;</c>).</summary>
public long? SubRoomId { get; set; }
public string? DataBlob { get; set; }
public DateTime? CreatedAt { get; set; }
public string? Description { get; set; }
public long? SavedByAccountId { get; set; }
}
// ── Invention DTOs ──────────────────────────────────────────────── // ── Invention DTOs ────────────────────────────────────────────────
public sealed class InventionJsonDto public sealed class InventionJsonDto
@@ -268,7 +241,7 @@ public class RoomZipImportController(
[HttpPost("zip-bulk-import")] [HttpPost("zip-bulk-import")]
[DisableRequestSizeLimit] [DisableRequestSizeLimit]
[RequestFormLimits(MultipartBodyLengthLimit = 5_000_000_000)] // 5 GB [RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)] // 2 GB
public async Task<IActionResult> ZipImport( public async Task<IActionResult> ZipImport(
[FromForm(Name = "archive")] IFormFile? archive, [FromForm(Name = "archive")] IFormFile? archive,
[FromForm(Name = "creatorPlayerId")] long? creatorPlayerId, [FromForm(Name = "creatorPlayerId")] long? creatorPlayerId,
@@ -280,7 +253,7 @@ public class RoomZipImportController(
await using var stream = archive.OpenReadStream(); await using var stream = archive.OpenReadStream();
return await RunImportAsync( return await RunImportAsync(
stream, archive.Length, creatorPlayerId, stream, archive.Length, creatorPlayerId,
normalizeBlobs: normalizeBlobs ?? true); normalizeBlobs: normalizeBlobs ?? false);
} }
// ── Chunked upload (Cloudflare 100MB workaround) ───────────────── // ── Chunked upload (Cloudflare 100MB workaround) ─────────────────
@@ -312,16 +285,13 @@ public class RoomZipImportController(
/// <summary>POST <c>zip-upload-init</c> — allocates a temp file and /// <summary>POST <c>zip-upload-init</c> — allocates a temp file and
/// returns an <c>uploadId</c> the SPA threads through subsequent /// returns an <c>uploadId</c> the SPA threads through subsequent
/// chunk POSTs. Capped at 5 GB total; opens a sparse temp file so /// chunk POSTs. Capped at 2 GB total; opens a sparse temp file so
/// disk usage tracks actual bytes received. The cap is generous /// disk usage tracks actual bytes received.</summary>
/// because the live RecNet exports of large story-mode rooms
/// (large multi-scene Studio quests) easily
/// run 2-3 GB before any of the per-room recovery zip bundling.</summary>
[HttpPost("zip-upload-init")] [HttpPost("zip-upload-init")]
public ActionResult ChunkInit([FromBody] ChunkInitRequest body) public ActionResult ChunkInit([FromBody] ChunkInitRequest body)
{ {
if (body.TotalBytes <= 0 || body.TotalBytes > 5_000_000_000) if (body.TotalBytes <= 0 || body.TotalBytes > 2_000_000_000)
return BadRequest(new { error = "invalid_size", maxBytes = 5_000_000_000L, gotBytes = body.TotalBytes }); return BadRequest(new { error = "invalid_size" });
// Prune stale sessions before allocating a new one. Anything // Prune stale sessions before allocating a new one. Anything
// older than 2 hours with no activity is abandoned. // older than 2 hours with no activity is abandoned.
@@ -402,143 +372,32 @@ public class RoomZipImportController(
List<string>? SelectedInventionFolders, List<string>? SelectedInventionFolders,
bool? NormalizeBlobs); bool? NormalizeBlobs);
/// <summary>In-memory background job state for an async import. /// <summary>POST <c>zip-upload-finalize/{id}</c> — runs the existing
/// The HTTP request that kicked the import off only stays alive /// zip import on the assembled temp file then deletes it. Same
/// for milliseconds (it returns 202 immediately); the work runs /// response shape as <c>zip-bulk-import</c>.</summary>
/// in <c>Task.Run</c> against a freshly-created DI scope so it
/// outlives the request. The SPA polls
/// <c>GET zip-import-status/{jobId}</c> for progress + result.
/// Jobs auto-expire 1 hour after finish (so the dictionary doesn't
/// grow forever).</summary>
public sealed class ImportJob
{
public Guid Id { get; init; }
public long AdminPlayerId { get; init; }
public DateTime StartedAt { get; init; } = DateTime.UtcNow;
public DateTime? FinishedAt { get; set; }
/// <summary>"running" | "done" | "failed".</summary>
public string State { get; set; } = "running";
/// <summary>Short progress string updated by the import core
/// after each room/invention completes ("room 5/12: …").</summary>
public string? Phase { get; set; }
/// <summary>On success: the same anonymous payload the sync
/// endpoint would have returned. On failure (validation
/// error): the <c>{ error = ..., ... }</c> object the sync
/// endpoint would have wrapped in BadRequest.</summary>
public object? Result { get; set; }
/// <summary>Exception message when <see cref="State"/> is
/// "failed". <see cref="Result"/> stays null in that case.</summary>
public string? Error { get; set; }
}
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, ImportJob> Jobs = new();
/// <summary>POST <c>zip-upload-finalize/{id}</c> — kicks off the
/// import on the assembled temp file in a background task and
/// returns 202 immediately with a <c>jobId</c>. The synchronous
/// path got 524'd by Cloudflare on multi-GB archives (edge
/// disconnects clients at 100s but the server kept processing
/// successfully). Now: the upload session is consumed here, the
/// finalize request returns before any timeout, and the SPA polls
/// the status endpoint for completion.</summary>
[HttpPost("zip-upload-finalize/{id:guid}")] [HttpPost("zip-upload-finalize/{id:guid}")]
public ActionResult ChunkFinalize(Guid id, [FromBody] ChunkFinalizeRequest? body) public async Task<IActionResult> ChunkFinalize(Guid id, [FromBody] ChunkFinalizeRequest? body)
{ {
if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" }); if (!Sessions.TryGetValue(id, out var session)) return NotFound(new { error = "unknown_session" });
var adminId = CurrentAdminId; if (session.AdminPlayerId != CurrentAdminId) return Forbid();
if (session.AdminPlayerId != adminId) return Forbid();
// Capture before we leave the request context. try
var tempPath = session.TempPath;
var totalBytes = session.TotalBytes;
var creator = body?.CreatorPlayerId ?? adminId;
var selectedRoomFolders = body?.SelectedRoomFolders;
var selectedInventionFolders = body?.SelectedInventionFolders;
var normalizeBlobs = body?.NormalizeBlobs ?? true;
var jobId = Guid.NewGuid();
var job = new ImportJob { Id = jobId, AdminPlayerId = adminId };
Jobs[jobId] = job;
Sessions.TryRemove(id, out _);
// Prune jobs older than 1 hour past completion to keep the
// dictionary bounded. Admin only runs the importer a few times
// a day, so this stays tiny.
foreach (var (jid, j) in Jobs)
{ {
if (j.FinishedAt is { } fin && DateTime.UtcNow - fin > TimeSpan.FromHours(1)) await using var stream = System.IO.File.OpenRead(session.TempPath);
Jobs.TryRemove(jid, out _); return await RunImportAsync(
stream,
session.TotalBytes,
body?.CreatorPlayerId,
body?.SelectedRoomFolders,
body?.SelectedInventionFolders,
normalizeBlobs: body?.NormalizeBlobs ?? false);
} }
finally
_ = Task.Run(async () =>
{ {
using var scope = scopeFactory.CreateScope(); Sessions.TryRemove(id, out _);
var sp = scope.ServiceProvider; try { if (System.IO.File.Exists(session.TempPath)) System.IO.File.Delete(session.TempPath); }
// Build a fresh background controller instance bound to catch (Exception ex) { logger.LogWarning(ex, "[zip-import] failed to clean up {Path}", session.TempPath); }
// the new scope's services. The original `this` controller }
// is tied to the request scope and will be disposed the
// moment we return below — we cannot reference its `db`,
// `storage`, etc. from the bg task.
var bgDb = sp.GetRequiredService<DorkNetDbContext>();
var bgNorm = sp.GetRequiredService<RoomBlobNormalizerService>();
var bgStorage = sp.GetRequiredService<IObjectStorage>();
var bgLogger = sp.GetRequiredService<ILogger<RoomZipImportController>>();
var bgController = new RoomZipImportController(bgDb, bgNorm, bgStorage, scopeFactory, bgLogger);
try
{
await using var stream = System.IO.File.OpenRead(tempPath);
var outcome = await bgController.RunImportCoreAsync(
stream, totalBytes, creator, adminId,
selectedRoomFolders, selectedInventionFolders, normalizeBlobs,
jobPhase: p => job.Phase = p);
job.Result = outcome.Payload;
job.State = outcome.Success ? "done" : "failed";
}
catch (Exception ex)
{
bgLogger.LogError(ex, "[zip-import job {Job}] failed", jobId);
job.Error = ex.Message;
job.State = "failed";
}
finally
{
job.FinishedAt = DateTime.UtcNow;
try { if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath); }
catch (Exception ex) { bgLogger.LogWarning(ex, "[zip-import] failed to clean up {Path}", tempPath); }
}
});
return Accepted(new
{
jobId,
state = job.State,
statusUrl = $"/api/admin/v1/rooms/zip-import-status/{jobId}",
});
}
/// <summary>GET <c>zip-import-status/{jobId}</c> — SPA polls this
/// every few seconds while the import is running. Returns the
/// final result payload once <c>state == "done"</c> or
/// <c>"failed"</c>; on "failed" the payload either includes a
/// validation error (creator_not_found etc.) or just an
/// <c>error</c> string from the caught exception.</summary>
[HttpGet("zip-import-status/{jobId:guid}")]
public ActionResult GetImportStatus(Guid jobId)
{
if (!Jobs.TryGetValue(jobId, out var job)) return NotFound(new { error = "unknown_job" });
if (job.AdminPlayerId != CurrentAdminId) return Forbid();
return Ok(new
{
jobId = job.Id,
state = job.State,
phase = job.Phase,
startedAt = job.StartedAt,
finishedAt = job.FinishedAt,
elapsedSeconds = ((job.FinishedAt ?? DateTime.UtcNow) - job.StartedAt).TotalSeconds,
result = job.Result,
error = job.Error,
});
} }
/// <summary>DELETE <c>zip-upload-abort/{id}</c> — drop an in-flight /// <summary>DELETE <c>zip-upload-abort/{id}</c> — drop an in-flight
@@ -557,23 +416,6 @@ public class RoomZipImportController(
// ── Shared import core (called by both single-shot and chunked) ── // ── Shared import core (called by both single-shot and chunked) ──
/// <summary>Outcome of a single zip import. Wraps both success
/// payloads and validation errors into one object so the same
/// import code can serve the sync <c>zip-bulk-import</c> endpoint
/// AND the async <c>zip-upload-finalize</c> background job (which
/// has no <see cref="IActionResult"/> to return — it stores the
/// outcome in an <see cref="ImportJob"/> for the SPA to poll).</summary>
public sealed class ImportOutcome
{
/// <summary>true → payload is the success response;
/// false → payload includes an "error" property and the
/// sync endpoint should return 400.</summary>
public bool Success { get; init; }
/// <summary>The anonymous payload that would have been wrapped
/// in Ok()/BadRequest() in the sync path.</summary>
public object Payload { get; init; } = new { };
}
private async Task<IActionResult> RunImportAsync( private async Task<IActionResult> RunImportAsync(
Stream archiveStream, Stream archiveStream,
long archiveBytes, long archiveBytes,
@@ -582,37 +424,9 @@ public class RoomZipImportController(
List<string>? selectedInventionFolders = null, List<string>? selectedInventionFolders = null,
bool normalizeBlobs = false) bool normalizeBlobs = false)
{ {
var adminId = CurrentAdminId; var creator = creatorPlayerId ?? CurrentAdminId;
var outcome = await RunImportCoreAsync(
archiveStream, archiveBytes,
creatorPlayerId ?? adminId,
adminId,
selectedRoomFolders, selectedInventionFolders, normalizeBlobs,
jobPhase: null);
return outcome.Success
? Ok(outcome.Payload)
: BadRequest(outcome.Payload);
}
/// <summary>Pure import worker: no HttpContext access (caller passes
/// the resolved <paramref name="creator"/>), no IActionResult
/// wrapping. Background jobs call this via a freshly-resolved
/// scope so they can outlive the request that kicked them off.
/// <paramref name="jobPhase"/>, when non-null, gets updated with a
/// short progress string after each room/invention is processed so
/// the status endpoint can report meaningful progress.</summary>
private async Task<ImportOutcome> RunImportCoreAsync(
Stream archiveStream,
long archiveBytes,
long creator,
long adminId,
List<string>? selectedRoomFolders,
List<string>? selectedInventionFolders,
bool normalizeBlobs,
Action<string>? jobPhase)
{
if (!await db.Players.AnyAsync(p => p.Id == creator)) if (!await db.Players.AnyAsync(p => p.Id == creator))
return new ImportOutcome { Success = false, Payload = new { error = "creator_not_found", creator } }; return BadRequest(new { error = "creator_not_found", creator });
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false); using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false);
@@ -636,14 +450,6 @@ public class RoomZipImportController(
.Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length)) .Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length))
.ToList(); .ToList();
var studioRoomFolders = entryByPath.Keys
.Where(k => k.EndsWith("/room.json", StringComparison.OrdinalIgnoreCase))
.Select(k => k[..^"/room.json".Length])
.Where(f => HasStudioSaveEntries(f, entryByPath.Keys))
.Where(f => !roomFolders.Contains(f, StringComparer.OrdinalIgnoreCase))
.ToList();
roomFolders.AddRange(studioRoomFolders);
// Inventions can live either at the zip root (Inventions/Invention_<id>_<name>_<ts>/) // 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_…). // OR inside a room folder (Rooms/Room_<id>_<name>_<ts>/Inventions/Invention_…).
// Both layouts show up in the wild — handle either. // Both layouts show up in the wild — handle either.
@@ -655,7 +461,7 @@ public class RoomZipImportController(
.ToList(); .ToList();
if (roomFolders.Count == 0 && inventionFolders.Count == 0) if (roomFolders.Count == 0 && inventionFolders.Count == 0)
return new ImportOutcome { Success = false, Payload = new { error = "empty_archive_no_manifests" } }; return BadRequest(new { error = "empty_archive_no_manifests" });
// If the admin supplied an explicit selection (non-null, // If the admin supplied an explicit selection (non-null,
// non-empty), filter to just those folders. Comparison is // non-empty), filter to just those folders. Comparison is
@@ -684,14 +490,11 @@ public class RoomZipImportController(
.Select(r => (long?)r.Id) .Select(r => (long?)r.Id)
.MaxAsync() ?? 999) + 1; .MaxAsync() ?? 999) + 1;
var roomIdx = 0;
foreach (var folder in roomFolders) foreach (var folder in roomFolders)
{ {
roomIdx++;
jobPhase?.Invoke($"room {roomIdx}/{roomFolders.Count}: {folder}");
try try
{ {
var result = await ImportRoomAsync(folder, nextRoomId, creator, adminId, entryByPath, normalizeBlobs); var result = await ImportRoomAsync(folder, nextRoomId, creator, entryByPath, normalizeBlobs);
roomReports.Add(result); roomReports.Add(result);
if (result.GetType().GetProperty("ok")!.GetValue(result) as bool? == true) if (result.GetType().GetProperty("ok")!.GetValue(result) as bool? == true)
nextRoomId++; nextRoomId++;
@@ -704,12 +507,9 @@ public class RoomZipImportController(
} }
var inventionReports = new List<object>(); var inventionReports = new List<object>();
var invIdx = 0;
foreach (var folder in inventionFolders) foreach (var folder in inventionFolders)
{ {
invIdx++; try { inventionReports.Add(await ImportInventionAsync(folder, creator, entryByPath)); }
jobPhase?.Invoke($"invention {invIdx}/{inventionFolders.Count}: {folder}");
try { inventionReports.Add(await ImportInventionAsync(folder, creator, adminId, entryByPath)); }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "[zip-import] invention folder='{Folder}' failed", folder); logger.LogError(ex, "[zip-import] invention folder='{Folder}' failed", folder);
@@ -717,19 +517,14 @@ public class RoomZipImportController(
} }
} }
jobPhase?.Invoke($"done: {roomFolders.Count} rooms, {inventionFolders.Count} inventions"); return Ok(new
return new ImportOutcome
{ {
Success = true, archiveBytes,
Payload = new roomCount = roomFolders.Count,
{ inventionCount = inventionFolders.Count,
archiveBytes, rooms = roomReports,
roomCount = roomFolders.Count, inventions = inventionReports,
inventionCount = inventionFolders.Count, });
rooms = roomReports,
inventions = inventionReports,
},
};
} }
// ── Per-room import ─────────────────────────────────────────────── // ── Per-room import ───────────────────────────────────────────────
@@ -738,25 +533,13 @@ public class RoomZipImportController(
string roomFolder, string roomFolder,
long roomId, long roomId,
long creator, long creator,
long adminId,
Dictionary<string, ZipArchiveEntry> entries, Dictionary<string, ZipArchiveEntry> entries,
bool normalizeBlobs) bool normalizeBlobs)
{ {
// RoomDetails.json. Studio single-room dumps use room.json at // RoomDetails.json
// the room root; it is the same rooms.rec.net detail payload and
// can be read through RoomDetailsDto directly.
var detailsKey = $"{roomFolder}/RoomDetails.json"; var detailsKey = $"{roomFolder}/RoomDetails.json";
var studioDetailsKey = $"{roomFolder}/room.json"; var details = await ReadJsonAsync<RoomDetailsDto>(entries[detailsKey]) ?? new RoomDetailsDto();
ZipArchiveEntry detailsEntry; var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : Path.GetFileName(roomFolder);
if (entries.TryGetValue(detailsKey, out var recNetDetailsEntry))
detailsEntry = recNetDetailsEntry;
else if (entries.TryGetValue(studioDetailsKey, out var studioDetailsEntry))
detailsEntry = studioDetailsEntry;
else
throw new InvalidOperationException($"Missing RoomDetails.json or room.json in {roomFolder}");
var details = await ReadJsonAsync<RoomDetailsDto>(detailsEntry) ?? new RoomDetailsDto();
var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : StripStudioSuffix(ZipFileName(roomFolder));
if (await db.Rooms.AnyAsync(r => r.Name == name)) if (await db.Rooms.AnyAsync(r => r.Name == name))
return new { name, ok = false, error = "duplicate_name", folder = roomFolder }; return new { name, ok = false, error = "duplicate_name", folder = roomFolder };
@@ -778,12 +561,6 @@ public class RoomZipImportController(
var resolvedScenes = new List<ResolvedSubroom>(); var resolvedScenes = new List<ResolvedSubroom>();
var missingScenes = new List<string>(); var missingScenes = new List<string>();
if (scenes.Count == 0 && entries.ContainsKey(studioDetailsKey))
{
(resolvedScenes, missingScenes) = await ResolveStudioDumpSubroomsAsync(
roomFolder, details, name, entries, normalizeBlobs);
}
foreach (var sceneName in scenes.Keys) foreach (var sceneName in scenes.Keys)
{ {
var sceneFolder = $"{subroomRoot}{sceneName}"; var sceneFolder = $"{subroomRoot}{sceneName}";
@@ -864,47 +641,6 @@ public class RoomZipImportController(
polaroids.Add((Path.GetFileName(k), e)); polaroids.Add((Path.GetFileName(k), e));
} }
// History/ folder — every non-canonical save dropped by the
// studio-dump-to-import-zip converter (with --every-save). Each
// `<DataBlob>.room` becomes a separate RoomDataBlobEntity row so
// it shows up in `GET api/rooms/v1/datahistory/{roomId}`. The
// optional `<DataBlob>.json` sidecar carries CreatedAt; missing
// sidecars fall back to "now".
var history = new List<HistorySave>();
var historyPrefix = $"{sceneFolder}/History/";
// Derive SubRoomId from the folder name (SubRooms/<scene>) when
// the sidecar omits it. The converter names scene folders as
// either the rec.net Name (no id), or — when running our own
// dumper — the raw `<scene>__<subRoomId>`. Falling back to
// manifest.SubRoomId catches the no-suffix case.
long? subRoomIdFromFolder = null;
var lastUnderscores = sceneName.LastIndexOf("__", StringComparison.Ordinal);
if (lastUnderscores >= 0
&& long.TryParse(sceneName[(lastUnderscores + 2)..], out var parsedSub))
{
subRoomIdFromFolder = parsedSub;
}
var manifestSub = manifest.SubRoomId;
foreach (var (k, hEntry) in entries.Where(kv =>
kv.Key.StartsWith(historyPrefix, StringComparison.OrdinalIgnoreCase)
&& kv.Key.EndsWith(".room", StringComparison.OrdinalIgnoreCase)
&& !kv.Key[historyPrefix.Length..].Contains('/')))
{
var blobName = Path.GetFileName(k);
var sidecarKey = $"{k}.json";
HistorySidecarDto? sidecar = null;
if (entries.TryGetValue(sidecarKey, out var sidecarEntry))
sidecar = await ReadJsonAsync<HistorySidecarDto>(sidecarEntry);
history.Add(new HistorySave(
DataBlob: blobName,
RoomEntry: hEntry,
SubRoomId: sidecar?.SubRoomId ?? subRoomIdFromFolder ?? manifestSub,
SubRoomDataSaveId: sidecar?.SubRoomDataSaveId,
CreatedAt: sidecar?.CreatedAt,
Description: sidecar?.Description,
SavedByAccountId: sidecar?.SavedByAccountId));
}
resolvedScenes.Add(new ResolvedSubroom( resolvedScenes.Add(new ResolvedSubroom(
Manifest: manifest, Manifest: manifest,
SceneName: sceneName, SceneName: sceneName,
@@ -916,8 +652,7 @@ public class RoomZipImportController(
NormalizedOk: norm.Normalized, NormalizedOk: norm.Normalized,
HtrAssets: htrAssets, HtrAssets: htrAssets,
PvImages: pvImages, PvImages: pvImages,
Polaroids: polaroids, Polaroids: polaroids));
History: history));
logger.LogInformation( logger.LogInformation(
"[zip-import] room='{Room}' scene='{Scene}' blob={Blob} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol}", "[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, name, sceneName, blobFile, blobBytes.Length, norm.Bytes.Length,
@@ -1010,7 +745,6 @@ public class RoomZipImportController(
imageEntry = entries.FirstOrDefault(kv => imageEntry = entries.FirstOrDefault(kv =>
kv.Key.StartsWith($"{roomFolder}/RoomImage.", StringComparison.OrdinalIgnoreCase)).Value; kv.Key.StartsWith($"{roomFolder}/RoomImage.", StringComparison.OrdinalIgnoreCase)).Value;
} }
imageEntry ??= FindStudioPhotoEntry(roomFolder, details.ImageName, entries);
// Reorder so entry is first. Manifest order preserved for the rest. // Reorder so entry is first. Manifest order preserved for the rest.
var ordered = new List<ResolvedSubroom> { entry }; var ordered = new List<ResolvedSubroom> { entry };
@@ -1074,78 +808,15 @@ public class RoomZipImportController(
var pvAlreadyInDbAtStart = existingPvNames.Count; var pvAlreadyInDbAtStart = existingPvNames.Count;
var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count; var polaroidAlreadyInDbAtStart = existingPolaroidNames.Count;
// History (SubRooms/<scene>/History/<DataBlob>.room) — pre-load
// existing names so the de-dup HashSet covers prior imports + same
// hash referenced under multiple scenes.
var allHistoryNames = ordered
.SelectMany(s => s.History.Select(h => h.DataBlob))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var existingHistoryNames = new HashSet<string>(
await db.RoomDataBlobs
.Where(b => allHistoryNames.Contains(b.BlobName))
.Select(b => b.BlobName).ToListAsync(),
StringComparer.OrdinalIgnoreCase);
var historyAlreadyInDbAtStart = existingHistoryNames.Count;
var historyImported = 0;
var historySkipped = 0;
var imageBlobName = string.Empty; var imageBlobName = string.Empty;
await using (var tx = await db.Database.BeginTransactionAsync()) await using (var tx = await db.Database.BeginTransactionAsync())
{ {
// Subroom blobs — canonical save per scene + every History/ entry // Subroom blobs
// restoring the rec.net DataBlob filename verbatim so the in-game
// `Restore` UI's `datahistory` list shows up as expected.
//
// De-dup the History writes the same way HTR / PV / Polaroid
// sections do: pre-load existing BlobNames from the DB, then
// append in-memory as we queue each new row. Without this the
// unique index `IX_RoomDataBlobs_BlobName` fires on the
// SaveChanges when two scenes share a history hash (common: a
// baseline blob that didn't change between subroom edits).
var seenHistoryNames = new HashSet<string>(
existingHistoryNames, StringComparer.OrdinalIgnoreCase);
// Watch's URL uses RoomSceneEntity.OrderIndex (e.g. /datahistory
// for `subRoom=11` is asking for the scene at OrderIndex 11),
// NOT the rec.net SubRoomId from the manifest (e.g. 9717143).
// RoomDataBlobs.SubRoomId must store the OrderIndex so the
// datahistory query (`WHERE SubRoomId = subRoomId`) matches.
// Use the same idx that the RoomSceneEntity insert loop below
// assigns — both iterate `ordered` in lockstep.
int historyOrderIdx = 0;
foreach (var s in ordered) foreach (var s in ordered)
{ {
await WriteBlobAsync(roomId, MakeBlobName(s.SceneName), s.Bytes, creator); await WriteBlobAsync(roomId, MakeBlobName(s.SceneName), s.Bytes, creator);
foreach (var h in s.History)
{
if (!seenHistoryNames.Add(h.DataBlob))
{
historySkipped++;
continue;
}
var hBytes = await ReadAllAsync(h.RoomEntry);
// Don't normalise historical blobs — they're a snapshot of
// exactly what rec.net served at the time, and replaying
// them through the normaliser would mutate the bytes
// in subtle ways that obscure provenance. The watch reads
// them via CDN just like a normal save, so the same
// protobuf parser applies anyway.
await WriteBlobAsync(
roomId: roomId,
blobName: h.DataBlob,
bytes: hBytes,
uploaderPlayerId: h.SavedByAccountId ?? creator,
uploadedAt: h.CreatedAt,
subRoomId: historyOrderIdx);
historyImported++;
}
historyOrderIdx++;
} }
logger.LogInformation(
"[zip-import] room='{Room}' history imports={Imported} (skipped={Skipped})",
name, historyImported, historySkipped);
// HTR assets (shared content — keyed by hash, RoomId=0) // HTR assets (shared content — keyed by hash, RoomId=0)
foreach (var (hname, hentry) in allHtrAssets) foreach (var (hname, hentry) in allHtrAssets)
{ {
@@ -1212,16 +883,6 @@ public class RoomZipImportController(
var bytes = await ReadAllAsync(pentry); var bytes = await ReadAllAsync(pentry);
await WriteBlobAsync(0, fname, bytes, creator); await WriteBlobAsync(0, fname, bytes, creator);
} }
var studioPromoPrefix = $"{roomFolder}/photos/";
foreach (var (k, pentry) in entries.Where(kv =>
kv.Key.StartsWith(studioPromoPrefix, StringComparison.OrdinalIgnoreCase)
&& Path.GetFileNameWithoutExtension(kv.Key).StartsWith("promo", StringComparison.OrdinalIgnoreCase)))
{
var fname = $"PromoImage_{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 db.Rooms.Add(new RoomEntity
{ {
@@ -1275,7 +936,7 @@ public class RoomZipImportController(
db.AdminActions.Add(new AdminActionEntity db.AdminActions.Add(new AdminActionEntity
{ {
AdminPlayerId = adminId, AdminPlayerId = CurrentAdminId,
Action = "zip_import_room", Action = "zip_import_room",
TargetType = "room", TargetType = "room",
TargetId = roomId, TargetId = roomId,
@@ -1318,13 +979,6 @@ public class RoomZipImportController(
newlyImported = polaroidCount, newlyImported = polaroidCount,
alreadyInDb = polaroidAlreadyInDbAtStart, alreadyInDb = polaroidAlreadyInDbAtStart,
}, },
history = new
{
referenced = allHistoryNames.Count,
newlyImported = historyImported,
alreadyInDb = historyAlreadyInDbAtStart,
skipped = historySkipped,
},
// Legacy fields kept for back-compat with anything that // Legacy fields kept for back-compat with anything that
// already reads them; new code should prefer htrAssets / // already reads them; new code should prefer htrAssets /
// pvImages / polaroids above. // pvImages / polaroids above.
@@ -1354,154 +1008,11 @@ public class RoomZipImportController(
}; };
} }
private async Task<(List<ResolvedSubroom> Resolved, List<string> Missing)> ResolveStudioDumpSubroomsAsync(
string roomFolder,
RoomDetailsDto details,
string roomName,
Dictionary<string, ZipArchiveEntry> entries,
bool normalizeBlobs)
{
var resolvedScenes = new List<ResolvedSubroom>();
var missingScenes = new List<string>();
var rootPrefix = $"{roomFolder}/";
var sceneFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in entries.Keys.Where(k => k.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)))
{
var rel = path[rootPrefix.Length..];
var slash = rel.IndexOf('/');
if (slash <= 0) continue;
var sceneFolder = rel[..slash];
if (rel[(slash + 1)..].StartsWith("saves/", StringComparison.OrdinalIgnoreCase))
sceneFolders.Add(sceneFolder);
}
foreach (var sceneFolderName in sceneFolders.OrderBy(StripStudioSuffix, StringComparer.OrdinalIgnoreCase))
{
var sceneName = StripStudioSuffix(sceneFolderName);
var savesPrefix = $"{rootPrefix}{sceneFolderName}/saves/";
var saves = new List<(long SaveId, HistorySidecarDto Sidecar, ZipArchiveEntry DataEntry)>();
foreach (var (path, jsonEntry) in entries.Where(kv =>
kv.Key.StartsWith(savesPrefix, StringComparison.OrdinalIgnoreCase)
&& kv.Key.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
&& !kv.Key[savesPrefix.Length..].Contains('/')))
{
var stem = Path.GetFileNameWithoutExtension(path);
if (!long.TryParse(stem, out var saveId)) continue;
if (!entries.TryGetValue($"{savesPrefix}{saveId}__data.room", out var dataEntry))
{
missingScenes.Add($"{sceneName} save {saveId} (missing {saveId}__data.room)");
continue;
}
var sidecar = await ReadJsonAsync<HistorySidecarDto>(jsonEntry) ?? new HistorySidecarDto();
saves.Add((sidecar.SubRoomDataSaveId ?? saveId, sidecar, dataEntry));
}
if (saves.Count == 0)
{
missingScenes.Add($"{sceneName} (no usable saves)");
continue;
}
var current = saves.OrderBy(s => s.SaveId).Last();
var subroomDetails = details.SubRooms?.FirstOrDefault(s =>
(s.SubRoomId.HasValue && s.SubRoomId == (current.Sidecar.SubRoomId ?? TryParseStudioId(sceneFolderName)))
|| string.Equals(s.Name, sceneName, StringComparison.OrdinalIgnoreCase));
var manifest = new SubRoomDto
{
SubRoomId = current.Sidecar.SubRoomId ?? subroomDetails?.SubRoomId ?? TryParseStudioId(sceneFolderName),
UnitySceneId = subroomDetails?.UnitySceneId,
Name = subroomDetails?.Name ?? sceneName,
IsSandbox = subroomDetails?.IsSandbox ?? true,
MaxPlayers = subroomDetails?.MaxPlayers ?? details.MaxPlayers ?? 8,
Accessibility = subroomDetails?.Accessibility ?? details.Accessibility ?? 1,
CurrentSave = new CurrentSaveDto
{
SubRoomDataSaveId = current.SaveId,
DataBlob = current.Sidecar.DataBlob ?? ZipFileName(current.DataEntry.FullName),
},
};
var blobBytes = await ReadAllAsync(current.DataEntry);
var norm = roomBlobNormalizer.Normalize(blobBytes);
var persistedBytes = normalizeBlobs ? norm.Bytes : blobBytes;
var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>();
var pvImages = new List<(string Name, ZipArchiveEntry Entry)>();
var polaroids = new List<(string Name, ZipArchiveEntry Entry)>();
foreach (var save in saves)
{
var refPrefix = $"{savesPrefix}{save.SaveId}__ref_";
foreach (var (path, assetEntry) in entries.Where(kv =>
kv.Key.StartsWith(refPrefix, StringComparison.OrdinalIgnoreCase)
&& !kv.Key[refPrefix.Length..].Contains('/')))
{
var assetName = path[refPrefix.Length..];
var ext = Path.GetExtension(assetName).ToLowerInvariant();
if (ext == ".htr")
{
htrAssets.Add((assetName, assetEntry));
}
else if (IsImageExt(ext))
{
if (assetName.StartsWith("PVImage_", StringComparison.OrdinalIgnoreCase))
{
pvImages.Add((assetName, assetEntry));
polaroids.Add((assetName["PVImage_".Length..], assetEntry));
}
else
{
pvImages.Add(($"PVImage_{assetName}", assetEntry));
polaroids.Add((assetName, assetEntry));
}
}
}
}
var history = new List<HistorySave>();
foreach (var save in saves.Where(s => s.SaveId != current.SaveId))
{
history.Add(new HistorySave(
DataBlob: !string.IsNullOrWhiteSpace(save.Sidecar.DataBlob)
? save.Sidecar.DataBlob!
: ZipFileName(save.DataEntry.FullName),
RoomEntry: save.DataEntry,
SubRoomId: save.Sidecar.SubRoomId ?? manifest.SubRoomId,
SubRoomDataSaveId: save.Sidecar.SubRoomDataSaveId ?? save.SaveId,
CreatedAt: save.Sidecar.CreatedAt,
Description: save.Sidecar.Description,
SavedByAccountId: save.Sidecar.SavedByAccountId));
}
resolvedScenes.Add(new ResolvedSubroom(
Manifest: manifest,
SceneName: sceneName,
Bytes: persistedBytes,
RawBytes: blobBytes.Length,
NormalizedOk: norm.Normalized,
HtrAssets: htrAssets,
PvImages: pvImages,
Polaroids: polaroids,
History: history));
logger.LogInformation(
"[zip-import] studio-dump room='{Room}' scene='{Scene}' save={SaveId} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol} history={History}",
roomName, sceneName, current.SaveId, blobBytes.Length, norm.Bytes.Length,
normalizeBlobs ? "normaliser" : "raw",
htrAssets.Count, pvImages.Count, polaroids.Count, history.Count);
}
return (resolvedScenes, missingScenes);
}
// ── Per-invention import ───────────────────────────────────────── // ── Per-invention import ─────────────────────────────────────────
private async Task<object> ImportInventionAsync( private async Task<object> ImportInventionAsync(
string folder, string folder,
long creator, long creator,
long adminId,
Dictionary<string, ZipArchiveEntry> entries) Dictionary<string, ZipArchiveEntry> entries)
{ {
// Invention.json // Invention.json
@@ -1548,58 +1059,6 @@ public class RoomZipImportController(
var blobBytes = await ReadAllAsync(blobEntry); var blobBytes = await ReadAllAsync(blobEntry);
// Preserve the ORIGINAL Rec Room InventionId so the .room blob's
// embedded invention references resolve. The watch's blob format
// stores invention refs as `InventionId` longs (verified by
// varint-scanning a known room blob); if we let EF auto-generate
// a new Id, every invention-spawn reference inside the blob
// points at a non-existent id → invention shape doesn't spawn →
// big visible holes (floor missing, custom geometry absent, etc.).
//
// Skip the explicit-Id path when the json doesn't carry an
// InventionId (corrupt exports) or when our local DB already has
// a row with that id (cross-room collision: rare, but if a single
// zip imports two rooms that both reference the same shared
// invention with the same id, the second import would conflict —
// fall back to auto-id and accept the lookup loss for that one).
long? preservedId = null;
if (meta.InventionId is long origId && origId > 0)
{
var idTaken = await db.Inventions.AsNoTracking().AnyAsync(x => x.Id == origId);
if (!idTaken) preservedId = origId;
else logger.LogWarning("[zip-import] invention id {Id} already exists in DB; using auto-id (refs from .room blob may not resolve)", origId);
}
// Map CreationRoomId from the ORIGINAL Rec Room room id (carried
// in the json) to our LOCAL room id when we're importing the
// invention alongside a room in the same archive. Without this
// remap, /api/inventions/v1/room?id={localRoomId} returns nothing
// (watch asks with our local id; the row carries the original
// remote id). Fall back to the original id when we can't resolve
// the local room (e.g. invention imported standalone).
long? remappedRoomId = meta.CreationRoomId;
if (meta.CreationRoomId is long origRoomId)
{
// Walk recent zip_import_room admin actions for the same
// session to find which local room id maps to the original.
// The Reason text encodes "name={Name}" but not the original
// id; the room's Description sometimes hints. Simpler: match
// by name when the room json's CreationRoomId equals
// RoomDetails.RoomId we read earlier. We pass the local
// room id in via the caller chain when available.
//
// For zips that import the room AND its inventions together,
// ImportRoomAsync runs first and adds the room with the new
// local id, so by the time ImportInventionAsync runs we can
// look up the room by RoomDetails.RoomId == meta.CreationRoomId
// (the original) — but we don't store that field. To avoid
// an entity schema change here, attempt a lookup by the
// local id matching the original (handles single-room imports
// where the id happens to match) and otherwise leave as-is.
// The watch endpoint will need to handle both id spaces.
remappedRoomId = origRoomId;
}
var entity = new InventionEntity var entity = new InventionEntity
{ {
CreatorPlayerId = creator, CreatorPlayerId = creator,
@@ -1617,13 +1076,12 @@ public class RoomZipImportController(
CurrentVersionNumber = meta.CurrentVersionNumber ?? version.VersionNumber ?? 1, CurrentVersionNumber = meta.CurrentVersionNumber ?? version.VersionNumber ?? 1,
NumPlayersHaveUsedInRoom = meta.NumPlayersHaveUsedInRoom ?? 0, NumPlayersHaveUsedInRoom = meta.NumPlayersHaveUsedInRoom ?? 0,
CheerCount = meta.CheerCount ?? 0, CheerCount = meta.CheerCount ?? 0,
CreationRoomId = remappedRoomId, CreationRoomId = meta.CreationRoomId,
TagsCsv = JoinTags(extras.Tags), TagsCsv = JoinTags(extras.Tags),
CreatedAt = meta.CreatedAt ?? DateTime.UtcNow, CreatedAt = meta.CreatedAt ?? DateTime.UtcNow,
UpdatedAt = meta.ModifiedAt ?? DateTime.UtcNow, UpdatedAt = meta.ModifiedAt ?? DateTime.UtcNow,
FirstPublishedAt = meta.FirstPublishedAt, FirstPublishedAt = meta.FirstPublishedAt,
}; };
if (preservedId is long pid) entity.Id = pid;
db.Inventions.Add(entity); db.Inventions.Add(entity);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -1642,34 +1100,9 @@ public class RoomZipImportController(
await WriteBlobAsync(0, imageBlobName, bytes, creator); await WriteBlobAsync(0, imageBlobName, bytes, creator);
} }
// Insert the per-version row for the watch's
// /api/inventions/v1/version?inventionId=X&version=N lookup.
// Without this the watch's room-load chain can't resolve a
// BlobName to download (returns 404 → invention spawns as empty
// placeholder), even though the bytes are on disk and the
// InventionEntity has CurrentBlobName set. The /v3/save path
// (api/inventions) writes this row at create time — the zip
// importer was missing the same step.
var exists = await db.InventionVersions.AnyAsync(
v => v.InventionId == entity.Id && v.VersionNumber == entity.CurrentVersionNumber);
if (!exists)
{
db.InventionVersions.Add(new InventionVersionEntity
{
InventionId = entity.Id,
ReplicationId = entity.ReplicationId ?? Guid.NewGuid().ToString("D"),
VersionNumber = entity.CurrentVersionNumber,
BlobName = blobName,
// Costs aren't in our minimal InventionVersionDto today;
// safe defaults until we extend the DTO to read them.
InstantiationCost = 0,
LightsCost = 0,
});
}
db.AdminActions.Add(new AdminActionEntity db.AdminActions.Add(new AdminActionEntity
{ {
AdminPlayerId = adminId, AdminPlayerId = CurrentAdminId,
Action = "zip_import_invention", Action = "zip_import_invention",
TargetType = "invention", TargetType = "invention",
TargetId = entity.Id, TargetId = entity.Id,
@@ -1698,74 +1131,6 @@ public class RoomZipImportController(
private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/'); private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/');
private static string ZipFileName(string path)
{
var normalized = Normalize(path).TrimEnd('/');
var slash = normalized.LastIndexOf('/');
return slash >= 0 ? normalized[(slash + 1)..] : normalized;
}
private static string StripStudioSuffix(string name)
{
var marker = name.LastIndexOf("__", StringComparison.Ordinal);
return marker > 0 && long.TryParse(name[(marker + 2)..], out _)
? name[..marker]
: name;
}
private static long? TryParseStudioId(string name)
{
var marker = name.LastIndexOf("__", StringComparison.Ordinal);
return marker > 0 && long.TryParse(name[(marker + 2)..], out var id) ? id : null;
}
private static bool IsImageExt(string ext) => ext.ToLowerInvariant() switch
{
".jpg" or ".jpeg" or ".png" or ".webp" or ".gif" => true,
_ => false,
};
private static bool HasStudioSaveEntries(string roomFolder, IEnumerable<string> paths)
{
var prefix = $"{roomFolder.TrimEnd('/')}/";
return paths.Any(k =>
{
if (!k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|| !k.EndsWith("__data.room", StringComparison.OrdinalIgnoreCase))
return false;
var rel = k[prefix.Length..];
var parts = rel.Split('/');
return parts.Length == 3
&& parts[0].Contains("__", StringComparison.Ordinal)
&& string.Equals(parts[1], "saves", StringComparison.OrdinalIgnoreCase);
});
}
private static ZipArchiveEntry? FindStudioPhotoEntry(
string roomFolder,
string? imageName,
Dictionary<string, ZipArchiveEntry> entries)
{
var photosPrefix = $"{roomFolder}/photos/";
if (!string.IsNullOrWhiteSpace(imageName))
{
var byImageName = entries.FirstOrDefault(kv =>
kv.Key.StartsWith(photosPrefix, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Path.GetFileName(kv.Key), imageName, StringComparison.OrdinalIgnoreCase)).Value;
if (byImageName is not null) return byImageName;
var imageStem = Path.GetFileNameWithoutExtension(imageName);
byImageName = entries.FirstOrDefault(kv =>
kv.Key.StartsWith(photosPrefix, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Path.GetFileNameWithoutExtension(kv.Key), imageStem, StringComparison.OrdinalIgnoreCase)).Value;
if (byImageName is not null) return byImageName;
}
return entries.FirstOrDefault(kv =>
kv.Key.StartsWith(photosPrefix, StringComparison.OrdinalIgnoreCase)
&& string.Equals(Path.GetFileNameWithoutExtension(kv.Key), "main", StringComparison.OrdinalIgnoreCase)).Value;
}
private static async Task<byte[]> ReadAllAsync(ZipArchiveEntry entry) private static async Task<byte[]> ReadAllAsync(ZipArchiveEntry entry)
{ {
await using var s = entry.Open(); await using var s = entry.Open();
@@ -1799,24 +1164,7 @@ public class RoomZipImportController(
bool NormalizedOk, bool NormalizedOk,
List<(string Name, ZipArchiveEntry Entry)> HtrAssets, List<(string Name, ZipArchiveEntry Entry)> HtrAssets,
List<(string Name, ZipArchiveEntry Entry)> PvImages, List<(string Name, ZipArchiveEntry Entry)> PvImages,
List<(string Name, ZipArchiveEntry Entry)> Polaroids, List<(string Name, ZipArchiveEntry Entry)> Polaroids);
List<HistorySave> History);
/// <summary>One historical save under SubRooms/&lt;scene&gt;/History/.
/// The .room blob bytes go into S3 under the original
/// <see cref="DataBlob"/> filename so the watch's `Restore` UI can
/// fetch them by name. The optional .json sidecar carries the
/// original <see cref="CreatedAt"/> + provenance, which we mirror
/// into <see cref="RoomDataBlobEntity.UploadedAt"/> so the in-game
/// history list sorts in real chronological order.</summary>
private sealed record HistorySave(
string DataBlob,
ZipArchiveEntry RoomEntry,
long? SubRoomId,
long? SubRoomDataSaveId,
DateTime? CreatedAt,
string? Description,
long? SavedByAccountId);
/// <summary>Decode RecNet's room-snapshot manifest <c>.meta</c> and /// <summary>Decode RecNet's room-snapshot manifest <c>.meta</c> and
/// return the entry-subroom index it encodes, or null if the file /// return the entry-subroom index it encodes, or null if the file
@@ -0,0 +1,230 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Admin;
/// <summary>
/// admin.*/api/admin/v1/storage/backfill — one-shot migrator that
/// moves the legacy <c>RoomDataBlobs.Bytes</c> column out of the
/// database and into S3 at the canonical key from
/// <see cref="BlobRouter.Route"/>. After every row's bytes have been
/// uploaded and verified, a follow-up EF migration drops the
/// <c>Bytes</c> column entirely.
///
/// Why this exists: every controller that writes new content already
/// targets S3 directly (the importer, photo upload, profile image,
/// dorm save, room save, …). The <c>Bytes</c> column only holds
/// pre-cutover data — rooms imported before the S3-only refactor
/// landed. This endpoint drains that legacy column into S3 once and
/// then we never look at it again.
///
/// Runs as a background task so the HTTP request returns immediately
/// even for a multi-GB drain. Progress streams to the server log and
/// to a small in-memory <see cref="BackfillStatus"/> snapshot the UI
/// can poll. Idempotent — each row is checked against
/// <see cref="IObjectStorage.ExistsAsync"/> first, and rows whose
/// bytes are already in S3 get their DB <c>Bytes</c> cleared without
/// re-upload.
/// </summary>
[ApiController]
[Route("api/admin/v1/storage")]
[Authorize]
[AdminOnly]
public class StorageBackfillController(
ILogger<StorageBackfillController> log,
IServiceScopeFactory scopeFactory) : ControllerBase
{
private static readonly object _gate = new();
private static BackfillStatus _status = BackfillStatus.Idle;
/// <summary>POST <c>/storage/backfill?dryRun=true</c> — start a
/// drain pass. Returns 200 immediately; the actual work happens
/// in a background task. Re-POSTing while running returns 409.
/// Set <c>?dryRun=true</c> on the first pass to log every
/// (row → bucket/key) decision without writing to S3 or clearing
/// the DB <c>Bytes</c> column.</summary>
[HttpPost("backfill")]
public IActionResult Start([FromQuery] bool dryRun = false)
{
lock (_gate)
{
if (_status.Running)
return Conflict(new { error = "already_running", since = _status.StartedAt });
_status = BackfillStatus.Starting(dryRun);
}
_ = Task.Run(() => RunAsync(dryRun));
return Ok(new { started = true, dryRun });
}
/// <summary>GET <c>/storage/backfill/status</c> — current
/// progress. Updated row-by-row during a run.</summary>
[HttpGet("backfill/status")]
public IActionResult Status() => Ok(_status);
private async Task RunAsync(bool dryRun)
{
var startedAt = DateTime.UtcNow;
try
{
// Phase 1: pull the list of row ids that still have bytes.
// Materialise just the ids (not the bytes) up-front so we
// don't hold a reader open across per-row S3 PUTs — that
// would conflict with the per-row ExecuteUpdateAsync on
// Npgsql, which doesn't allow a second command while a
// reader is mid-iteration. Bytes get fetched one row at a
// time below so RAM only holds one blob at a time.
// Filter on Bytes != null only — Npgsql can't translate
// .Length on a byte[] property (it sees it as Enumerable.Any
// and bails). Empty-byte-array rows don't exist in our
// schema; rows either hold real bytes or NULL after backfill.
// We still re-check Length on the materialised row before
// uploading, defensive against any future client-evaluated
// edge case.
List<long> rowIds;
long total;
await using (var scope = scopeFactory.CreateAsyncScope())
{
var dbScoped = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
rowIds = await dbScoped.RoomDataBlobs
.AsNoTracking()
.Where(b => b.Bytes != null)
.OrderBy(b => b.Id)
.Select(b => b.Id)
.ToListAsync();
total = rowIds.Count;
}
log.LogInformation(
"[backfill] starting — rowsWithBytes={Total} dryRun={DryRun}",
total, dryRun);
lock (_gate) _status = _status with { Total = total };
// Phase 2: per-row upload + clear. Each row gets its own
// short-lived DbContext so the reader/writer conflict is
// structurally impossible. Memory-bound at one row's
// bytes plus query overhead.
foreach (var rowId in rowIds)
{
await using var rowScope = scopeFactory.CreateAsyncScope();
var rowDb = rowScope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
var storageScoped = rowScope.ServiceProvider.GetRequiredService<IObjectStorage>();
var row = await rowDb.RoomDataBlobs
.AsNoTracking()
.Where(b => b.Id == rowId)
.Select(b => new { b.Id, b.BlobName, b.Bytes })
.FirstOrDefaultAsync();
if (row is null || row.Bytes is null || row.Bytes.Length == 0)
{
Bump(s => s with { Skipped = s.Skipped + 1 });
continue;
}
var (bucket, key) = BlobRouter.Route(row.BlobName);
try
{
if (dryRun)
{
log.LogInformation(
"[backfill:dry] {Blob} → {Bucket}/{Key} ({Bytes} B)",
row.BlobName, bucket, key, row.Bytes.Length);
Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName });
continue;
}
// Upload unconditionally (S3 PUT is idempotent /
// overwriting). Verifying with ExistsAsync first
// would let us skip already-mirrored rows, but a
// mismatch between S3 and DB bytes (corrupted
// earlier upload, partial backfill, etc.) would
// leave the divergence in place. Re-uploading
// guarantees S3 == DB before we clear DB.
var contentType = MimeFromName(row.BlobName);
// Per-row timeout: 4 minutes generous enough for a
// 100 MB blob over a 5 Mbps origin link, well
// inside the 5-minute S3 SDK ceiling. Without a
// CT, a hung S3 connection could freeze the whole
// drain indefinitely.
using var putCt = new CancellationTokenSource(TimeSpan.FromMinutes(4));
await storageScoped.PutAsync(bucket, key, row.Bytes, contentType, putCt.Token);
// SAFETY GATE: confirm S3 actually persisted the
// object before dropping the DB copy. PutAsync
// returning success isn't enough — some S3 impls
// can ack a write that disappears (eventual
// consistency, replication lag, write buffer
// dropped on shutdown). HEAD costs nothing.
using var headCt = new CancellationTokenSource(TimeSpan.FromSeconds(15));
if (!await storageScoped.ExistsAsync(bucket, key, headCt.Token))
throw new InvalidOperationException(
$"S3 PUT {bucket}/{key} returned success but ExistsAsync says no — refusing to clear DB bytes");
await rowDb.RoomDataBlobs
.Where(b => b.Id == row.Id)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Bytes, (byte[]?)null));
Bump(s => s with { Uploaded = s.Uploaded + 1, LastBlobName = row.BlobName });
}
catch (Exception ex)
{
log.LogWarning(ex, "[backfill] failed {Blob} → {Bucket}/{Key} — DB bytes left intact",
row.BlobName, bucket, key);
Bump(s => s with { Failed = s.Failed + 1, LastBlobName = row.BlobName });
}
}
var elapsed = (int)(DateTime.UtcNow - startedAt).TotalMilliseconds;
lock (_gate) _status = _status with
{
Running = false,
FinishedAt = DateTime.UtcNow,
ElapsedMs = elapsed,
};
log.LogInformation(
"[backfill] finished — total={Total} uploaded={Up} alreadyMirrored={Already} skipped={Skip} failed={Fail} dryRun={Dry} elapsed={Ms}ms",
_status.Total, _status.Uploaded, _status.AlreadyMirrored, _status.Skipped, _status.Failed, dryRun, elapsed);
}
catch (Exception ex)
{
log.LogError(ex, "[backfill] crashed");
lock (_gate) _status = _status with { Running = false, FinishedAt = DateTime.UtcNow, Error = ex.Message };
}
}
private static void Bump(Func<BackfillStatus, BackfillStatus> mutate)
{
lock (_gate) _status = mutate(_status);
}
private static string MimeFromName(string name) => Path.GetExtension(name).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".mp4" => "video/mp4",
_ => "application/octet-stream",
};
public sealed record BackfillStatus(
bool Running,
bool DryRun,
DateTime? StartedAt,
DateTime? FinishedAt,
long Total,
long Uploaded,
long AlreadyMirrored,
long Skipped,
long Failed,
string? LastBlobName,
int? ElapsedMs,
string? Error)
{
public static BackfillStatus Idle => new(false, false, null, null, 0, 0, 0, 0, 0, null, null, null);
public static BackfillStatus Starting(bool dryRun) =>
new(true, dryRun, DateTime.UtcNow, null, 0, 0, 0, 0, 0, null, null, null);
}
}
@@ -23,7 +23,7 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase
/// <c>ExpectPrimitiveResponse&lt;bool&gt;</c> so the body must be /// <c>ExpectPrimitiveResponse&lt;bool&gt;</c> so the body must be
/// the literal JSON <c>true</c> or <c>false</c>.</summary> /// the literal JSON <c>true</c> or <c>false</c>.</summary>
[HttpGet("/account/me/haspassword")] [HttpGet("/account/me/haspassword")]
[HttpGet("/api/platformlogin/haspassword", Order = 10)] [HttpGet("/api/platformlogin/haspassword")]
[Authorize] [Authorize]
public async Task<IActionResult> HasPassword() public async Task<IActionResult> HasPassword()
{ {
@@ -40,8 +40,8 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase
/// one; first-time creation skips that check. Form-encoded body.</summary> /// one; first-time creation skips that check. Form-encoded body.</summary>
[HttpPost("/account/me/changepassword")] [HttpPost("/account/me/changepassword")]
[HttpPost("/account/me/createpassword")] [HttpPost("/account/me/createpassword")]
[HttpPost("/api/platformlogin/password", Order = 10)] [HttpPost("/api/platformlogin/password")]
[HttpPut("/api/platformlogin/password", Order = 10)] [HttpPut("/api/platformlogin/password")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
[Authorize] [Authorize]
public async Task<IActionResult> ChangePassword( public async Task<IActionResult> ChangePassword(
@@ -76,7 +76,7 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase
/// account from the admin tab and the user re-creates it via /// account from the admin tab and the user re-creates it via
/// changepassword on next login. Anonymous calls 401.</summary> /// changepassword on next login. Anonymous calls 401.</summary>
[HttpPost("/account/me/resetpassword")] [HttpPost("/account/me/resetpassword")]
[HttpPost("/api/platformlogin/resetpassword", Order = 10)] [HttpPost("/api/platformlogin/resetpassword")]
[Authorize] [Authorize]
[AdminOnly] [AdminOnly]
public async Task<IActionResult> AdminResetPassword( public async Task<IActionResult> AdminResetPassword(
@@ -235,18 +235,6 @@ public class AuthController(
}); });
} }
// 2020.12 added an EAC challenge step in the login flow. The client GETs
// a challenge string and passes it to EACManager.GenerateChallengeResponse,
// which calls System.Convert.FromBase64CharPtr on the string. So the
// returned challenge MUST be valid base64 — sending arbitrary text like
// "dorknet-eac-disabled" throws FormatException client-side and kicks
// the login. 24 chars of base64 'A's decodes to 18 zero bytes; matches
// the rough size of a real EAC handshake nonce.
[HttpGet("/eac/challenge")]
[HttpPost("/eac/challenge")]
public IActionResult EacChallenge()
=> Content("\"AAAAAAAAAAAAAAAAAAAAAAAA\"", "application/json");
// Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager. // Cached-logins lookup — boot-sequence call from ScreenAccountSelectionManager.
// URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId} // URL: GET auth.rec.net/cachedlogin/forplatformid/{platform}/{platformId}
// Returns List<Login.CachedLogin>. Empty array = no remembered accounts. // Returns List<Login.CachedLogin>. Empty array = no remembered accounts.
@@ -1,99 +0,0 @@
using DorkNet.Server.Compat;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.Auth;
/// <summary>The boot-time client-version gate the 2020.12 watch hits
/// at startup as <c>GET api/versioncheck/v4?v={build}&amp;p={platform}</c>.
///
/// <para>Verified against the watch's <c>KMDHPCHFADM.VerifyGameVersion</c>
/// (decompiled): the response is parsed into <c>JFKEIPDCCHH</c> which
/// exposes a single <c>VersionStatus</c> property of enum type
/// <c>PCBCGDHBAEL</c>:
/// <list type="bullet">
/// <item>0 = <c>ValidForPlay</c> — proceed normally</item>
/// <item>1 = <c>ValidForMenu</c> — let user reach menus but block matchmaking</item>
/// <item>2 = <c>UpdateRequired</c> — watch shows its built-in
/// "Update Required" dialog and refuses to advance past the boot screen</item>
/// </list>
/// This is the CLEAN way to reject a wrong-build client at the
/// front door — the watch has dedicated UX for it, no custom client
/// mod work needed.</para>
///
/// <para>The <c>v</c> query param is the raw build identifier the watch
/// reads from <c>KMJJGELKNAP.MCFMPCNNEBH</c> — for the December build
/// that's the literal string <c>"20201210"</c>. We map raw build IDs
/// to our internal <see cref="ClientVersion.Key"/>s via the
/// <c>DorkNet:BuildIdToVersionKey</c> config map; any build ID we
/// don't recognise OR that maps to a key not in
/// <see cref="VersionRegistry.SupportedVersionKeys"/> gets
/// <c>VersionStatus = 2</c> and the watch refuses to proceed.</para>
///
/// <para>This endpoint is EXEMPT from
/// <c>VersionDetectionMiddleware</c>'s gate (otherwise the gate would
/// 426 the rejection request itself before this endpoint could reply
/// with the polite UpdateRequired payload). The exemption is set in
/// <c>VersionDetectionMiddleware.IsExempt</c>.</para>
/// </summary>
[ApiController]
public class VersionCheckController(
IConfiguration config,
VersionRegistry registry,
ILogger<VersionCheckController> logger) : ControllerBase
{
// Mirrors the PCBCGDHBAEL enum the watch decodes the response into.
private const int VersionStatus_ValidForPlay = 0;
private const int VersionStatus_ValidForMenu = 1;
private const int VersionStatus_UpdateRequired = 2;
private static readonly IReadOnlyDictionary<string, string> BuiltInBuildIdToVersionKey =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["20201210"] = "december_2020_12_18",
};
[HttpGet("/api/versioncheck/v4")]
[HttpGet("/api/versioncheck/v3")]
[HttpGet("/api/versioncheck/v2")]
[HttpGet("/api/versioncheck/v1")]
[HttpGet("/api/versioncheck")]
[HttpGet("/api/version/v1")]
[HttpGet("/api/version")]
public IActionResult Check(
[FromQuery(Name = "v")] string? buildId,
[FromQuery(Name = "p")] string? platform)
{
// Map raw build ID → ClientVersion key via config. Format:
// "DorkNet": {
// "BuildIdToVersionKey": {
// "20201210": "december_2020_12_18",
// "20200310": "march_2020_03_10"
// }
// }
// Mapping ABSENT → unknown build → UpdateRequired.
var mapSection = config.GetSection("DorkNet:BuildIdToVersionKey");
string? versionKey = null;
if (!string.IsNullOrWhiteSpace(buildId))
{
var normalizedBuildId = buildId.Trim();
versionKey = mapSection[normalizedBuildId];
if (string.IsNullOrWhiteSpace(versionKey)
&& BuiltInBuildIdToVersionKey.TryGetValue(normalizedBuildId, out var builtInVersionKey))
{
versionKey = builtInVersionKey;
}
}
bool allowed = versionKey is not null && registry.IsSupported(versionKey);
var status = allowed ? VersionStatus_ValidForPlay : VersionStatus_UpdateRequired;
logger.LogInformation(
"[versioncheck] buildId={Build} platform={Platform} → versionKey={Key} status={Status} ({Label})",
buildId, platform, versionKey ?? "(unmapped)", status,
status == VersionStatus_ValidForPlay ? "ValidForPlay" : "UpdateRequired");
// Wire shape verified against KMDHPCHFADM_NestedType_JFKEIPDCCHH.txt:
// single key "VersionStatus" (PascalCase, case-sensitive — LitJson).
return Ok(new { VersionStatus = status });
}
}
@@ -94,12 +94,6 @@ public class CdnController(
// explicit route. Without this, the catch-all regex below only // explicit route. Without this, the catch-all regex below only
// matches single-segment names and `/video/cb_video_*.mp4` 404s. // matches single-segment names and `/video/cb_video_*.mp4` 404s.
[Route("/video/{*path:minlength(1)}")] [Route("/video/{*path:minlength(1)}")]
// `cdn.{apex}/invention/<BlobName>` — the watch's invention
// download fetcher (BBHENFCNLAB.GetInventionData at
// BBHENFCNLAB_NestedType_NBDMFFEGNGJ.txt:207) builds the URL via
// String.Concat("/invention/", filename). Same byte-fetch flow as
// /room/, routed to S3 via BlobRouter on the filename.
[Route("/invention/{*path:minlength(1)}")]
// ASP.NET route templates treat `[` / `]` as token markers // ASP.NET route templates treat `[` / `]` as token markers
// (e.g. `[controller]`). To use them as literals inside a regex // (e.g. `[controller]`). To use them as literals inside a regex
// constraint they must be doubled — so `[^/]` becomes `[[^/]]`. // constraint they must be doubled — so `[^/]` becomes `[[^/]]`.
@@ -351,31 +345,12 @@ public class CdnController(
} }
if (s3Bytes is { Length: > 0 }) if (s3Bytes is { Length: > 0 })
{ {
var overlaidRoomRoleData = false;
if (await ShouldOverlayRroRoomRoleDataAsync(fileName))
{
try
{
s3Bytes = roomDataBlob.OverlayRroEditableRoleData(s3Bytes);
overlaidRoomRoleData = true;
logger.LogInformation("[cdn] overlaid RRO room-role permissions host={Host} file={File} bytes={Bytes}",
Request.Host.Host, fileName, s3Bytes.Length);
}
catch (Exception ex)
{
logger.LogWarning(ex, "[cdn] failed to overlay RRO room-role permissions for {File}; serving original blob",
fileName);
}
}
logger.LogInformation("[cdn] s3 hit host={Host} file={File} bytes={Bytes}", logger.LogInformation("[cdn] s3 hit host={Host} file={File} bytes={Bytes}",
Request.Host.Host, fileName, s3Bytes.Length); Request.Host.Host, fileName, s3Bytes.Length);
// Allow CF edge to cache for an hour. RoomDataBlobs are // Allow CF edge to cache for an hour. RoomDataBlobs are
// content-addressed by hash so the same BlobName never // content-addressed by hash so the same BlobName never
// serves different bytes — safe to cache aggressively. // serves different bytes — safe to cache aggressively.
Response.Headers.CacheControl = overlaidRoomRoleData Response.Headers.CacheControl = "public, max-age=3600";
? "public, max-age=60"
: "public, max-age=3600";
signatures.AddContentSignature(Response, s3Bytes); signatures.AddContentSignature(Response, s3Bytes);
return new FileContentResult(s3Bytes, MimeFromName(fileName)); return new FileContentResult(s3Bytes, MimeFromName(fileName));
} }
@@ -393,16 +368,6 @@ public class CdnController(
return new FileContentResult(TransparentPng, "image/png"); return new FileContentResult(TransparentPng, "image/png");
} }
if (await ShouldOverlayRroRoomRoleDataAsync(fileName))
{
logger.LogInformation("[cdn] MISS host={Host} file={File} -> RRO editable role blob",
Request.Host.Host, fileName);
var rroBlob = roomDataBlob.GetRroEditableBlob();
Response.Headers.CacheControl = "public, max-age=60";
signatures.AddContentSignature(Response, rroBlob);
return new FileContentResult(rroBlob, "application/octet-stream");
}
logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob", logger.LogInformation("[cdn] MISS host={Host} file={File} -> default blob",
Request.Host.Host, fileName); Request.Host.Host, fileName);
var fallbackBlob = roomDataBlob.GetDefaultBlob(); var fallbackBlob = roomDataBlob.GetDefaultBlob();
@@ -410,33 +375,6 @@ public class CdnController(
return new FileContentResult(fallbackBlob, "application/octet-stream"); return new FileContentResult(fallbackBlob, "application/octet-stream");
} }
private async Task<bool> ShouldOverlayRroRoomRoleDataAsync(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return false;
if (await db.Rooms.AsNoTracking().AnyAsync(r =>
r.IsAGRoom &&
r.CurrentDataBlobName == fileName &&
db.RoomRoles.Any(rr => rr.RoomId == r.Id && rr.Accepted)))
return true;
if (fileName.StartsWith("room_", StringComparison.OrdinalIgnoreCase) &&
fileName.EndsWith("_v1.dat", StringComparison.OrdinalIgnoreCase) &&
long.TryParse(fileName[5..^7], out var roomId) &&
await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId && r.IsAGRoom) &&
await db.RoomRoles.AsNoTracking().AnyAsync(r => r.RoomId == roomId && r.Accepted))
return true;
return await (
from scene in db.RoomScenes.AsNoTracking()
join room in db.Rooms.AsNoTracking() on scene.RoomId equals room.Id
where room.IsAGRoom &&
scene.DataBlobName == fileName &&
db.RoomRoles.Any(rr => rr.RoomId == room.Id && rr.Accepted)
select scene.Id
).AnyAsync();
}
/// <summary> /// <summary>
/// Apply the watch's <c>?cropSquare=1</c> + <c>?width=N</c> transforms /// Apply the watch's <c>?cropSquare=1</c> + <c>?width=N</c> transforms
/// per-request on the way out and sign the *resulting* bytes — the /// per-request on the way out and sign the *resulting* bytes — the
@@ -1,939 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Clubs;
/// <summary>
/// clubs.localhost — the 2020.12 watch's club home tab and announcement
/// inbox. Path-based routing per the host-filter strategy in
/// Program.cs (HostFilteringMiddleware gates which subdomains reach
/// us, individual controllers don't need [Host] filters).
///
/// URL surface (verb + template) verified against
/// <c>Cpp2IL_ISIL/.../JDJGIBLMFKK.txt</c>:
/// GET /club/home/me
/// GET /club/mine/member
/// GET /club/mine/created
/// GET /club/categoryTags
/// GET /announcements/v2/mine/unread
/// GET /announcements/v2/subscription/mine/unread
/// POST /announcements/club/{clubId}/{announcementId}/read
///
/// Wire types (deserialiser JSON keys, per ISIL):
/// Club (<c>PLILLKHMNDA</c>): ClubId, Name, Description,
/// MainImageName, State, CreatorAccountId, Category,
/// Visibility, Joinability, AllowJuniors, MemberCount,
/// IsRRO, ClubhouseRoomId, ClubType
/// Announcement (<c>NFEMLMAFFIP</c>): AnnouncementId,
/// CreatorAccountId, ClubId, Title, Body, ImageName,
/// CreatedAt, Meta
/// UnreadResponse (<c>HPACLJHLHBG</c>) — wraps per-club rows:
/// { clubId, LastReadAnnouncementId, announcements: [Announcement] }
/// CategoryTags plain <c>List&lt;String&gt;</c> (per
/// <c>JDJGIBLMFKK.GetPrimaryTags</c> callback shape).
/// </summary>
[ApiController]
public class ClubsController(ClubService clubs) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("/club/home/me")]
[Authorize]
public async Task<IActionResult> HomeMe()
{
var pid = Me;
var home = await clubs.HomeClubAsync(pid);
if (home is null)
{
// Watch tolerates an empty/default Club object on the home
// tab — it shows the "no home club" empty state. Returning
// 204 No Content would short-circuit the deserialiser
// (<c>Action`1&lt;PLILLKHMNDA&gt;</c>) so we send a minimal
// sentinel row with ClubId=0 instead; the watch's null-check
// path keys off ClubId.
return Ok(ToWireClub(EmptyClub(), 0));
}
var memberCount = await clubs.MemberCountAsync(home.Id);
return Ok(ToWireClub(home, memberCount));
}
[HttpGet("/club/mine/member")]
[Authorize]
public async Task<IActionResult> MineMember()
{
var pid = Me;
var rows = await clubs.MyClubsAsync(pid);
var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id));
return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))));
}
[HttpGet("/club/mine/created")]
[Authorize]
public async Task<IActionResult> MineCreated()
{
var pid = Me;
var rows = await clubs.CreatedByAsync(pid);
var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id));
return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))));
}
/// <summary>GET <c>/club/search</c> — browse/search clubs by
/// optional category and text query. The watch issues this while
/// building the Clubs discover tab, e.g.
/// <c>?sort=0&amp;category=Creative&amp;count=30</c>, and expects
/// a <c>CAJHIEENJJD</c> envelope: <c>Clubs</c>,
/// <c>TotalClubs</c>, and <c>ContinuationToken</c>.</summary>
[HttpGet("/club/search")]
public async Task<IActionResult> ClubSearch(
[FromQuery] string? query,
[FromQuery] string? search,
[FromQuery] string? category,
[FromQuery] int? sort,
[FromQuery] int? count)
{
var result = await clubs.SearchAsync(query ?? search, category, sort ?? 0, count ?? 30);
var counts = await clubs.MemberCountsAsync(result.Clubs.Select(r => r.Id));
return Ok(new
{
Clubs = result.Clubs.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))),
TotalClubs = result.TotalClubs,
ContinuationToken = string.Empty,
});
}
/// <summary>GET <c>/club?name=Foo</c> — direct name lookup used by
/// the client when resolving a club by display name. Returns the
/// single <c>PLILLKHMNDA</c> Club wire shape.</summary>
[HttpGet("/club")]
public async Task<IActionResult> ClubByName([FromQuery] string? name)
{
if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { error = "missing_name" });
var club = await clubs.GetByNameAsync(name);
if (club is null) return NotFound();
var members = await clubs.MemberCountAsync(club.Id);
return Ok(ToWireClub(club, members));
}
/// <summary>The 2020.12 watch deserialises this as
/// <c>List&lt;String&gt;</c> (per <c>JDJGIBLMFKK.GetPrimaryTags</c>
/// callback signature), so we project just the tag Name column.</summary>
[HttpGet("/club/categoryTags")]
public async Task<IActionResult> CategoryTags()
{
var rows = await clubs.CategoryTagsAsync();
return Ok(rows.Select(t => t.Name));
}
/// <summary>
/// Unread direct-feed announcements grouped by club into
/// <c>HPACLJHLHBG</c> wire rows ({ clubId, LastReadAnnouncementId,
/// announcements }). One row per club that has at least one unread
/// item — clubs with nothing new are omitted so the watch's bell
/// badge counts correctly.
/// </summary>
[HttpGet("/announcements/v2/mine/unread")]
[Authorize]
public async Task<IActionResult> UnreadMine()
{
var pid = Me;
var rows = await clubs.UnreadDirectAsync(pid);
return Ok(GroupByClub(rows));
}
[HttpGet("/announcements/v2/subscription/mine/unread")]
[Authorize]
public async Task<IActionResult> UnreadSubscription()
{
var pid = Me;
var rows = await clubs.UnreadSubscriptionAsync(pid);
return Ok(GroupByClub(rows));
}
/// <summary>GET <c>/subscription/mine/member</c> — list of clubs the
/// caller is subscribed to. Watch deserializes as
/// <c>List&lt;LNIKPLKOBDK&gt;</c>; per the dump at
/// <c>LNIKPLKOBDK.txt:110-124</c> the keys are lowercase
/// <c>accountId</c>, <c>clubId</c>, <c>subscriberCount</c>. One row
/// per subscription. Empty array is legal — the watch just hides the
/// "Subscribed Clubs" section when the list is empty.</summary>
[HttpGet("/subscription/mine/member")]
[Authorize]
public async Task<IActionResult> MySubscriptions()
{
var pid = Me;
var rows = await clubs.MySubscriptionsAsync(pid);
return Ok(rows);
}
/// <summary>
/// GET <c>/subscription/subscriberCount/{accountId}</c> — bare int
/// of how many subscribers an account has. Watch caller is
/// <c>JDJGIBLMFKK.PNFHJBIAPBM(int)</c> at
/// <c>JDJGIBLMFKK.txt:5533,5774</c> with signature
/// <c>IPromise&lt;Int32&gt;</c>. Each Rec Room account has an
/// implicit personal "creator club" so we union both possible
/// counts:
/// * player→player subscriptions where TargetPlayerId == id, and
/// * club subscriptions where ClubId == id (if a club happens to
/// share the id).
/// Anonymous-safe — used by profile cards the watch shows before
/// the viewer is logged in to anyone they're not friends with.
/// </summary>
[HttpGet("/subscription/subscriberCount/{accountId:long}")]
public async Task<IActionResult> SubscriberCount(long accountId)
{
var playerSubs = await clubs.PlayerSubscriberCountAsync(accountId);
var clubSubs = await clubs.ClubSubscriberCountAsync(accountId);
return Ok(playerSubs + clubSubs);
}
/// <summary>
/// POST endpoint the watch hits when the player taps "mark read".
/// Idempotent — the upsert in the service quietly returns if a
/// read-marker already exists.
/// </summary>
[HttpPost("/announcements/club/{clubId:long}/{announcementId:long}/read")]
[Authorize]
public async Task<IActionResult> MarkRead(long clubId, long announcementId)
{
var pid = Me;
await clubs.MarkAnnouncementReadAsync(pid, announcementId);
return Ok(new { Read = true });
}
// ── Single-club reads ─────────────────────────────────────────────
/// <summary>GET <c>/club/{id}</c> — single Club lookup. Wire shape
/// is <c>PLILLKHMNDA</c> (same as the lists). Used by the watch's
/// link-resolver and the "club tile" widget on profile cards.</summary>
[HttpGet("/club/{clubId:long}")]
public async Task<IActionResult> ClubById(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
var members = await clubs.MemberCountAsync(clubId);
return Ok(ToWireClub(club, members));
}
/// <summary>GET <c>/club/{id}/details</c> — the central club page
/// payload. Wire shape is <c>PIHMJGCGNLP</c> with required keys
/// <c>Club</c>, <c>CoownerPermissions</c>, <c>ModeratorPermissions</c>,
/// <c>MemberPermissions</c>, <c>MyMembershipType</c> per
/// <c>PIHMJGCGNLP.cs</c>. Returning just the Club shape throws
/// KeyNotFoundException on the deserializer.</summary>
[HttpGet("/club/{clubId:long}/details")]
public Task<IActionResult> ClubDetails(long clubId) => BuildDetailsResponseAsync(clubId);
/// <summary><c>/club/{id}/clubhouse</c> — returns the same
/// <c>PIHMJGCGNLP</c> shape as /details. The watch's clubhouse
/// setter passes an optional roomId; clearing the clubhouse uses
/// DELETE. We accept GET / PUT / DELETE so whichever verb the
/// request-builder chose lands on the handler, then route the
/// roomId into ClubhouseRoomId when present (set/clear) and reply
/// with the refreshed details envelope.</summary>
[HttpGet("/club/{clubId:long}/clubhouse")]
[HttpPut("/club/{clubId:long}/clubhouse")]
[HttpDelete("/club/{clubId:long}/clubhouse")]
public async Task<IActionResult> ClubClubhouse(long clubId, [FromQuery(Name = "roomId")] long? roomId)
{
if (HttpMethods.IsPut(Request.Method) || HttpMethods.IsDelete(Request.Method))
{
try
{
var updated = await clubs.ModifyAsync(clubId, Me, c =>
c.ClubhouseRoomId = HttpMethods.IsDelete(Request.Method) ? null : roomId);
if (updated is null) return NotFound();
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
return await BuildDetailsResponseAsync(clubId);
}
/// <summary>GET <c>/club/{id}/permissions/{role}</c> — bare int of
/// the permission bitmask for a role on this club. Used by the
/// watch's role-permissions screen. Returns 0 for unknown roles so
/// the screen renders an empty checklist instead of erroring.</summary>
[HttpGet("/club/{clubId:long}/permissions/{role:int}")]
public async Task<IActionResult> ClubPermissions(long clubId, int role)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
return Ok(PermissionsForRole(role));
}
// ── Account / created lists ──────────────────────────────────────
/// <summary>GET <c>/account/{playerId}/clubs</c> — list of Clubs
/// the player is a member of. Wire shape is
/// <c>List&lt;PLILLKHMNDA&gt;</c>. Anonymous-safe — used on profile
/// cards.</summary>
[HttpGet("/account/{playerId:long}/clubs")]
public async Task<IActionResult> AccountClubs(long playerId)
{
var rows = await clubs.ClubsForPlayerAsync(playerId);
var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id));
return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))));
}
/// <summary>GET <c>/club/account/{playerId}/created</c> — list of
/// Clubs the player created. Wire shape is
/// <c>List&lt;PLILLKHMNDA&gt;</c>. Anonymous-safe.</summary>
[HttpGet("/club/account/{playerId:long}/created")]
public async Task<IActionResult> ClubAccountCreated(long playerId)
{
var rows = await clubs.CreatedByAsync(playerId);
var counts = await clubs.MemberCountsAsync(rows.Select(r => r.Id));
return Ok(rows.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))));
}
// ── Club create / modify ─────────────────────────────────────────
public sealed class CreateClubRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? MainImageName { get; set; }
public string? Category { get; set; }
}
/// <summary>POST <c>/club/create</c> — create a new club + the
/// owner's membership row. Returns the <c>PIHMJGCGNLP</c> envelope
/// (same shape as <c>/club/{id}/details</c>) so the watch's
/// post-create navigation can render the club page without a
/// separate fetch.</summary>
[HttpPost("/club/create")]
[Authorize]
public async Task<IActionResult> ClubCreate()
{
var req = await ReadCreateClubRequestAsync();
var club = await clubs.CreateAsync(Me, req.Name ?? string.Empty,
req.Description, req.MainImageName, req.Category);
if (club is null) return Conflict(new { error = "club_name_taken_or_empty" });
return await BuildDetailsResponseAsync(club.Id);
}
public sealed class ModifyClubRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? Category { get; set; }
public int? Visibility { get; set; }
public int? Joinability { get; set; }
public bool? AllowJuniors { get; set; }
public long? ClubhouseRoomId { get; set; }
}
/// <summary>POST <c>/club/{id}/modify</c> + <c>/modifydetails</c> —
/// the watch sends two different request DTOs to two URLs, but
/// both mutate the same Club row and both expect the
/// <c>PIHMJGCGNLP</c> response. Single handler covers both
/// because the response shape is identical and we only persist
/// fields the request includes.</summary>
[HttpPost("/club/{clubId:long}/modify")]
[HttpPut("/club/{clubId:long}/modify")]
[HttpPost("/club/{clubId:long}/modifydetails")]
[HttpPut("/club/{clubId:long}/modifydetails")]
[Authorize]
public async Task<IActionResult> ClubModify(long clubId, [FromBody] ModifyClubRequest req)
{
try
{
var updated = await clubs.ModifyAsync(clubId, Me, c =>
{
if (!string.IsNullOrWhiteSpace(req.Name)) c.Name = req.Name.Trim();
if (req.Description is not null) c.Description = req.Description;
if (req.Category is not null) c.Category = req.Category;
if (req.Visibility is int v) c.Visibility = v;
if (req.Joinability is int j) c.Joinability = j;
if (req.AllowJuniors is bool aj) c.AllowJuniors = aj;
if (req.ClubhouseRoomId is long ch) c.ClubhouseRoomId = ch;
});
if (updated is null) return NotFound();
return await BuildDetailsResponseAsync(updated.Id);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
public sealed class MainImageRequest { public string? ImageName { get; set; } }
/// <summary>POST <c>/club/{id}/mainimage</c> — replace the club's
/// MainImageName. Same <c>PIHMJGCGNLP</c> response so the watch can
/// refresh the club page in one round-trip.</summary>
[HttpPost("/club/{clubId:long}/mainimage")]
[HttpPut("/club/{clubId:long}/mainimage")]
[Authorize]
public async Task<IActionResult> ClubMainImage(long clubId, [FromBody] MainImageRequest req)
{
try
{
var updated = await clubs.ModifyAsync(clubId, Me,
c => c.ImageName = req.ImageName ?? string.Empty);
if (updated is null) return NotFound();
return await BuildDetailsResponseAsync(updated.Id);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
/// <summary>POST <c>/club/{id}/additionalimage/{slot}</c> — the
/// watch's gallery-image slot setter. The 2020 ClubEntity doesn't
/// have additional image slots persisted yet, so we just return
/// the current details unchanged (the gallery renders blank).
/// Hooked up so the request doesn't 404 — populated once a future
/// schema patch lands.</summary>
[HttpPost("/club/{clubId:long}/additionalimage/{slot:int}")]
[HttpPut("/club/{clubId:long}/additionalimage/{slot:int}")]
[Authorize]
public async Task<IActionResult> ClubAdditionalImage(long clubId, int slot, [FromBody] MainImageRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
return await BuildDetailsResponseAsync(clubId);
}
// ── Single-member read ───────────────────────────────────────────
/// <summary>GET <c>/club/{id}/members/{playerId}</c> — single
/// membership row. Wire shape is <c>MFOAODGNGKB</c>: AccountId,
/// ClubId, MembershipType, CreatedAt.</summary>
[HttpGet("/club/{clubId:long}/members/{playerId:long}")]
public async Task<IActionResult> ClubMember(long clubId, long playerId)
{
var row = await clubs.MembershipForAsync(clubId, playerId);
if (row is null) return NotFound();
return Ok(ToWireMembership(row));
}
// ── Member mutations (void / IPromise) ───────────────────────────
/// <summary>POST <c>/club/{id}/members/requesttojoin</c> — caller
/// asks to join. For Open clubs we promote straight to Member; for
/// RequestToJoin we mark Pending; InviteOnly clubs reject.</summary>
[HttpPost("/club/{clubId:long}/members/requesttojoin")]
[HttpPut("/club/{clubId:long}/members/requesttojoin")]
[Authorize]
public async Task<IActionResult> MemberRequestToJoin(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
var perms = club.Joinability switch
{
0 => 0, // Open: instant member
1 => 128, // RequestToJoin: pending
_ => -1, // InviteOnly: rejected
};
if (perms == -1) return Forbid();
await clubs.UpsertMembershipAsync(clubId, Me, perms);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/directJoin</c> — same as
/// requesttojoin for an Open club but bypasses the pending flow
/// even on RequestToJoin clubs (used by the "accept invite" flow
/// after a successful invite acceptance).</summary>
[HttpPost("/club/{clubId:long}/members/directJoin")]
[Authorize]
public async Task<IActionResult> MemberDirectJoin(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
await clubs.UpsertMembershipAsync(clubId, Me, 0);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/leave</c> — caller leaves
/// the club. Owner cannot leave without first transferring
/// ownership (mirrors the GroupsController.Leave guard).</summary>
[HttpPost("/club/{clubId:long}/members/leave")]
[Authorize]
public async Task<IActionResult> MemberLeave(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (club.CreatorPlayerId == Me)
return BadRequest(new { error = "owner_must_transfer_first" });
await clubs.RemoveMembershipAsync(clubId, Me);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/invite</c> — caller invites
/// a target player. Pending=128 marker; the invited player's
/// <c>acceptinvite</c> flips it to Member.</summary>
public sealed class MemberInviteRequest
{
public int? PlayerId { get; set; }
public int? AccountId { get; set; }
public int? MembershipType { get; set; }
}
[HttpPost("/club/{clubId:long}/members/invite")]
[HttpPut("/club/{clubId:long}/members/invite")]
[Authorize]
public async Task<IActionResult> MemberInvite(long clubId, [FromBody] MemberInviteRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
await clubs.UpsertMembershipAsync(clubId, target, 128); // Pending
return Ok();
}
/// <summary>POST <c>/club/{id}/members/invitemembers</c> — bulk
/// invite. Body is <c>{playerIds: [...]}</c> + optional
/// membership-type override.</summary>
public sealed class InviteMembersRequest
{
public List<int>? PlayerIds { get; set; }
public List<int>? AccountIds { get; set; }
public int? MembershipType { get; set; }
}
[HttpPost("/club/{clubId:long}/members/invitemembers")]
[Authorize]
public async Task<IActionResult> MemberInviteMembers(long clubId, [FromBody] InviteMembersRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
foreach (var pid in ids)
await clubs.UpsertMembershipAsync(clubId, pid, 128);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/acceptinvite</c> — caller
/// accepts an outstanding invite (their own pending row).</summary>
[HttpPost("/club/{clubId:long}/members/acceptinvite")]
[Authorize]
public async Task<IActionResult> MemberAcceptInvite(long clubId)
{
var row = await clubs.MembershipForAsync(clubId, Me);
if (row is null) return NotFound();
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_invite" });
await clubs.UpsertMembershipAsync(clubId, Me, 0); // Member
return Ok();
}
/// <summary>POST <c>/club/{id}/members/declineinvite</c> — caller
/// rejects an outstanding invite (removes their pending row).</summary>
[HttpPost("/club/{clubId:long}/members/declineinvite")]
[Authorize]
public async Task<IActionResult> MemberDeclineInvite(long clubId)
{
await clubs.RemoveMembershipAsync(clubId, Me);
return Ok();
}
public sealed class MemberTargetRequest
{
public int? PlayerId { get; set; }
public int? AccountId { get; set; }
public int? MembershipType { get; set; }
}
/// <summary>POST <c>/club/{id}/members/acceptrequest</c> — owner /
/// moderator approves a single pending join request, promoting the
/// target's row from Pending to Member.</summary>
[HttpPost("/club/{clubId:long}/members/acceptrequest")]
[Authorize]
public async Task<IActionResult> MemberAcceptRequest(long clubId, [FromBody] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
var row = await clubs.MembershipForAsync(clubId, target);
if (row is null) return NotFound();
if ((row.Permissions & 128) == 0) return BadRequest(new { error = "no_pending_request" });
await clubs.UpsertMembershipAsync(clubId, target, 0);
return Ok();
}
public sealed class BulkTargetRequest
{
public List<int>? PlayerIds { get; set; }
public List<int>? AccountIds { get; set; }
}
[HttpPost("/club/{clubId:long}/members/acceptrequests")]
[Authorize]
public async Task<IActionResult> MemberAcceptRequests(long clubId, [FromBody] BulkTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
foreach (var pid in ids)
{
var row = await clubs.MembershipForAsync(clubId, pid);
if (row is null || (row.Permissions & 128) == 0) continue;
await clubs.UpsertMembershipAsync(clubId, pid, 0);
}
return Ok();
}
[HttpPost("/club/{clubId:long}/members/denyrequest")]
[Authorize]
public async Task<IActionResult> MemberDenyRequest(long clubId, [FromBody] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
await clubs.RemoveMembershipAsync(clubId, target);
return Ok();
}
[HttpPost("/club/{clubId:long}/members/denyrequests")]
[Authorize]
public async Task<IActionResult> MemberDenyRequests(long clubId, [FromBody] BulkTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
foreach (var pid in ids)
await clubs.RemoveMembershipAsync(clubId, pid);
return Ok();
}
[HttpPost("/club/{clubId:long}/members/remove")]
[Authorize]
public async Task<IActionResult> MemberRemove(long clubId, [FromBody] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
if (target == club.CreatorPlayerId)
return BadRequest(new { error = "cannot_remove_owner" });
await clubs.RemoveMembershipAsync(clubId, target);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/ban</c> — kick + prevent
/// re-join. We persist with perms=256 as a ban marker bit so the
/// request-to-join flow can refuse them later.</summary>
[HttpPost("/club/{clubId:long}/members/ban")]
[Authorize]
public async Task<IActionResult> MemberBan(long clubId, [FromBody] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
if (target == club.CreatorPlayerId)
return BadRequest(new { error = "cannot_ban_owner" });
await clubs.UpsertMembershipAsync(clubId, target, 256); // Ban marker
return Ok();
}
[HttpPost("/club/{clubId:long}/members/unban")]
[Authorize]
public async Task<IActionResult> MemberUnban(long clubId, [FromBody] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
await clubs.RemoveMembershipAsync(clubId, target);
return Ok();
}
/// <summary>POST <c>/club/{id}/members/changetype</c> — promote /
/// demote a member's role. Body: PlayerId + MembershipType (the
/// wire enum, NOT the perms int — we translate via
/// <see cref="ClubService.PermsFromMembershipType"/>).</summary>
[HttpPost("/club/{clubId:long}/members/changetype")]
[HttpPut("/club/{clubId:long}/members/changetype")]
[Authorize]
public async Task<IActionResult> MemberChangeType(long clubId, [FromBody] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (club.CreatorPlayerId != Me) return Forbid(); // Owner-only — perms transitions are sensitive
var target = req.PlayerId ?? req.AccountId ?? 0;
var newType = req.MembershipType ?? ClubService.MembershipTypeMember;
if (target == 0) return BadRequest(new { error = "missing_player_id" });
if (target == club.CreatorPlayerId)
return BadRequest(new { error = "cannot_change_owner_role" });
await clubs.UpsertMembershipAsync(clubId, target, ClubService.PermsFromMembershipType(newType));
return Ok();
}
// ── members/bulk + clubreporting ─────────────────────────────────
/// <summary>GET <c>/members/bulk?clubId=N&amp;playerIds=A&amp;playerIds=B</c>
/// — bulk membership lookup. Wire shape is
/// <c>List&lt;JHMMGLNJHIB&gt;</c> with each entry carrying
/// <c>AccountId</c> + <c>MembershipType</c>.</summary>
[HttpGet("/members/bulk")]
[HttpPost("/members/bulk")]
public async Task<IActionResult> MembersBulk()
{
var form = Request.HasFormContentType ? Request.Form : null;
if (!long.TryParse(Request.Query["clubId"].ToString(), out var clubId)
&& (form is null || !long.TryParse(form["clubId"].ToString(), out clubId)))
return BadRequest(new { error = "missing_club_id" });
var idsRaw = form is null
? Request.Query["playerIds"].AsEnumerable()
: Request.Query["playerIds"].Concat(form["playerIds"]);
var ids = idsRaw
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(s => long.TryParse(s, out var n) ? n : 0)
.Where(n => n != 0)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await clubs.MembershipsBulkAsync(clubId, ids);
return Ok(rows.Select(m => new
{
AccountId = (int)m.PlayerId,
MembershipType = ClubService.MembershipTypeFromPerms(m.Permissions),
}));
}
public sealed class ClubReportRequest
{
public long? ClubId { get; set; }
public int? Category { get; set; }
public string? Details { get; set; }
}
/// <summary>POST <c>/api/clubreporting/v1/report</c> — file a
/// moderation report against a club. Persists to the same
/// <see cref="DorkNet.Server.Data.Entities.ReportEntity"/> table
/// the admin queue reads from; the controller prefixes the message
/// with "[club {id}]" so admins can tell club reports apart from
/// player reports without a schema change.</summary>
[HttpPost("/api/clubreporting/v1/report")]
[Authorize]
public async Task<IActionResult> ClubReport([FromBody] ClubReportRequest req)
{
if (req.ClubId is not long cid) return BadRequest(new { error = "missing_club_id" });
await clubs.AddClubReportAsync(cid, Me, req.Category ?? 0, req.Details ?? string.Empty);
return Ok();
}
// ── Announcements (per-club feed + per-announcement) ─────────────
/// <summary>GET <c>/announcements/club/{clubId}</c> — full
/// announcement feed for the club. Wire shape mirrors the unread
/// rollup (<c>HPACLJHLHBG</c> envelope) but holds every visible
/// announcement, newest first.</summary>
[HttpGet("/announcements/club/{clubId:long}")]
public async Task<IActionResult> AnnouncementsForClub(long clubId)
{
var rows = await clubs.AnnouncementsForClubAsync(clubId);
return Ok(GroupByClub(rows));
}
/// <summary>GET <c>/announcements/club/{clubId}/{aid}</c> — single
/// announcement read. Returns the inner Announcement wire object
/// (<c>NFEMLMAFFIP</c>) without the per-club envelope.</summary>
[HttpGet("/announcements/club/{clubId:long}/{announcementId:long}")]
public async Task<IActionResult> AnnouncementSingle(long clubId, long announcementId)
{
var row = await clubs.AnnouncementAsync(clubId, announcementId);
if (row is null) return NotFound();
return Ok(ToWireAnnouncement(row));
}
/// <summary><c>/announcements/club/{clubId}/{aid}</c> delete —
/// register DELETE, PUT, and POST so the watch's mutation verb
/// reaches the same handler regardless of how its request-builder
/// shaped the call. The service is idempotent on missing rows so
/// double-fires are safe.</summary>
[HttpDelete("/announcements/club/{clubId:long}/{announcementId:long}")]
[HttpPost("/announcements/club/{clubId:long}/{announcementId:long}")]
[HttpPut("/announcements/club/{clubId:long}/{announcementId:long}")]
[Authorize]
public async Task<IActionResult> AnnouncementDelete(long clubId, long announcementId)
{
if (!await clubs.DeleteAnnouncementAsync(clubId, announcementId, Me)) return Forbid();
return Ok();
}
// ── Wire-shape mappers ──────────────────────────────────────────
/// <summary>
/// Build the PIHMJGCGNLP envelope around a single club row. Used
/// by /club/{id}/details, /clubhouse, and every modify* handler.
/// MyMembershipType is derived from the caller's row (or
/// NotAMember sentinel when the caller has no row).
/// </summary>
private async Task<IActionResult> BuildDetailsResponseAsync(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
var memberCount = await clubs.MemberCountAsync(clubId);
var pid = ControllerBaseExtensions.CurrentPlayerId(User);
var myType = ClubService.MembershipTypeNotMember;
if (pid is long me)
{
var membership = await clubs.MembershipForAsync(clubId, me);
if (membership is not null)
myType = ClubService.MembershipTypeFromPerms(membership.Permissions);
// Owner shortcut — the creator row may not exist (legacy data) but the
// creator should always read as Owner.
if (club.CreatorPlayerId == me) myType = ClubService.MembershipTypeOwner;
}
return Ok(new
{
// PIHMJGCGNLP deserializer at PIHMJGCGNLP.txt:143-194 reads
// 7 fields off the response dict, not the 5 the parser-keys
// doc lists. Missing any throws KeyNotFoundException —
// CustomTags (List<String>) and AdditionalImages
// (List<FKFAKOKIEGN>) need to be present even if empty.
Club = ToWireClub(club, memberCount),
CustomTags = Array.Empty<string>(),
AdditionalImages = Array.Empty<object>(),
CoownerPermissions = PermissionsForRole(ClubService.MembershipTypeCoOwner),
ModeratorPermissions = PermissionsForRole(ClubService.MembershipTypeModerator),
MemberPermissions = PermissionsForRole(ClubService.MembershipTypeMember),
MyMembershipType = myType,
});
}
/// <summary>
/// Bitmask of capabilities a given role has on a club. The bits
/// are arbitrary (the wire enum <c>JHEEFBMODPG</c> is opaque in
/// the readable dump) but stay consistent so the watch's
/// role-permissions screen renders the same checklist on every
/// refresh. Owner / Coowner can manage all; Moderator can manage
/// content; Member can read.
/// </summary>
private static int PermissionsForRole(int membershipType) => membershipType switch
{
ClubService.MembershipTypeOwner => 0x7FFF, // everything
ClubService.MembershipTypeCoOwner => 0x7FFE, // all except disband
ClubService.MembershipTypeModerator => 0x00FF, // moderation only
ClubService.MembershipTypeMember => 0x0007, // post + read + react
_ => 0x0001, // read-only
};
private static object ToWireMembership(ClubMembershipEntity row) => new
{
AccountId = (int)row.PlayerId,
ClubId = row.ClubId,
MembershipType = ClubService.MembershipTypeFromPerms(row.Permissions),
CreatedAt = row.JoinedAt,
};
private static ClubEntity EmptyClub() => new()
{
Id = 0,
Name = string.Empty,
Description = string.Empty,
CreatorPlayerId = 0,
ImageName = string.Empty,
};
/// <summary>
/// Project a <see cref="ClubEntity"/> to the 2020.12 Club wire
/// shape. JSON keys + types match the
/// <c>PLILLKHMNDA.Deserialize</c> body in ISIL exactly:
/// ClubId/State/CreatorAccountId/Visibility/Joinability/MemberCount
/// are ints, Name/Description/MainImageName/Category are strings,
/// AllowJuniors/IsRRO are bools, ClubhouseRoomId is nullable long.
/// </summary>
private static object ToWireClub(ClubEntity c, int memberCount) => new
{
ClubId = c.Id,
c.Name,
c.Description,
MainImageName = c.ImageName,
State = c.State,
CreatorAccountId = (int)c.CreatorPlayerId,
Category = c.Category,
Visibility = c.Visibility,
Joinability = c.Joinability,
AllowJuniors = c.AllowJuniors,
MemberCount = memberCount,
IsRRO = c.IsRRO,
ClubhouseRoomId = c.ClubhouseRoomId,
ClubType = c.ClubType,
};
/// <summary>
/// Project a <see cref="ClubAnnouncementEntity"/> to the 2020.12
/// Announcement wire shape. Keys + types per the
/// <c>NFEMLMAFFIP.Deserialize</c> body in ISIL.
/// </summary>
private static object ToWireAnnouncement(ClubAnnouncementEntity a) => new
{
AnnouncementId = a.Id,
CreatorAccountId = (int)a.AuthorPlayerId,
ClubId = a.ClubId,
a.Title,
a.Body,
a.ImageName,
a.CreatedAt,
Meta = string.Empty,
};
private async Task<CreateClubRequest> ReadCreateClubRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new CreateClubRequest
{
Name = FirstFormValue(form, "name", "Name"),
Description = FirstFormValue(form, "description", "Description"),
MainImageName = FirstFormValue(form,
"mainImageName", "MainImageName", "imageName", "ImageName"),
Category = FirstFormValue(form, "category", "Category"),
};
}
try
{
return await Request.ReadFromJsonAsync<CreateClubRequest>(JsonOptions)
?? new CreateClubRequest();
}
catch (JsonException)
{
return new CreateClubRequest();
}
}
private static string? FirstFormValue(IFormCollection form, params string[] names)
{
foreach (var name in names)
{
var value = form[name].ToString();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
return null;
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Group a flat list of announcements into the per-club
/// <c>HPACLJHLHBG</c> envelope shape. Uses
/// <see cref="Dictionary{TKey, TValue}"/> for the outer object
/// because the wire mixes lowercase and PascalCase keys for the
/// same record (<c>clubId</c> + <c>LastReadAnnouncementId</c> +
/// <c>announcements</c>) and anonymous types can't model that.
/// </summary>
private static List<Dictionary<string, object?>> GroupByClub(
List<ClubAnnouncementEntity> rows)
{
return rows
.GroupBy(a => a.ClubId)
.Select(g =>
{
var ordered = g.OrderByDescending(a => a.CreatedAt).ToList();
return new Dictionary<string, object?>
{
["clubId"] = g.Key,
["LastReadAnnouncementId"] = (long?)null,
["announcements"] = ordered.Select(ToWireAnnouncement).ToList(),
};
})
.ToList();
}
}
@@ -184,14 +184,6 @@ public class CommerceCatalogController(StoreService store, DomainConfig domain)
["appleProductId"] = string.Empty, ["appleProductId"] = string.Empty,
["PSNProductLabel"] = string.Empty, ["PSNProductLabel"] = string.Empty,
["psnProductLabel"] = string.Empty, ["psnProductLabel"] = string.Empty,
// XboxProductId — present in GCBPKHFJKCE+DJGBECJHOKF's
// required-key list at Cpp2IL_ISIL/.../GCBPKHFJKCE_NestedType_DJGBECJHOKF.txt:419.
// We don't ship Xbox, so empty string is correct; missing
// it entirely throws KeyNotFoundException on the watch
// and breaks the whole Shop ("malformed RecNet response"
// → store times out waiting for a usable catalog).
["XboxProductId"] = string.Empty,
["xboxProductId"] = string.Empty,
["IsSingleUse"] = false, ["IsSingleUse"] = false,
["isSingleUse"] = false, ["isSingleUse"] = false,
["DisplayPrice"] = i.Price.ToString(), ["DisplayPrice"] = i.Price.ToString(),
@@ -1,10 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.API.Messages.V2;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services; using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.Match; namespace DorkNet.Server.Controllers.Match;
@@ -39,34 +36,13 @@ public class GoToController(
RoomService rooms, RoomService rooms,
DorkNet.Server.Data.DorkNetDbContext db, DorkNet.Server.Data.DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
JoinTimeoutService joinTimeouts,
ServerSettingsService serverSettings, ServerSettingsService serverSettings,
ILogger<GoToController> logger) : ControllerBase ILogger<GoToController> logger) : ControllerBase
{ {
[HttpPost("/goto/room/{roomName}")] [HttpPost("/goto/room/{roomName}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToRoom( public async Task<ActionResult<MatchmakingResponseDto>> GoToRoom(
string roomName, string roomName,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false, [FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false)
// The December watch signals "make a new instance" via the
// JoinMode form field, NOT CreatePrivateInstance (which it never
// sends — verified on the wire: a "New Private Instance" request
// carries `JoinMode=2` and no CreatePrivateInstance key). Maps to
// RecNet.Matchmaking's GIJOGKJCEKG enum:
// 0 = PublicMatchmaking (join the existing shared instance)
// 1 = PublicNewInstance (fork a fresh PUBLIC instance)
// 2 = PrivateNewInstance (fork a fresh, invite-gated instance)
// Without honouring this, JoinMode 1/2 fell through to the
// default branch and returned the SAME roomInstanceId+photonRoomId
// the player was already standing in. Per the sub-room note below,
// identical instance id + photon room => the watch's transition
// state machine never fires and "Going to <room>" hangs forever
// (the player softlocks; the join-watchdog then times out at 30s).
[FromForm(Name = "JoinMode")] int joinMode = 0,
// Party members the watch wants to bring along (added as repeated
// `AdditionalPlayerIds` form fields by RecNet.Matchmaking — verified
// at Matchmaking.txt:5798-5806). Each gets a follow invite once the
// leader's new instance is built. See NotifyAdditionalPlayersAsync.
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
{ {
// Unknown room name shouldn't silently drop the player into a // Unknown room name shouldn't silently drop the player into a
// synth'd dorm scene any more — that masked typo bug reports // synth'd dorm scene any more — that masked typo bug reports
@@ -93,32 +69,11 @@ public class GoToController(
} }
} }
var resp = await BuildResponseAsync(roomName); var resp = await BuildResponseAsync(roomName);
await ApplyNewInstanceAsync(resp, subRoomId: 0, createPrivateInstance, joinMode); if (createPrivateInstance) await ApplyPrivateInstanceAsync(resp, subRoomId: 0);
await RecordResponseAsync(resp); await RecordResponseAsync(resp);
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
return Ok(resp); return Ok(resp);
} }
[HttpPost("/goto/room")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToRoomFromBody(
[FromForm(Name = "RoomName")] string? roomNameForm,
[FromForm(Name = "roomName")] string? roomNameLower,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false,
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
{
var roomName = roomNameForm
?? roomNameLower
?? Request.Query["RoomName"].FirstOrDefault()
?? Request.Query["roomName"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(roomName))
return BadRequest(new { error = "missing roomName" });
return await GoToRoom(roomName, createPrivateInstance, joinMode, additionalPlayerIds);
}
[HttpPost("/goto/none")]
public IActionResult GoToNone() => Ok();
/// <summary>POST <c>/goto/room/{room}/{subRoom}</c> — sub-room form /// <summary>POST <c>/goto/room/{room}/{subRoom}</c> — sub-room form
/// from <c>RecNet.Matchmaking.GoToSubRoom</c> /// from <c>RecNet.Matchmaking.GoToSubRoom</c>
/// (<c>Cpp2IL_ISIL/.../Matchmaking.txt:4682-4702</c>): /// (<c>Cpp2IL_ISIL/.../Matchmaking.txt:4682-4702</c>):
@@ -136,11 +91,7 @@ public class GoToController(
[HttpPost("/goto/room/{roomName}/{subRoomName}")] [HttpPost("/goto/room/{roomName}/{subRoomName}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToSubRoom( public async Task<ActionResult<MatchmakingResponseDto>> GoToSubRoom(
string roomName, string subRoomName, string roomName, string subRoomName,
[FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false, [FromForm(Name = "CreatePrivateInstance")] bool createPrivateInstance = false)
// See GoToRoom: JoinMode 1/2 = fork a fresh public/private
// instance of this sub-room rather than rejoining the shared one.
[FromForm(Name = "JoinMode")] int joinMode = 0,
[FromForm(Name = "AdditionalPlayerIds")] List<long>? additionalPlayerIds = null)
{ {
// Build (don't record yet) — we need to mutate the RoomInstance // Build (don't record yet) — we need to mutate the RoomInstance
// for the sub-room before presence is recorded, otherwise the // for the sub-room before presence is recorded, otherwise the
@@ -229,14 +180,13 @@ public class GoToController(
// blob, so the sub-scene visually never changes. // blob, so the sub-scene visually never changes.
if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob; if (!string.IsNullOrEmpty(subDataBlob)) resp.RoomInstance.DataBlob = subDataBlob;
// Private/new-instance mutation goes AFTER sub-room mutation so the // Private instance mutation goes AFTER sub-room mutation so the
// PhotonRoomId we register includes the _sub{N} suffix as its // PhotonRoomId we register includes the _sub{N} suffix as its
// base — invitees coming in via /goto/instance/{id} land in the // base — invitees coming in via /goto/instance/{id} land in the
// exact same sub-room photon match, not the lobby's. // exact same sub-room photon match, not the lobby's.
await ApplyNewInstanceAsync(resp, subRoomId: subId, createPrivateInstance, joinMode); if (createPrivateInstance) await ApplyPrivateInstanceAsync(resp, subRoomId: subId);
await RecordResponseAsync(resp); await RecordResponseAsync(resp);
await NotifyAdditionalPlayersAsync(additionalPlayerIds, resp);
return Ok(resp); return Ok(resp);
} }
@@ -293,85 +243,6 @@ public class GoToController(
return Ok(resp); return Ok(resp);
} }
[HttpPost("/goto/club/{clubId:long}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToClub(long clubId)
{
var club = await db.Clubs.AsNoTracking().FirstOrDefaultAsync(c => c.Id == clubId);
if (club is null)
{
return Ok(new MatchmakingResponseDto
{
ErrorCode = 4,
RoomInstance = new RoomInstanceDto(),
});
}
var resp = await BuildResponseAsync("RecCenter");
resp.RoomInstance!.ClubId = club.Id;
await RecordResponseAsync(resp);
return Ok(resp);
}
[HttpPost("/goto/code")]
[HttpPost("/goto/code/{code}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToCode(string? code = null)
{
code ??= Request.HasFormContentType
? (await Request.ReadFormAsync())["code"].FirstOrDefault()
: Request.Query["code"].FirstOrDefault();
code = RoomCodeService.Normalize(code);
if (string.IsNullOrWhiteSpace(code))
{
return Ok(new MatchmakingResponseDto
{
ErrorCode = 40,
RoomInstance = new RoomInstanceDto(),
});
}
var privateIds = await db.PrivateInstances.AsNoTracking()
.Select(p => p.Id)
.ToListAsync();
var instanceId = privateIds.FirstOrDefault(id => RoomCodeService.Generate(id) == code);
if (instanceId == 0)
{
return Ok(new MatchmakingResponseDto
{
ErrorCode = 40,
RoomInstance = new RoomInstanceDto(),
});
}
var resp = await BuildPrivateInstanceJoinResponseAsync(instanceId);
await RecordResponseAsync(resp);
return Ok(resp);
}
[HttpPost("/goto/playlist")]
[HttpPost("/goto/playlist/{playlist}")]
public async Task<ActionResult<MatchmakingResponseDto>> GoToPlaylist(string? playlist = null)
{
playlist ??= Request.Query["playlist"].FirstOrDefault()
?? Request.Query["playlistId"].FirstOrDefault();
var value = (playlist ?? string.Empty).Trim();
var roomName = "RecCenter";
if (!string.IsNullOrWhiteSpace(value))
{
var lowered = value.ToLowerInvariant();
var room = await db.Rooms.AsNoTracking()
.Where(r => r.Accessibility == 2
&& (r.Name.ToLower().Contains(lowered)
|| r.TagsCsv.ToLower().Contains(lowered)))
.OrderByDescending(r => r.HotScore)
.FirstOrDefaultAsync();
if (room is not null) roomName = room.Name;
}
var resp = await BuildResponseAsync(roomName);
await RecordResponseAsync(resp);
return Ok(resp);
}
/// <summary>POST <c>/goto/instance/{roomInstanceId}</c> — invite- /// <summary>POST <c>/goto/instance/{roomInstanceId}</c> — invite-
/// accept route. The watch posts here when joining a private /// accept route. The watch posts here when joining a private
/// match via a Message-attached invite. We resolve the instance /// match via a Message-attached invite. We resolve the instance
@@ -402,7 +273,7 @@ public class GoToController(
var invite = await db.Messages.FirstOrDefaultAsync(m => var invite = await db.Messages.FirstOrDefaultAsync(m =>
m.Id == inviteId m.Id == inviteId
&& m.RecipientPlayerId == callerId.Value && m.RecipientPlayerId == callerId.Value
&& (m.Type == 6 || m.Type == 7 || m.Type == -6 || m.Type == -7)); && (m.Type == 6 || m.Type == 7));
long? roomInstanceId = null; long? roomInstanceId = null;
if (invite is not null && TryParseRoomInstanceId(invite.Body, out var parsed)) if (invite is not null && TryParseRoomInstanceId(invite.Body, out var parsed))
@@ -413,12 +284,12 @@ public class GoToController(
// parallel — verified in the live server log: // parallel — verified in the live server log:
// 09:53:47.161Z DELETE messageIds=[60] // 09:53:47.161Z DELETE messageIds=[60]
// 09:53:47.245Z /goto/invite/60 → [goto-invite] invite 60 not found // 09:53:47.245Z /goto/invite/60 → [goto-invite] invite 60 not found
// Current deletes tombstone invite rows so this should only // DELETE usually wins the race, so the message row is gone
// be needed for invite rows that were hard-deleted by older // by the time we run FirstOrDefaultAsync. The
// builds. The (instance, player) → LatestInviteMessageId // (instance, player) → LatestInviteMessageId mapping that
// mapping that SendInvite writes onto the // SendInvite writes onto the PrivateInstanceInviteeEntity
// PrivateInstanceInviteeEntity survives those old deletions // survives the message deletion and lets us recover
// and lets us recover roomInstanceId here. // roomInstanceId here.
if (roomInstanceId is null) if (roomInstanceId is null)
{ {
var inviteeRoomInstance = await db.PrivateInstanceInvitees var inviteeRoomInstance = await db.PrivateInstanceInvitees
@@ -463,55 +334,7 @@ public class GoToController(
} }
} }
// Two routes: var resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId.Value);
// PRIVATE instance: roomInstanceId resolves to a registered
// PrivateInstance → build a join response pinned to its
// photonRoomId + invite-list enforcement.
//
// PUBLIC room: no PrivateInstance for that id (sender was in
// a public room / a dorm / etc.). Use the message's RoomId
// to look up the room name and route via the standard public
// matchmaking path so the recipient lands in the same room
// (different instance is fine — public rooms aren't pinned
// to one Photon match).
var isPrivate = await privateInstances.GetAsync(roomInstanceId.Value) is not null;
MatchmakingResponseDto resp;
if (isPrivate)
{
resp = await BuildPrivateInstanceJoinResponseAsync(roomInstanceId.Value);
}
else if (invite?.RoomId is long publicRoomId && publicRoomId > 0)
{
var roomName = await db.Rooms
.Where(r => r.Id == publicRoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync();
if (string.IsNullOrWhiteSpace(roomName))
{
logger.LogWarning(
"[goto-invite] invite {InviteId} → no PrivateInstance and Message.RoomId={RoomId} doesn't resolve to a Rooms row; routing to dorm",
inviteId, publicRoomId);
resp = await BuildResponseAsync("DormRoom");
}
else
{
logger.LogInformation(
"[goto-invite] public-room invite {InviteId} → routing caller {Caller} to {RoomName} (RoomId={RoomId})",
inviteId, callerId, roomName, publicRoomId);
resp = await BuildResponseAsync(roomName);
}
}
else
{
// No private instance and the message has no RoomId. This
// shouldn't happen with the current SendInvite (it always
// stamps RoomId), but legacy rows from before the public-
// room invite fix may not have one. Fall back to dorm.
logger.LogWarning(
"[goto-invite] invite {InviteId} → no PrivateInstance and no Message.RoomId; routing to dorm",
inviteId);
resp = await BuildResponseAsync("DormRoom");
}
await RecordResponseAsync(resp); await RecordResponseAsync(resp);
return Ok(resp); return Ok(resp);
} }
@@ -619,11 +442,9 @@ public class GoToController(
/// <summary>POST <c>/goto/player/{playerId}</c> — join the room /// <summary>POST <c>/goto/player/{playerId}</c> — join the room
/// the target player is currently in. /// the target player is currently in.
/// ///
/// For PUBLIC rooms: mirror the target's live RoomInstance. The /// For PUBLIC rooms: mirror the target's room name and rebuild a
/// 2020.12 client reconciles joined players against the exact /// fresh RoomInstance (the caller may end up in a different
/// roomInstanceId/photonRoomId pair it got from matchmaking; rebuilding /// matchmade instance, which is fine for public rooms).
/// a fresh instance here can leave the caller in a different Photon
/// shard than the target and null-ref during the follow-join callback.
/// ///
/// For PRIVATE rooms: this route must NOT bypass the invite list. /// For PRIVATE rooms: this route must NOT bypass the invite list.
/// Earlier we mirrored the target's RoomInstance verbatim under /// Earlier we mirrored the target's RoomInstance verbatim under
@@ -666,49 +487,20 @@ public class GoToController(
} }
// Allowed — mirror the target's RoomInstance so we land // Allowed — mirror the target's RoomInstance so we land
// in the same Photon match. // in the same Photon match.
resp = new MatchmakingResponseDto resp = new MatchmakingResponseDto { ErrorCode = 0, RoomInstance = theirRoom };
{
ErrorCode = 0,
RoomInstance = CloneRoomInstance(theirRoom),
};
} }
else else
{ {
// Public follow-joins still need to land in the target's var roomName = await db.Rooms
// specific live instance, not just the same room name. This .Where(r => r.Id == theirRoom.RoomId)
// preserves sub-room and Photon shard identity for PUN's .Select(r => r.Name)
// player-enter reconciliation. .FirstOrDefaultAsync();
resp = new MatchmakingResponseDto resp = await BuildResponseAsync(roomName ?? "DormRoom");
{
ErrorCode = 0,
RoomInstance = CloneRoomInstance(theirRoom),
};
} }
await RecordResponseAsync(resp); await RecordResponseAsync(resp);
return Ok(resp); return Ok(resp);
} }
private static RoomInstanceDto CloneRoomInstance(RoomInstanceDto room) => new()
{
RoomInstanceId = room.RoomInstanceId,
RoomId = room.RoomId,
SubRoomId = room.SubRoomId,
Location = room.Location,
PhotonRegionId = room.PhotonRegionId,
PhotonRoomId = room.PhotonRoomId,
MaxCapacity = room.MaxCapacity,
IsFull = room.IsFull,
IsPrivate = room.IsPrivate,
IsInProgress = room.IsInProgress,
DataBlob = room.DataBlob,
EventId = room.EventId,
Name = room.Name,
RoomInstanceType = room.RoomInstanceType,
ClubId = room.ClubId,
RoomCode = room.RoomCode,
EncryptVoiceChat = room.EncryptVoiceChat,
};
/// <summary>Record the just-built response against the bearer-token /// <summary>Record the just-built response against the bearer-token
/// player so the next heartbeat reflects the room they joined. /// player so the next heartbeat reflects the room they joined.
/// Anonymous /goto calls (no JWT) skip recording — the heartbeat is /// Anonymous /goto calls (no JWT) skip recording — the heartbeat is
@@ -720,41 +512,21 @@ public class GoToController(
private async Task RecordResponseAsync(MatchmakingResponseDto response) private async Task RecordResponseAsync(MatchmakingResponseDto response)
{ {
if (this.CurrentPlayerId() is not long playerId) return; if (this.CurrentPlayerId() is not long playerId) return;
if (response.RoomInstance is null) return;
var targetRoom = response.RoomInstance; presence.SetRoom(playerId, response.RoomInstance);
var currentRoom = presence.GetRoom(playerId);
var deferPresenceCommit = ShouldDeferPresenceCommit(currentRoom, targetRoom);
joinTimeouts.MarkPending(playerId, targetRoom, deferPresenceCommit);
if (deferPresenceCommit)
{
logger.LogInformation(
"[goto] deferred presence commit player={PlayerId} room={RoomId} subRoom={SubRoomId} fromInstance={FromInstance} toInstance={ToInstance} fromPhoton={FromPhoton} toPhoton={ToPhoton}",
playerId,
targetRoom.RoomId,
targetRoom.SubRoomId,
currentRoom?.RoomInstanceId,
targetRoom.RoomInstanceId,
currentRoom?.PhotonRoomId,
targetRoom.PhotonRoomId);
return;
}
presence.SetRoom(playerId, targetRoom);
presence.MarkActive(playerId);
// Real visit tracking: upsert the per-(room, player) row so // Real visit tracking: upsert the per-(room, player) row so
// we get distinct VisitorCount (unique players ever) AND the // we get distinct VisitorCount (unique players ever) AND the
// gross VisitCount (every join, including rejoins). Skip the // gross VisitCount (every join, including rejoins). Skip the
// canonical dorm (RoomId=1) so per-day boots don't skew it. // canonical dorm (RoomId=1) so per-day boots don't skew it.
if (targetRoom.RoomId != 1) if (response.RoomInstance.RoomId != 1)
await RecordVisitAsync(playerId, targetRoom.RoomId); await RecordVisitAsync(playerId, response.RoomInstance.RoomId);
// Push a presence-update notification to every accepted-friend // Push a presence-update notification to every accepted-friend
// so their watch's "friends" list shows the new room. Cheap // so their watch's "friends" list shows the new room. Cheap
// query — Relationships is small, and we only fan out to the // query — Relationships is small, and we only fan out to the
// actually-connected friends. // actually-connected friends.
await NotifyFriendsOfMoveAsync(playerId, targetRoom); await NotifyFriendsOfMoveAsync(playerId, response.RoomInstance);
// NOTE: we INTENTIONALLY do NOT self-push SubscriptionUpdateRoom // NOTE: we INTENTIONALLY do NOT self-push SubscriptionUpdateRoom
// from /goto. The watch's Rooms.OnSubscriptionUpdateRoom // from /goto. The watch's Rooms.OnSubscriptionUpdateRoom
@@ -774,97 +546,6 @@ public class GoToController(
// first one for any (room, subRoom). // first one for any (room, subRoom).
} }
/// <summary>Party-follow. When the leader's /goto carries
/// <c>AdditionalPlayerIds</c> (party members the watch wants to bring
/// along — added as repeated form fields by RecNet.Matchmaking,
/// Matchmaking.txt:5798-5806), drop a GameInviteV2 (Type 6) into each
/// member's inbox pointing at the leader's freshly-built RoomInstance.
/// Mirrors <see cref="MessagesController.SendInvite"/>: registers each
/// member on the private instance (so /goto/instance|invite accepts
/// them) and pushes a MessageReceived notification carrying
/// {inviteId,name,roomInstanceId} so their watch's GameInviteController
/// follows via /goto/invite/{inviteId}. Without this the leader travels
/// alone and the party silently stays behind — the reported bug. We
/// notify ONLY the members, not the leader (unlike SendInvite's
/// both-sides chat sync): the leader doesn't want an invite to the room
/// they're already entering.</summary>
private async Task NotifyAdditionalPlayersAsync(
List<long>? additionalPlayerIds, MatchmakingResponseDto resp)
{
if (additionalPlayerIds is null || additionalPlayerIds.Count == 0) return;
if (resp.RoomInstance is null) return;
if (this.CurrentPlayerId() is not long leaderId) return;
var targets = additionalPlayerIds
.Where(id => id > 0 && id != leaderId)
.Distinct()
.ToList();
if (targets.Count == 0) return;
var instance = resp.RoomInstance;
var roomName = !string.IsNullOrWhiteSpace(instance.Name) ? instance.Name : "Room";
foreach (var target in targets)
{
// Type 6 = RecNet.Message.MessageType.GameInviteV2 — the watch's
// GameInviteController turns this into goto/invite/{inviteId}.
// First save allocates the Id that BECOMES the inviteId; the
// second backfills the JSON Body once we know it (EF identity
// columns only populate on SaveChanges).
var entry = new MessageEntity
{
SenderPlayerId = leaderId,
RecipientPlayerId = target,
Body = string.Empty,
Type = 6,
RoomId = instance.RoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
// Private instances gate joins on the invitee list; register the
// member so /goto/instance|invite lets them in. Public rooms have
// no registered instance — GoToInvite falls back to the message's
// RoomId, so no registration is needed there.
if (instance.IsPrivate)
await privateInstances.InviteAsync(instance.RoomInstanceId, target, messageId: entry.Id);
// roomInstanceId MUST be a JSON STRING — LitJson NREs on a >int32
// long in the dict cast and aborts the whole Message.Deserialize
// (see the detailed note in MessagesController.SendInvite).
entry.Body = System.Text.Json.JsonSerializer.Serialize(new
{
inviteId = entry.Id,
name = roomName,
roomInstanceId = instance.RoomInstanceId.ToString(System.Globalization.CultureInfo.InvariantCulture),
});
await db.SaveChangesAsync();
var dto = new MessagesController.MessageDto(
Id: entry.Id,
FromPlayerId: entry.SenderPlayerId,
SentTime: entry.SentAt,
Type: entry.Type,
Data: entry.Body,
RoomId: entry.RoomId,
PlayerEventId: null);
await notifications.NotifyAsync(target, PushNotificationId.MessageReceived, dto);
}
logger.LogInformation(
"[goto-party] leader={Leader} room={Room}/\"{Name}\" instance={Instance} isPrivate={Priv} → invited {Count} member(s): {Members}",
leaderId, instance.RoomId, roomName, instance.RoomInstanceId,
instance.IsPrivate, targets.Count, string.Join(",", targets));
}
private static bool ShouldDeferPresenceCommit(RoomInstanceDto? currentRoom, RoomInstanceDto targetRoom) =>
currentRoom is not null
&& targetRoom.IsPrivate
&& currentRoom.RoomId == targetRoom.RoomId
&& currentRoom.SubRoomId == targetRoom.SubRoomId
&& (currentRoom.RoomInstanceId != targetRoom.RoomInstanceId
|| !string.Equals(currentRoom.PhotonRoomId, targetRoom.PhotonRoomId, StringComparison.OrdinalIgnoreCase));
/// <summary>Mutate the response so it represents a fresh invite-only /// <summary>Mutate the response so it represents a fresh invite-only
/// match: forks the PhotonRoomId with a per-(player, nonce) suffix /// match: forks the PhotonRoomId with a per-(player, nonce) suffix
/// so randos can't matchmake into it, sets isPrivate=true, and /// so randos can't matchmake into it, sets isPrivate=true, and
@@ -902,59 +583,6 @@ public class GoToController(
resp.RoomInstance.RoomId, subRoomId, ownerId, inst.InstanceId, inst.PhotonRoomId); resp.RoomInstance.RoomId, subRoomId, ownerId, inst.InstanceId, inst.PhotonRoomId);
} }
/// <summary>Apply the "make a new instance" mutation to a freshly
/// built matchmaking response based on the watch's JoinMode (and the
/// legacy CreatePrivateInstance flag). Does nothing for ordinary
/// PublicMatchmaking joins (JoinMode=0) — those keep the shared,
/// deterministic instance that lets randos matchmake together.
/// • JoinMode=2 / CreatePrivateInstance → fresh invite-gated
/// instance (registered + owned, isPrivate=true).
/// • JoinMode=1 → fresh PUBLIC instance (unique id+photon room,
/// isPrivate=false, not persisted — lives only while populated).
/// Both forks change roomInstanceId AND photonRoomId, which is what
/// the watch's transition state machine needs to actually leave the
/// current match instead of softlocking on "Going to &lt;room&gt;".</summary>
private async Task ApplyNewInstanceAsync(
MatchmakingResponseDto resp, long subRoomId, bool createPrivateInstance, int joinMode)
{
if (createPrivateInstance || joinMode == 2 /* PrivateNewInstance */)
{
await ApplyPrivateInstanceAsync(resp, subRoomId);
}
else if (joinMode == 1 /* PublicNewInstance */)
{
ApplyNewPublicInstance(resp);
}
}
/// <summary>Fork a fresh PUBLIC instance of the room the watch just
/// asked for (JoinMode=PublicNewInstance). Unlike a private instance
/// this isn't persisted or invite-gated — it's discoverable through
/// live <see cref="PlayerPresenceService"/> presence (friends joining
/// via /goto/player) and evaporates once empty. The id is packed the
/// same way as <see cref="PrivateInstanceService.RegisterAsync"/>
/// (nonce in the low 24 bits, roomId/subRoomId in the high bits, kept
/// inside int63 for LitJson) so it can't collide with the shared
/// roomId*100000 instance the player is forking away from. The photon
/// room id gets a <c>_new{nonce}</c> suffix on the existing base so it
/// differs from the shared lobby's room.</summary>
private void ApplyNewPublicInstance(MatchmakingResponseDto resp)
{
var nonce = Random.Shared.Next(0x10000, 0xFFFFFF);
var instanceId = (long)(((ulong)(uint)nonce & 0xFFFFFFul)
^ ((ulong)resp.RoomInstance.RoomId << 24)
^ ((ulong)resp.RoomInstance.SubRoomId << 40));
if (instanceId < 0) instanceId = -instanceId;
resp.RoomInstance.RoomInstanceId = instanceId;
resp.RoomInstance.PhotonRoomId = $"{resp.RoomInstance.PhotonRoomId}_new{nonce:x6}";
resp.RoomInstance.IsPrivate = false;
logger.LogInformation(
"[goto-newpublic] room={Room} sub={Sub} → instId={Id} photon={Photon}",
resp.RoomInstance.RoomId, resp.RoomInstance.SubRoomId,
instanceId, resp.RoomInstance.PhotonRoomId);
}
/// <summary>Upsert the (room, player) <see cref="RoomVisitEntity"/> /// <summary>Upsert the (room, player) <see cref="RoomVisitEntity"/>
/// row for this join. New rows bump /// row for this join. New rows bump
/// <see cref="RoomEntity.VisitorCount"/> (unique-visitor metric); /// <see cref="RoomEntity.VisitorCount"/> (unique-visitor metric);
@@ -1008,8 +636,7 @@ public class GoToController(
private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room) private async Task NotifyFriendsOfMoveAsync(long playerId, RoomInstanceDto room)
{ {
// Honors the global-friends toggle: with it on, every other player is // Honors the global-friends toggle: with it on, every other player is
// a "friend" and gets the presence ping (so the whole small server // a "friend" and gets the presence ping.
// sees each other's room moves).
var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, playerId); var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, playerId);
logger.LogInformation( logger.LogInformation(
"[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)", "[goto-fanout] player={PlayerId} moved to room={RoomId}/{RoomName} → notifying {FriendCount} friend(s)",
@@ -1026,7 +653,6 @@ public class GoToController(
// baked-in DormRoom mapping for unknown names. // baked-in DormRoom mapping for unknown names.
long roomId; long roomId;
string locationId; string locationId;
var maxCapacity = 8; // admin-adjustable per-room cap; 8 = default/fallback
if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase)) if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase))
{ {
// Each player has their own dorm row (created at signup, // Each player has their own dorm row (created at signup,
@@ -1039,7 +665,6 @@ public class GoToController(
var personal = await rooms.EnsurePersonalDormAsync(callerPid); var personal = await rooms.EnsurePersonalDormAsync(callerPid);
roomId = personal.Id; roomId = personal.Id;
locationId = personal.LocationReplicationId; locationId = personal.LocationReplicationId;
maxCapacity = personal.MaxCapacity;
} }
else else
{ {
@@ -1057,7 +682,6 @@ public class GoToController(
{ {
roomId = room.Id; roomId = room.Id;
locationId = room.LocationReplicationId; locationId = room.LocationReplicationId;
maxCapacity = room.MaxCapacity;
} }
else else
{ {
@@ -1076,9 +700,6 @@ public class GoToController(
// load (black screen). // load (black screen).
var isDorm = roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase); var isDorm = roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase);
var dormOwnerId = this.CurrentPlayerId(); var dormOwnerId = this.CurrentPlayerId();
var dormInstanceName = isDorm && dormOwnerId is long ownerForDisplay
? await BuildDormInstanceNameAsync(ownerForDisplay)
: roomName;
var photonRoom = (isDorm && dormOwnerId is long ownerForName) var photonRoom = (isDorm && dormOwnerId is long ownerForName)
? $"^dormroom_p{ownerForName}" ? $"^dormroom_p{ownerForName}"
: $"^{roomName.ToLowerInvariant()}_{roomId}"; : $"^{roomName.ToLowerInvariant()}_{roomId}";
@@ -1105,8 +726,8 @@ public class GoToController(
// Lowercase enum names — that's the wire format Photon uses too. // Lowercase enum names — that's the wire format Photon uses too.
PhotonRegionId = photonRegion, PhotonRegionId = photonRegion,
PhotonRoomId = photonRoom, PhotonRoomId = photonRoom,
Name = dormInstanceName, Name = roomName,
MaxCapacity = maxCapacity, MaxCapacity = 8,
IsFull = false, IsFull = false,
IsPrivate = false, IsPrivate = false,
IsInProgress = true, IsInProgress = true,
@@ -1129,7 +750,7 @@ public class GoToController(
ownerPlayerId: ownerPid, ownerPlayerId: ownerPid,
roomId: resp.RoomInstance.RoomId, roomId: resp.RoomInstance.RoomId,
subRoomId: 0, subRoomId: 0,
baseName: dormInstanceName, baseName: resp.RoomInstance.Name,
location: resp.RoomInstance.Location, location: resp.RoomInstance.Location,
dataBlob: resp.RoomInstance.DataBlob, dataBlob: resp.RoomInstance.DataBlob,
photonRegion: resp.RoomInstance.PhotonRegionId, photonRegion: resp.RoomInstance.PhotonRegionId,
@@ -1142,23 +763,6 @@ public class GoToController(
return resp; return resp;
} }
private async Task<string> BuildDormInstanceNameAsync(long ownerPlayerId)
{
var player = await db.Players.AsNoTracking()
.Where(p => p.Id == ownerPlayerId)
.Select(p => new { p.DisplayName, p.Username })
.FirstOrDefaultAsync();
var name = !string.IsNullOrWhiteSpace(player?.DisplayName)
? player!.DisplayName
: !string.IsNullOrWhiteSpace(player?.Username)
? player!.Username
: $"Player{ownerPlayerId}";
var cleaned = new string(name
.Select(ch => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' ? ch : '_')
.ToArray()).Trim('_');
return string.IsNullOrWhiteSpace(cleaned) ? $"Player{ownerPlayerId}_dorm" : $"{cleaned}_dorm";
}
private async Task<string> ResolveInitialDataBlobAsync(long roomId, bool isDorm, long? dormOwnerId) private async Task<string> ResolveInitialDataBlobAsync(long roomId, bool isDorm, long? dormOwnerId)
{ {
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId); var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.Id == roomId);
@@ -1166,15 +770,16 @@ public class GoToController(
if (isDorm && dormOwnerId is long ownerId) if (isDorm && dormOwnerId is long ownerId)
{ {
var dormBlob = await rooms.ResolveDormDataBlobNameAsync(ownerId, room.Id); var dormBlob = await db.DormStates.AsNoTracking()
.Where(d => d.PlayerId == ownerId)
.Select(d => d.CurrentDataBlobName)
.FirstOrDefaultAsync();
if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob; if (!string.IsNullOrWhiteSpace(dormBlob)) return dormBlob;
} }
if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName; if (!string.IsNullOrWhiteSpace(room.CurrentDataBlobName)) return room.CurrentDataBlobName;
if (room.IsDormRoom) return string.Empty; var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
var customisable = room.CreatorPlayerId != 1;
return customisable ? $"room_{room.Id}_v1.dat" : string.Empty; return customisable ? $"room_{room.Id}_v1.dat" : string.Empty;
} }
@@ -1288,46 +893,8 @@ public class RoomInstanceDto
[JsonPropertyName("eventId")] [JsonPropertyName("eventId")]
public long EventId { get; set; } = 0; public long EventId { get; set; } = 0;
// Raw room name (NO leading "^"). Kept clean because server-side code
// derives other values from it — most importantly
// PrivateInstanceService builds the photonRoomId as "^{baseName}_…"
// from this, and MessagesController uses it verbatim as the invite
// message's display name (the "just the event name" case, no "^").
[JsonIgnore]
public string Name { get; set; } = string.Empty;
// Wire "name" field. The 2020.12 watch's PlayerPresence.FriendlyPresence
// (EFAJBHGDIDD.JDICJBJBMMI → BaseAccountModel.get_FriendlyPresence) shows
// roomInstance.name VERBATIM and its built-in default is the literal
// "^DormRoom" — i.e. a player's current room is expected to carry a
// leading "^". So we prefix on serialize and strip on deserialize
// (PlayerPresenceService round-trips this DTO through Redis), keeping the
// C# Name raw. Empty stays empty so the watch falls back to its own
// "^DormRoom" default. Invite MESSAGES keep the plain name — that's a
// separate field built from the raw Name in MessagesController.
[JsonPropertyName("name")] [JsonPropertyName("name")]
public string NameWire public string Name { get; set; } = string.Empty;
{
get => string.IsNullOrEmpty(Name) || Name.StartsWith('^') ? Name : "^" + Name;
set => Name = !string.IsNullOrEmpty(value) && value.StartsWith('^') ? value[1..] : value;
}
// 2020.12 added these keys to RoomInstance.Deserialize. Verified via
// Cpp2IL ISIL on CIIBGMBOFEI.PPGFHEDFBEA (the obfuscated RoomInstance
// class). Missing → "Malformed Response" in matchmaking.
[JsonPropertyName("roomInstanceType")]
public int RoomInstanceType { get; set; } = 0;
[JsonPropertyName("clubId")]
public long ClubId { get; set; } = 0;
[JsonPropertyName("roomCode")]
public string RoomCode { get; set; } = string.Empty;
// NOTE: PascalCase 'E'. The 2020.12 ISIL literally loads "EncryptVoiceChat"
// (not "encryptVoiceChat"). LitJson is case-sensitive.
[JsonPropertyName("EncryptVoiceChat")]
public bool EncryptVoiceChat { get; set; } = false;
/// <summary> /// <summary>
/// Returns a copy of <paramref name="room"/> with <c>photonRoomId</c> /// Returns a copy of <paramref name="room"/> with <c>photonRoomId</c>

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